Plugins

The open bi server can be extended with plugins written in .NET. A plugin is a class library whose compiled assembly is copied into the OPENBI\HttpServer\plugins directory. All assemblies of that directory are loaded on startup, into their own assembly load context, and their extension points are picked up automatically.

Create a plugin

A plugin is a class library which targets the same .NET version as the open bi server, see open bi server versions.

The server assemblies are not published on NuGet, so a plugin references the assemblies of the installation directory directly. Only reference the assemblies the plugin actually uses, ibssolution.bioxRepository.dll and BiExcellence.OpenBi.Server.License.Abstractions.dll are always needed.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0-windows</TargetFramework>
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <!-- References to open bi server -->
  <ItemGroup>
    <Reference Include="BiExcellence.OpenBi.Server.License.Abstractions">
      <HintPath>C:\OPENBI\BiExcellence.OpenBi.Server.License.Abstractions.dll</HintPath>
    </Reference>
    <Reference Include="ibssolution.bioxRepository">
      <HintPath>C:\OPENBI\ibssolution.bioxRepository.dll</HintPath>
    </Reference>
    <Reference Include="ibssolution.bioxSession">
      <HintPath>C:\OPENBI\ibssolution.bioxSession.dll</HintPath>
    </Reference>
    <Reference Include="Microsoft.Extensions.Logging.Abstractions">
      <HintPath>C:\OPENBI\Microsoft.Extensions.Logging.Abstractions.dll</HintPath>
    </Reference>
  </ItemGroup>

  <!-- Copy dll to C:\OPENBI\HttpServer\plugins after debug build -->
  <Target Name="PostBuild" AfterTargets="PostBuildEvent" Condition=" '$(Configuration)' == 'Debug' ">
    <Copy SourceFiles="$(TargetPath)" DestinationFolder="C:\OPENBI\HttpServer\plugins\" />
  </Target>

</Project>

Adjust the paths if the open bi server is not installed in C:\OPENBI. On Linux use net10.0 without the -windows suffix and the matching runtime identifier.

Because the open bi server is deployed with all its dependencies, every assembly it uses can be referenced the same way, for example HtmlAgilityPack.dll for an HTML item or Microsoft.AspNetCore.Http.Abstractions.dll for an HttpContext.

Assembly Contains
ibssolution.bioxRepository.dll The server itself: IOpenBiStartup, ICommandApiHandler, HtmlItem, IOpenBiCmsApp, CmsDescription, OpenBiSession, OpenBiConfigurationProvider
BiExcellence.OpenBi.Server.License.Abstractions.dll ILicenseManager. Required to reference ibssolution.bioxRepository.dll
ibssolution.bioxSession.dll The session and role model: BioxUser, BioxRole, BioxRoleContentElement, BioxOrganisation, iCommunication
BiExcellence.OpenBi.Server.Plugin.Abstractions.dll IOpenBiPlugin
BiExcellence.OpenBi.Server.Database.Abstractions.dll IDatabaseFactory, IEntityDatabaseFactory, Entity, Field, Where, SelectOptions
BiExcellence.OpenBi.Server.Database.Models.dll The entities of the open bi database, e.g. User, Role, Job
BiExcellence.OpenBi.Server.BatchJob.Abstractions.dll BatchJobHandler
BiExcellence.OpenBi.Server.DataProvider.Abstractions.dll The dataprovider interfaces
BiExcellence.OpenBi.Server.Email.Abstractions.dll ISmtp

The BiExcellence.OpenBi.Api.* packages on NuGet are the client APIs for own applications, not for plugins, see API.

Working examples of the extension points below: github.com/biexcellence/openbi-examples

Deployment

Copy the plugin assembly, and every assembly it depends on which is not already part of the open bi server, into the OPENBI\HttpServer\plugins directory and restart the open bi server. The PostBuild target above does this for a debug build.

Loaded plugins are logged on startup with their name, version and path, and a plugin which fails to load is logged as an error.

Dependency injection

Plugin classes are created by the open bi server, so they can request services through constructor injection.

There are two ways a plugin assembly can be treated, and they are mutually exclusive per assembly:

Without IOpenBiPlugin the assembly is scanned for types and every type which implements an extension point is instantiated automatically. This is the simplest option and needs no registration.

With IOpenBiPlugin the assembly registers its own services, and for the interfaces which are resolved from the service collection (see the table under extension points) only the registered services are used - types of that assembly are no longer instantiated automatically. Use this when a plugin needs its own services, a specific lifetime, or several implementations of one interface.

