Skip to content

Semantic model

The semantic model is what you, the developer, declare: which fields of your data users can analyze, under which names. Users never see your classes or your tables, only the model.

SemanticModel.For(sales, "sales", "Sales")
  .Dimension(s => s.Date, "Date")
  .Dimension(s => s.Region, "Region")
  .Dimension(s => s.Channel, "Channel")
  .Dimension(s => s.Product.Category,
             "Category")
  .Dimension(s => s.Product.Name,
             "Product")
  .Measure(s => s.Amount, "Revenue",
           format: "#,##0 €")
  .Measure(s => s.Margin, "Margin",
           format: "#,##0 €")
  .Measure(s => s.Quantity, "Quantity")
  .Count("Number of sales")
  .Ratio(s => s.Margin, s => s.Amount,
         "Margin rate", "MarginRate",
         format: "P1");

The fields of the sample's model in the designer: dimensions, then measures The fields of the sample's model in the designer: dimensions, then measures

Dimensions

A dimension is what users group or filter by: a region, a channel, a date, a product category.

.Dimension(s => s.Region, "Region")
.Dimension(s => s.Product.Category, "Category")          // (1)!
.Dimension(s => s.Customer.Country, "Country", name: "Country") // (2)!
  1. Navigation properties work: with EF Core, they become joins in SQL.
  2. The name is the member path by default (Customer.Country); give your own to keep reports stable when the class changes.

What users can do with a dimension depends on its type:

Type Examples Users can
Text string, enum Group, filter on values, search
Number int, decimal, double Group, filter on values or ranges (≥, <, between)
Date DateTime, DateOnly, DateTimeOffset Group by year, quarter, month or day; filter on periods (this year, last 12 months, between)
Boolean bool Group, filter on yes / no

Measures

A measure is what users add up: a revenue, a quantity, a number of sales.

.Measure(s => s.Amount, "Revenue", format: "#,##0 €")              // summed by default
.Measure(s => s.UnitPrice, "Unit price", Aggregation.Average, "C2") // (1)!
.Count("Number of sales")                                          // (2)!
  1. The default aggregation; users can switch to Sum, Average, Min, Max or Count in the designer.
  2. Counts rows. Its name is Count unless you give another.

Formats are .NET format strings, applied in the user's culture: N0 (12,345), #,##0 € (12,345 €), C2 (currency of the culture), P1 (12.3%). Users can override them per visual, with conditional formatting too.

Calculated measures

A ratio cannot be summed: the margin rate of a region is its total margin divided by its total revenue, not the average of its sales' rates. Calculated measures are computed per group, inside the query:

Method For Example
Ratio A sum divided by a sum Margin rate, average basket
Calculated Any aggregate expression g => g.Sum(s => s.Amount) - g.Sum(s => s.Cost)
Formula A formula users can read and rewrite SUM([Amount]; [Channel] = "Web") / [Amount]

Users create their own too. See Calculated measures.

Names and captions

Every model and field has two labels:

Seen by Example Must
Name Reports (JSON), your code sales, Amount, Product.Category Stay stable: saved reports refer to it
Caption Users Sales, Revenue, Category Read well; can follow the language

Renaming a property breaks saved reports

A field's name defaults to its member path. Before renaming a property of your class, give the field its old name explicitly (name: "Amount"), or saved reports will show the field as missing (EasyBIContext.Validate lists such problems).

Captions can follow the user's language: call .Localize(MyStrings.ResourceManager) and every caption becomes a resource key. See Localization.

Data sources

SemanticModel.For(sales, "sales", "Sales")   // any IEnumerable<T>

Queries run in LINQ to objects, in parallel.

SemanticModel.For(() => dbFactory.CreateDbContext(), db => db.Sales, "sales", "Sales")

Each query opens its own DbContext (from an IDbContextFactory) and disposes it: the visuals of a dashboard query in parallel.

SemanticModel.For(dbContext.Sales, "sales", "Sales")

One DbContext for every query: they run one after another, since a DbContext does not support concurrent operations.

Every visual becomes one query, with its filters, grouping, aggregates, sort and Top N inside the IQueryable. With EF Core, that is one SQL statement: only the aggregated rows reach the application, whatever the size of the table.

The SQL of a visual

Revenue by region, top 3, on SQL Server, is roughly:

SELECT TOP(3) [s].[Region], SUM([s].[Amount])
FROM [Sales] AS [s]
GROUP BY [s].[Region]
ORDER BY SUM([s].[Amount]) DESC

Keep dimension and measure expressions translatable by your provider: a method call EF Core cannot translate fails when the visual queries.

Several models

Add as many models as you need; the designer lets users pick one, and each visual uses a single model.

EasyBIContext.Create()
    .AddModel(salesModel)
    .AddModel(stockModel)
    .UseRenderer(new LiveCharts2Renderer());

When the data changes

Visuals cache their last result and query again only when their own query changes. When the rows change (an import, another data source), tell them, from any thread:

context.RefreshData("sales");   // the visuals of this model
context.RefreshData();          // every visual

When a query fails

A visual whose query throws (a database out of reach, an expression EF Core cannot translate) or whose renderer throws does not show the exception: it says it could not be displayed, with Retry, which runs the query again, and See what happened.

