HomeFeaturesPluginsDocsDownload

Building a provider

A provider plugin teaches DataTray to talk to one database engine. It implements a single interface, IDbProvider, from the public SDK — which is MIT-licensed precisely so anyone can build and ship an engine without asking us.

A provider references only DataTray.Sdk. No reference to the host's Core or App assemblies is needed — or allowed. That boundary is what lets a plugin carry its own driver version without colliding with anyone else's.

The contract: IDbProvider

public interface IDbProvider { string DisplayName { get; } ProviderIcon? Icon { get; } ISqlDialect Dialect { get; } IReadOnlyList<ConnectionField> ConnectionFields { get; } string BuildConnectionString(IReadOnlyDictionary<string, string?> values); Task<bool> TestConnectionAsync(ConnectionProfile profile, CancellationToken ct); Task<IReadOnlyList<DbTreeNode>> GetChildNodesAsync( ConnectionProfile profile, IReadOnlyList<DbNodeRef> ancestors, CancellationToken ct); Task<QueryResult> ExecuteQueryAsync(ConnectionProfile profile, string sql, CancellationToken ct); Task<int> ExecuteBatchAsync( ConnectionProfile profile, IReadOnlyList<SqlStatement> statements, CancellationToken ct); }
MemberPurpose
DisplayNameHuman-readable engine name, e.g. "PostgreSQL".
IconOptional glyph or image for connection nodes. ProviderIconLoader.Load(typeof(YourProvider), "🔧") embeds an icon.png next to the project if there is one, and falls back to the emoji otherwise.
DialectYour ISqlDialect — quoting, qualification and pagination.
ConnectionFieldsDeclares the connection dialog. The host renders the form from this, so a provider never writes UI code.
BuildConnectionStringComposes the driver connection string from the submitted values, including the secret just fetched from the OS keychain.
TestConnectionAsyncOpens and validates a connection — the "Test connection" button.
GetChildNodesAsyncLists the children of one tree node, on demand, so a large server is never introspected all at once. ancestors is the path from the connection root; empty for the top level. Your engine decides its own hierarchy: server → database → schema → tables, or something flatter, as SQLite does.
ExecuteQueryAsyncRuns free-form SQL, returns a QueryResult.
ExecuteBatchAsyncRuns parameterised statements in one transaction, rolling back on failure. This is the commit step of the editable grid: the host generates the quoted INSERT/UPDATE/DELETE, the provider owns only parameter binding and the transaction.

The dialect: ISqlDialect

public interface ISqlDialect { IReadOnlySet<string> Keywords { get; } string QuoteIdentifier(string identifier); string QualifyName(string? database, string? schema, string table); string Paginate(string sql, int limit, int offset, string? orderBy = null); }

Keywords drives syntax highlighting; QuoteIdentifier and QualifyName keep generated SQL correct for your engine; Paginate wraps a query in the engine's own paging syntax (LIMIT/OFFSET, OFFSET/FETCH, …) and powers the Browse tab's paging and sorting.

The DTOs you'll meet

TypeWhat it carries
ConnectionFieldOne field of the connection dialog: Text | Password | Number | File | Bool, plus grouping and "advanced" metadata. A Password field goes to the OS keychain and is never written to the config file.
ConnectionProfileWhat every provider method receives: the resolved connection string plus the optional Database context chosen in the UI.
DbNodeKindThe tree's node kinds — databases, schemas, tables, views, columns, indexes, sequences, routines, triggers, foreign keys, and a generic Object for anything engine-specific.
DbTreeNode / DbNodeRefA node you return / a path segment you receive. DbTreeNode.Count lets a folder show "Tables (22)" without being expanded.
QueryResultColumns, rows, affected count, elapsed. Each ResultColumn carries edit metadata (BaseTable, IsKey, AllowDbNull, …) — that's what tells the host whether a grid is safely editable.
SqlStatement / SqlParamA parameterised statement with named placeholders.

Optional capabilities

Beyond the required members, IDbProvider carries a set of optional capabilities as default-interface members that return "nothing extra", so a minimal provider ignores them entirely. The convention is consistent: a flag defaults to false, a nullable return to null, a collection to empty, and the paired builder throws until the flag turns it on.