using BiExcellence.OpenBi.Server.Plugin.Abstractions;
using Microsoft.Extensions.DependencyInjection;

namespace MyCompany.MyPlugin;

public sealed class MyPluginStartup : IOpenBiPlugin
{
    public void ConfigureServices(IServiceCollection serviceCollection)
    {
        serviceCollection.AddSingleton<IMyService, MyService>();

        // with an IOpenBiPlugin in this assembly, the extension points of this
        // assembly have to be registered as well
        serviceCollection.AddSingleton<IOpenBiStartup, MyStartup>();
        serviceCollection.AddSingleton<ICommandApiHandler, MyCommandApiHandler>();
    }
}

Services which are registered by one plugin assembly can be injected into the classes of another plugin assembly.

Available services

All services of the open bi server can be injected, the most useful ones being:

Service Description
ILogger<T> Logging, see logging.
IConfiguration Server settings, see configuration.
IWebHostEnvironment Installation and web root paths, see directory paths.
IEntityDatabaseFactory Database access with entity classes, see database.
IDatabaseFactory Database access with dictionaries, see database.
ISmtp Sends emails with the configured SMTP settings.
ILicenseManager Information about the installed license.
IPluginManager Access to the other loaded plugins.

Extension points

Extension point Purpose Resolved from
IOpenBiStartup Own HTTP endpoints and middleware Service collection
IStartupFilter Own middleware around the whole pipeline Service collection
ICommandApiHandler Own commands of the legacy command API Service collection
IHostedService Background services which start and stop with the server Service collection
IDataProviderListExtender Additional entries in a dataprovider list Service collection
ICmsSitemapExtender Additional URLs in the CMS sitemap Service collection
HtmlItem Own CMS HTML items Type scan
IComponent Own Blazor components for the CMS Type scan
IOpenBiCmsApp Own CMS apps Type scan
BatchJobHandler Own batch jobs Type scan
BioxDataProvider Own dataproviders Type scan

"Service collection" means the extension point has to be registered in ConfigureServices if the assembly contains an IOpenBiPlugin. "Type scan" means the type is always found automatically, with or without an IOpenBiPlugin.

IOpenBiStartup

IOpenBiStartup is called while the server builds its request pipeline and provides the IApplicationBuilder and the IEndpointRouteBuilder of the server, so own endpoints, route groups and middleware can be added.

using Ibssolution.biox.Repositoryserver;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;

namespace MyCompany.MyPlugin;

public sealed class MyStartup : IOpenBiStartup
{
    private readonly ILogger<MyStartup> _logger;

    public MyStartup(ILogger<MyStartup> logger)
    {
        _logger = logger;
    }

    public void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes)
    {
        routes.Map("/custom/endpoint", async context =>
        {
            _logger.LogInformation("Process Custom Endpoint Request");

            await context.Response.WriteAsync("Hello World");
        });

        routes.MapGroup("/custom/group").Map("/endpoint", static async context =>
        {
            await context.Response.WriteAsync("Hello Group");
        });

        app.Use(next => async context =>
        {
            if (!context.Request.Path.StartsWithSegments("/custom/app"))
            {
                await next(context);
                return;
            }

            await context.Response.WriteAsync("Hello Middleware");
        });
    }
}

Scoped services of a request are resolved through context.RequestServices, application wide services through app.ApplicationServices or routes.ServiceProvider.

IStartupFilter

IStartupFilter is the ASP.NET Core interface and wraps the whole pipeline of the server, so it also runs before the CMS and the REST API.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;

namespace MyCompany.MyPlugin;

public sealed class MyStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return app =>
        {
            app.Use(next => async context =>
            {
                if (!context.Request.Path.StartsWithSegments("/custom/filter"))
                {
                    await next(context);
                    return;
                }

                await context.Response.WriteAsync("Hello Filter");
            });

            next(app);
        };
    }
}

ICommandApiHandler

Own commands can be added to the legacy command API (/openbi/xmlprovider/mobile.biex, see API). Return false for every command the handler does not handle so the other handlers can process it, and write the response into the JsonObject. Throw an OpenbiMobileCommandException to return a specific return code.

using Ibssolution.biox.Repositoryserver;
using System.Text.Json.Nodes;

namespace MyCompany.MyPlugin;