A dashboard visual that could not be displayed: an error icon, the message, Retry and See what happened A dashboard visual that could not be displayed: an error icon, the message, Retry and See what happened

The details tell what failed (the query or the renderer) and give the visual, the version of EasyBI and the exception with its stack trace, ready to copy into a bug report.

The details of the failure: what failed, the exception's message and the technical details, with Copy and Close The details of the failure: what failed, the exception's message and the technical details, with Copy and Close

EasyBIContext.VisualFailed tells the application first: log the failure, say what happened in the user's words, or keep the details from the user.

context.VisualFailed += (_, e) =>
{
    logger.LogError(e.Exception, "Visual {Title} failed ({Stage})", e.Visual.Title, e.Stage);
    if (e.Exception is TimeoutException) e.Message = "The sales database is not answering. Try again in a moment.";
    e.ShowDetails = false;   // no technical details in production
};

e.Stage is Query or Render (with e.Renderer), and e.Details is the text the details show. The sample shows it with its Unavailable source data source.

Your own data source

A visual asks its model for its data, not for queries: ISemanticModel.GetDataAsync receives a VisualDataRequest and answers with a VisualData. Where the data comes from, and how the request travels, is up to the model: SemanticModel.For runs it in the application over an IQueryable; another model can send it to a server, which answers with the visual's result.

VisualDataRequest
Visual The visual, with the filters of its page and the selections of other visuals applied
Measures The end user's calculated measures it uses, with those they use
HasTooltips Whether its type shows tooltips, whose measures are queried with it
Today The user's date: relative periods (this year, last 12 months) and comparisons with a year earlier
UICulture, FormatCulture The cultures of the captions and of the texts of values in the result (null for the model's own)

VisualData holds the visual's ResultSet and, for a matrix, its pivoted MatrixData. The request only holds data that serializes; the drawing (chart data, theme, renderer) always stays in the application.

public sealed class SqlSalesModel : QuerySemanticModel
{
    public override ModelMetadata Metadata { get; } = ...;

    public override Task<ResultSet> ExecuteAsync(AnalyticQuery query, CancellationToken cancellationToken = default)
        => ...; // filters, groups, aggregates, sort, Top and calculated measures

    public override Task<DistinctValues> GetDistinctValuesAsync(DistinctValuesRequest request, CancellationToken cancellationToken = default)
        => ...; // at most request.Limit values, those containing request.Search when it is given
}

QuerySemanticModel turns the request into AnalyticQuerys and composes their results: comparisons with a year earlier, year-to-date totals, the levels and totals of a matrix. Periods relative to today reach ExecuteAsync as date ranges, on the request's date.

public sealed class RemoteSalesModel(HttpClient http, ModelMetadata metadata) : ISemanticModel
{
    public ModelMetadata Metadata { get; } = metadata; // (1)!

    public async Task<VisualData> GetDataAsync(VisualDataRequest request, CancellationToken cancellationToken = default)
    {
        using var content = new StringContent(DataSerializer.Serialize(request), Encoding.UTF8, "application/json");
        using var response = await http.PostAsync("sales/data", content, cancellationToken);
        response.EnsureSuccessStatusCode();
        return DataSerializer.DeserializeData(await response.Content.ReadAsStringAsync());
    }

    public async Task<DistinctValues> GetDistinctValuesAsync(DistinctValuesRequest request, CancellationToken cancellationToken = default)
    {
        using var content = new StringContent(DataSerializer.Serialize(request), Encoding.UTF8, "application/json");
        using var response = await http.PostAsync("sales/values", content, cancellationToken);
        response.EnsureSuccessStatusCode();
        return DataSerializer.DeserializeValues(await response.Content.ReadAsStringAsync(), Metadata.GetField(request.Field).DataType);
    }
}
  1. Read once from the server: DataSerializer.DeserializeMetadata(await http.GetStringAsync("sales/metadata")).

The server answers with any ISemanticModel, here ASP.NET Core over EF Core:

var sales = SemanticModel.For(() => new SalesDbContext(options), db => db.Sales, "sales", "Sales")...;

app.MapGet("/sales/metadata", () => Results.Json(sales.Metadata, DataSerializer.Options));
app.MapPost("/sales/data", async (HttpRequest http, CancellationToken cancellationToken) =>
{
    var request = await JsonSerializer.DeserializeAsync<VisualDataRequest>(http.Body, DataSerializer.Options, cancellationToken);
    return Results.Json(await sales.GetDataAsync(request!, cancellationToken), DataSerializer.Options);
});
app.MapPost("/sales/values", async (HttpRequest http, CancellationToken cancellationToken) =>
{
    var request = await JsonSerializer.DeserializeAsync<DistinctValuesRequest>(http.Body, DataSerializer.Options, cancellationToken);
    return Results.Json(await sales.GetDistinctValuesAsync(request!, cancellationToken), DataSerializer.Options);
});

DataSerializer writes the request, the data, the metadata and the distinct values as plain JSON (see Data format): the server can be written in another language. The routes, the transport (HTTP, gRPC, a message bus...) and its security are yours. The sample does exactly this: see samples/EasyBI.Sample.Server and the Server over HTTP data source.

VisualPipeline.PrepareAsync asks a model for a visual's data and builds what renderers draw; VisualPipeline.Prepare does it with data obtained otherwise.