Examples already in the SDK: SupportsActivityMonitor, CanManageUsers, ParseConnectionString, CreateCapabilities, GetObjectDefinitionAsync, ContainerRecipe.

Non-SQL engines

By default the host assumes SQL: it generates SELECT/DROP/TRUNCATE text itself for the tree's convenience actions. A document store opts out and owns that generation instead.

// Declare the engine non-SQL. The host hides its SQL-scaffold submenu and routes // node-action generation to the two builders below. bool IsSqlBased => true; // return false for a non-SQL engine // "Select top 1000": return your own query text, or null to let the host generate SQL. string? BuildNodeQuery( NodeQueryKind kind, IReadOnlyList<DbNodeRef> nodePath, IReadOnlyList<ResultColumn>? columns) => null; // DROP/TRUNCATE/ALTER from the tree: your own statement, or null for the host's. SqlStatement? BuildAlterStatement(AlterSpec spec) => null;

MongoDB is the reference implementation: IsSqlBased => false, BuildNodeQuery returns db.coll.find({}).limit(1000), and BuildAlterStatement returns db.coll.drop(). Both builders are available to SQL providers too, as a plain override hook for any single action.

Server version

Report the engine's version and the host shows it next to your DisplayName — PostgreSQL 16.2 in the status bar and the connect message. Return null and the name shows alone.

// Fetched once per connection and cached: one cheap call, no per-query round-trip. Task<string?> GetServerVersionAsync(ConnectionProfile profile, CancellationToken ct) => Task.FromResult<string?>(null);

Step by step

1. Create the project

<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <RootNamespace>DataTray.Providers.MyEngine</RootNamespace> <!-- Emit the full private dependency closure so the plugin loads in its own ALC. --> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> </PropertyGroup> <ItemGroup> <!-- Private=false keeps Sdk.dll OUT of the plugin folder, so the host's copy is used across the ALC boundary and IDbProvider keeps one type identity. --> <ProjectReference Include="..\DataTray.Sdk\DataTray.Sdk.csproj" Private="false" /> <PackageReference Include="MyEngine.Driver" Version="x.y.z" /> </ItemGroup> <ItemGroup> <Content Include="plugin.json" CopyToOutputDirectory="PreserveNewest" /> <EmbeddedResource Include="icon.png" LogicalName="icon.png" Condition="Exists('icon.png')" /> </ItemGroup> </Project>

2. Implement the two interfaces

The SQLite provider is the simplest reference — no server, database or schema layers, tables straight under the connection root. For an engine with server → database → schema layering, read the Postgres or SQL Server provider.

public sealed class MyEngineProvider : IDbProvider { public string DisplayName => "MyEngine"; public ProviderIcon? Icon { get; } = ProviderIconLoader.Load(typeof(MyEngineProvider), "🔧"); public ISqlDialect Dialect { get; } = new MyEngineDialect(); public IReadOnlyList<ConnectionField> ConnectionFields { get; } = [ new("host", "Host", ConnectionFieldType.Text, Required: true), new("port", "Port", ConnectionFieldType.Number, Default: "5432"), new("database", "Database", ConnectionFieldType.Text, Required: true), new("username", "Username", ConnectionFieldType.Text, Required: true), new("password", "Password", ConnectionFieldType.Password) ]; // ... the rest of IDbProvider ... }

3. Write the manifest

{ "schemaVersion": 1, "id": "myengine", "type": "provider", "name": "MyEngine", "version": "1.0.0", "hostApiVersion": 27, "entryAssembly": "DataTray.Providers.MyEngine.dll" }

The id is the engine's permanent identity — there is no host-side enum of engines, which is exactly what keeps the set open. Saved connections reference it, so pick something short, lowercase and stable. Full field reference: Manifest schema.

4. Ship it

A plugin is a folder next to the host executable:

plugins/ myengine/ plugin.json DataTray.Providers.MyEngine.dll DataTray.Providers.MyEngine.deps.json MyEngine.Driver.dll ... (rest of the build output)

The .deps.json matters: it is what the plugin's load context resolves your driver against. See Discovery & loading.