public sealed class MyCommandApiHandler : ICommandApiHandler
{
    public Task<bool> ProcessCommandAsync(JsonObject responseObj, sxRequest request, OpenBiSession session, CancellationToken cancellationToken)
    {
        if (request.Command != "CUSTOM_COMMAND")
        {
            return Task.FromResult(false);
        }

        responseObj["COMMAND"] = request.Command;

        if (request.Parameters.TryGetValue("THROW", out var value))
        {
            throw new OpenbiMobileCommandException(value.Value, -100);
        }

        return Task.FromResult(true);
    }
}

New functionality should be exposed over the REST API with an IOpenBiStartup endpoint instead. The command API is only needed for clients which already use it.

HtmlItem

Own CMS items are created by deriving from HtmlItem and adding the HtmlItemTagName attribute. The item is then available in the CMS under that tag name, and its parameters are offered by the Configurator. See the CMS documentation for the common attributes, the templates and the POST actions.

using HtmlAgilityPack;
using Ibssolution.biox.Repositoryserver;
using Microsoft.AspNetCore.Http;
using System.ComponentModel;

namespace MyCompany.MyPlugin;

[HtmlItemTagName("custom:htmlitem")]
[Description("Custom HtmlItem")]
public sealed class CustomHtmlItem : HtmlItem
{
    public CustomHtmlItem(HtmlNode tag)
        : base(tag)
    {
    }

    protected override async Task<string?> GetHtmlFromTagChildAsync(CmsDescription cms, CancellationToken cancellationToken)
    {
        string? template = null;
        if (Attributes.TryGetValue("data-template", out var templateId))
        {
            template = await GetItemTemplateAsync(templateId, cms);
        }
        template ??= HtmlNode.InnerHtml;

        if (Attributes.TryGetValue("data-test", out var testAttributeValue))
        {
            template = template.Replace("%TEST%", testAttributeValue);

            cms.Page.Title = testAttributeValue;
        }

        if (Attributes.ContainsKey("data-hide"))
        {
            IsVisible = false;
        }

        return template;
    }

    protected override async Task ProcessActionChildAsync(CmsDescription cms, string action, CancellationToken cancellationToken)
    {
        cms.HttpContext.Response.Headers["X-Action"] = action;

        await cms.HttpContext.Response.WriteAsync("Hello World");
    }

    protected override void AddParametersToCollection(HtmlItemParameterCollection collection)
    {
        collection.Add(new HtmlItemParameter("data-test", HtmlItemParameterType.Text, "Test Attribute"));
        collection.Add(new HtmlItemParameter("data-template", HtmlItemParameterType.Template, "Test Template"));
    }

    protected override HtmlItemReplacementParameterCollection getReplacementParameters()
    {
        return [
            new HtmlItemReplacementParameter("data-template", "%TEST%", "Test Attribute Value")
        ];
    }
}

GetCss(), GetScripts() and GetBodyClasses() can be overridden to add includes and body classes to the page. A second constructor with additional parameters is used for dependency injection, the constructor with the HtmlNode alone is used when the server only needs the parameters of the item:

public CustomHtmlItem(HtmlNode tag, ILogger<CustomHtmlItem> logger)
    : base(tag)
{
    _logger = logger;
}

Blazor components

Components are used in the CMS with their class name as the tag name. Parameters, child content, query parameters and form values work like in any Blazor component.

using Microsoft.AspNetCore.Components;
using System.ComponentModel;

namespace MyCompany.MyPlugin;

[Description("Custom Component")]
public sealed class CustomComponent : ComponentBase
{
    [Inject] private ILogger<CustomComponent> Logger { get; set; } = null!;

    [Parameter, Description("Test Parameter")] public string? Parameter { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }

    [SupplyParameterFromQuery(Name = "q")] public string? Query { get; set; }
    [SupplyParameterFromForm(FormName = "custom", Name = "field")] public string? Field { get; set; }

    [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }

    protected override void OnInitialized()
    {
        Logger.LogInformation("Custom Component OnInitialized");
    }

    protected override void BuildRenderTree(RenderTreeBuilder builder)
    {
        if (ChildContent is not null)
        {
            builder.OpenElement(0, "div");
            builder.AddMultipleAttributes(1, AdditionalAttributes);
            builder.AddContent(2, ChildContent);
            builder.CloseElement();
        }
        if (Parameter is not null)
        {
            builder.AddContent(3, $"Parameter={Parameter}");
        }
    }
}

The component is then used in the CMS content like this:

<CustomComponent Parameter="test" />
<CustomComponent name="value">Hello World</CustomComponent>

CMS apps

A CMS app claims the first segment of the URL path for itself. The <openbi:cmsapp>, <openbi:cmsapplist> and <openbi:cmsappnavbar> items render the app of the current URL, the list of all apps and the navigation of the current app.

using Ibssolution.biox.Repositoryserver;

namespace MyCompany.MyPlugin;

[OpenBiCmsApp(Id = "customapp", Name = "CustomApp")]
public sealed class CustomOpenBiCmsApp : IOpenBiCmsApp
{
}

Batch job handlers

A batch job handler is a job type which can be scheduled like the system batch jobs, see Batch Jobs. The BatchJobHandler attribute provides the name and description shown in the Configurator, and BatchJobHandlerParameter declares the parameters of the job.

Everything the handler logs into context.Logger becomes part of the job log. Cancel the job cooperatively through the CancellationToken.

using BiExcellence.OpenBi.Server.BatchJob.Abstractions;
using Microsoft.Extensions.Logging;

namespace MyCompany.MyPlugin;

[BatchJobHandler("CUSTOMBATCHHANDLER", "Custom BatchHandler")]
[BatchJobHandlerParameter("TEST", "Test")]
public sealed class CustomBatchHandler : BatchJobHandler
{
    public override async Task RunAsync(IBatchJobHandlerRunContext context, CancellationToken cancellationToken)
    {
        context.Logger.LogInformation("JobID: {JobId}", context.Job.Id);
        context.Logger.LogInformation("JobName: {JobName}", context.Job.Name);
        context.Logger.LogInformation("Username: {Username}", context.User.Identity?.Name);

        foreach (var parameter in context.Parameters)
        {
            context.Logger.LogInformation("Parameter: {Name}={Value}", parameter.Key, parameter.Value);
        }

        await Task.CompletedTask;
    }
}

Override GetCustomRuntimes to schedule the job on runtimes which the periodic settings of a job cannot express.

Background services

IHostedService implementations are started with the server and stopped when it shuts down. BackgroundService from Microsoft.Extensions.Hosting is the easiest base class for a long running task.

using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace MyCompany.MyPlugin;

public sealed class MyBackgroundService : BackgroundService
{
    private readonly ILogger<MyBackgroundService> _logger;

    public MyBackgroundService(ILogger<MyBackgroundService> logger)
    {
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            _logger.LogInformation("Hello World");
        }
    }
}

For work which should be visible, configurable and schedulable by an administrator, use a batch job handler instead.

Logging

Logging uses ILogger<T> from Microsoft.Extensions.Logging. The output goes to the console when the server runs as a console application, into the trace file when tracing is enabled, and into the console of the Configurator.

public sealed class MyService
{
    private readonly ILogger<MyService> _logger;

    public MyService(ILogger<MyService> logger)
    {
        _logger = logger;

        _logger.LogInformation("Hello {Name}", "World");
    }
}

Log exceptions by passing them as the first argument, which also logs the full stack trace:

_logger.LogError(exception, "Failed to process {Count} items", count);

Use the message template with named placeholders instead of string interpolation, so the values stay available as structured data.

Configuration

The server settings from the Configuration.xml file, the environment variables and the command line are read through IConfiguration. Own settings can simply be added to the Configuration.xml file as custom settings. The OpenBiConfigurationProvider class contains the names of the standard settings.

public sealed class MyService
{
    public MyService(IConfiguration configuration)
    {
        string? httpPort = configuration[OpenBiConfigurationProvider.HttpPort];
        string? customSetting = configuration["CUSTOM_SETTING"];
    }
}

Directory paths

IWebHostEnvironment provides the installation directory and the web root of the server.

public sealed class MyService
{
    public MyService(IWebHostEnvironment hostEnvironment)
    {
        string installationPath = hostEnvironment.ContentRootPath;
        string webRootPath = hostEnvironment.WebRootPath;
    }
}

Both can be changed with the CONTENT_DIRECTORY and WEB_DIRECTORY server settings, so never build these paths manually.

Database

There are two ways to access the database of the open bi server, both defined in BiExcellence.OpenBi.Server.Database.Abstractions:

  • IEntityDatabaseFactory works with entity classes and is the recommended way.
  • IDatabaseFactory works with dictionaries and is useful when the columns are only known at runtime.

The entities of the open bi server itself are available in BiExcellence.OpenBi.Server.Database.Models, so a plugin can read and write users, roles, jobs and everything else with the same API.

These field types are supported: String, Integer, Float, Boolean, Date (a DateTimeOffset) and Binary.

A few things to keep in mind:

  • Always dispose the database and the transaction. Both implement IAsyncDisposable, so await using is the shortest way.
  • A transaction is rolled back unless CommitAsync is called.
  • The DefaultValue attribute declares the default of a column: [DefaultValue] alone means NULL, and a value can be given as a constant ([DefaultValue(false)]) or as one of DefaultValue.Guid and DefaultValue.CurrentTimestamp. Without the attribute the column gets no default.
  • Indexes, including unique ones, are declared with the Index attribute on the entity.
  • There are no foreign keys in the database. Cascading deletes are done in code.

IEntityDatabaseFactory

An entity is a class which derives from Entity, is mapped to a table with the TableName attribute and maps its properties with the Column attribute. The key columns are marked with the Key attribute. Provide a parameterless constructor so the entity can be selected.

DefaultValueAttribute exists in System.ComponentModel as well, so alias it when both namespaces are imported: using DefaultValueAttribute = BiExcellence.OpenBi.Server.Database.Abstractions.DefaultValueAttribute;

using BiExcellence.OpenBi.Server.Database.Abstractions;

namespace MyCompany.MyPlugin;

[TableName("TEST_TABLE")]
[Index(nameof(Name), IsUnique = true)]
public sealed class TestTable : Entity
{
    [Column("ID", Size = 36), Key, DefaultValue(DefaultValue.Guid)]
    public string Id { get => GetValue<string>()!; set => SetValue(value); }
    [Column("NAME"), DefaultValue]
    public string? Name { get => GetValue<string>(); set => SetValue(value); }
    [Column("INTEGER"), DefaultValue(0)]
    public int Integer { get => GetValue<int>(); set => SetValue(value); }
    [Column("FLOAT"), DefaultValue(0d)]
    public double Float { get => GetValue<double>(); set => SetValue(value); }
    [Column("DATE"), DefaultValue(DefaultValue.CurrentTimestamp)]
    public DateTimeOffset Date { get => GetValue<DateTimeOffset>(); set => SetValue(value); }
    [Column("BINARY"), DefaultValue]
    public byte[]? Binary { get => GetValue<byte[]>(); set => SetValue(value); }
}

The following sample demonstrates the whole API:

public sealed class MyService
{
    private readonly IEntityDatabaseFactory _entityDatabaseFactory;

    public MyService(IEntityDatabaseFactory entityDatabaseFactory)
    {
        _entityDatabaseFactory = entityDatabaseFactory;
    }

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        var entity = new TestTable
        {
            Id = Guid.NewGuid().ToString(),
            Name = "test",
            Integer = 123,
            Float = 123.456,
            Date = DateTimeOffset.UtcNow,
            Binary = [1, 2, 3],
        };

        await using (var database = _entityDatabaseFactory.GetDatabase())
        await using (var transaction = await database.TransactionAsync(cancellationToken))
        {
            // CREATE / UPDATE TABLE
            await transaction.MigrateAsync<TestTable>(cancellationToken);

            // INSERT OR UPDATE
            int savedCount = await transaction.SaveAsync(entity, cancellationToken);

            // UPDATE
            var update = new TestTable { Name = "Hello World" };
            var updateWhere = Where.AndGroup().Equal(Identifier.Entity<TestTable>.Column(t => t.Name), "test");

            int updatedCount = await transaction.UpdateAsync(update, updateWhere, cancellationToken);

            // SELECT
            var selectOptions = new SelectOptions();
            selectOptions.Fields.Add(new SelectField(Identifier.Entity<TestTable>.Column(t => t.Id)));
            selectOptions.Fields.Add(new SelectField(Identifier.Entity<TestTable>.Column(t => t.Name), "MYNAME"));
            selectOptions.Fields.Add(new SelectField(new Literal("1+1"), "CALC"));
            selectOptions.OrderBy.Add(new OrderByField(Identifier.Entity<TestTable>.Column(t => t.Date), OrderByDirection.Desc));
            selectOptions.Offset = 0;
            selectOptions.Count = 5;

            ISelectResult<TestTable> result = await transaction.SelectAsync<TestTable>(selectOptions, cancellationToken);

            foreach (var row in result)
            {
                Console.WriteLine(row.Name);
            }

            // SELECT A SINGLE ROW
            var idWhere = Where.AndGroup().Equal(Identifier.Entity<TestTable>.Column(t => t.Id), entity.Id);

            TestTable? single = await transaction.SelectFirstAsync<TestTable>(idWhere, cancellationToken);

            // DELETE
            var deleteWhere = Where.AndGroup().Equal(Identifier.Entity<TestTable>.Column(t => t.Integer), 123);

            int deletedCount = await transaction.DeleteAsync<TestTable>(deleteWhere, cancellationToken);

            // COMMIT
            await transaction.CommitAsync(cancellationToken);
        }
    }
}

SaveAsync inserts a row which does not exist yet and updates it otherwise, InsertAsync always inserts. QueryAsync returns an IAsyncEnumerable instead of reading the whole result into memory, which is the better choice for large results:

await foreach (var row in database.QueryAsync<TestTable>(where).WithCancellation(cancellationToken))
{
    Console.WriteLine(row.Name);
}

A transaction is not needed for reading, and GetDatabase() can be used directly:

await using (var database = _entityDatabaseFactory.GetDatabase())
{
    var users = await database.SelectAsync<User>(Where.AndGroup().Equal(Identifier.Entity<User>.Column(u => u.IsActive), true), cancellationToken);
}

IDatabaseFactory

The low level API reads and writes rows as dictionaries and takes the table name and the fields as arguments.

public sealed class MyService
{
    private readonly IDatabaseFactory _databaseFactory;

    public MyService(IDatabaseFactory databaseFactory)
    {
        _databaseFactory = databaseFactory;
    }

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        var fields = new List<IField>
        {
            new Field("ID", FieldType.String, KeyType.Key),
            new Field("NAME", FieldType.String),
            new Field("INTEGER", FieldType.Integer),
            new Field("FLOAT", FieldType.Float),
            new Field("DATE", FieldType.Date),
            new Field("BINARY", FieldType.Binary),
        };

        var values = new Dictionary<string, object?>
        {
            ["ID"] = Guid.NewGuid().ToString(),
            ["NAME"] = "test",
            ["INTEGER"] = 123,
            ["FLOAT"] = 123.456,
            ["DATE"] = DateTimeOffset.UtcNow,
            ["BINARY"] = new byte[] { 1, 2, 3 },
        };

        await using (var database = _databaseFactory.GetDatabase())
        {
            bool tableExists = await database.TableExistsAsync("TEST_TABLE", cancellationToken);

            await using (var transaction = await database.TransactionAsync(cancellationToken))
            {
                // CREATE / UPDATE TABLE
                await transaction.MigrateTableAsync(fields, "TEST_TABLE", cancellationToken);

                // INSERT
                int insertedCount = await transaction.InsertAsync(values, "TEST_TABLE", cancellationToken);

                // UPDATE
                var update = new Dictionary<string, object?> { ["NAME"] = "Hello World" };
                var updateWhere = Where.AndGroup().Equal(new Identifier("NAME"), "test");

                int updatedCount = await transaction.UpdateAsync(update, updateWhere, "TEST_TABLE", cancellationToken);

                // SELECT
                var selectOptions = new SelectOptions();
                selectOptions.Fields.Add(new SelectField(new Identifier("TEST_TABLE", Identifier.Star)));
                selectOptions.Fields.Add(new SelectField(new Identifier("NAME"), "MYNAME"));
                selectOptions.Fields.Add(new SelectField(new Literal("1+1"), "CALC"));
                selectOptions.OrderBy.Add(new OrderByField(new Identifier("DATE"), OrderByDirection.Desc));
                selectOptions.Offset = 0;
                selectOptions.Count = 5;

                ISelectResult<IEntity> result = await transaction.SelectAsync(selectOptions, "TEST_TABLE", cancellationToken);

                foreach (var row in result)
                {
                    Console.WriteLine(row.Attributes["NAME"]);
                }

                // DELETE
                var deleteWhere = Where.AndGroup().Equal(new Identifier("INTEGER"), 123);

                int deletedCount = await transaction.DeleteAsync(deleteWhere, "TEST_TABLE", cancellationToken);

                // DROP TABLE
                await transaction.DropTableAsync("TEST_TABLE", cancellationToken);

                // COMMIT
                await transaction.CommitAsync(cancellationToken);
            }
        }
    }
}

GetFieldsAsync returns the columns of an existing table, which is how a plugin can work with tables whose structure it does not know at compile time.

Client API

To talk to an open bi server from an own application, use the REST API and its .NET client instead of writing a plugin. See the API documentation.