November 01, 2022

Bringing .NET to EdgeDB

Today, we’re excited to announce the official .NET binding for EdgeDB!

I crafted the first version of the library a few months ago. Since then I’ve been working closely with(in) the EdgeDB team to fine-tune the implementation and the API to make it an idiomatic EdgeDB client.

Remember to give EdgeDB.Net a ⭐️ on GitHub, check out the docs, and join our Discord!

Star edgedb-net!

All EdgeDB clients share a certain design approach and philosophy that the new .NET client also embraces. In short, all EdgeDB clients should:

  • implement zero-config connectivity, ensured by passing the shared set of connection tests;

  • automatically (and when it’s safe!) reconnect on network errors and retry on transaction serialization errors;

  • be as efficient as possible: lean and mean data serialization and protocol implementation;

  • abstract away the complexity of client-side connection pooling;

  • and, most importantly, the API should be easy to pick up and be productive with in no time!

As usual, we’ve built EdgeDB.Net with performance and developer experience as our highest priorities. EdgeDB.Net achieves this by using a fully asynchronous implementation, making use of high-performance .NET design patterns like Span<T>.

To follow along with this demo you will need EdgeDB 🚀. If you are new to EdgeDB you can follow our quickstart guide to get EdgeDB installed and ready.

Make sure that you create the EdgeDB project in your .sln directory. This is to ensure that EdgeDB.Net can automatically configure things. Also keep in mind that the EdgeDB.Net package targets .NET 6.

Once you have EdgeDB running, install the new driver with the dotnet command in your terminal:

Copy
$ 
dotnet add package EdgeDB.Net.Driver

With the driver installed, you can create a client connection instance with EdgeDB with EdgeDBClient:

C#
F#
DI
Copy
using EdgeDB;

var client = new EdgeDBClient();
Copy
open EdgeDB;

let client = new EdgeDBClient()
Copy
using EdgeDB;

...

services.AddEdgeDB()

To learn more, read our .NET quickstart docs.

Now you are ready to run your first query:

C#
F#
Copy
var result = await client
    .QuerySingleAsync<string>("select 'Hello, .NET!'");

Console.WriteLine(result);
Copy
let! result = client.QuerySingleAsync<string>(
    "select 'Hello, .NET!'"
)

printf "%s" result

Note that EdgeDB.Net uses the common .NET value types to represent different scalar types in EdgeDB. To see the full type mapping table, check out the datatypes section in our docs.

EdgeDB.Net fully embraces strict typing, allowing you to define concrete types to represent query results. Yet one of the key features of EdgeDB.Net is that it supports polymorphism of EdgeDB types in .NET.

Abstract types defined in EdgeDB schema can be modeled by abstract types in your .NET code. You can then pass an abstract type as a query result and EdgeDB.Net will automatically deserialize data into the correct .NET type.

Let’s first create the .NET types which will map to the types defined in our classic example Movies schema:

EdgeDB Schema
Schema in C#
Schema in F#
Copy
module default {
  abstract type Content {
    required property title -> str;
    multi link actors -> Person {
      property character_name -> str;
    };
  };

  type Person {
    required property name -> str;
    link filmography := .<actors[is Content];
  };

  type Movie extending Content {
    property release_year -> int32;
  };

  type Show extending Content {
    property num_seasons := count(.<show[is Season]);
  };

  type Season {
    required link show -> Show;
    required property number -> int32;
  };
}
Copy
public abstract class Content
{
    public string? Title { get; set; }
    public Person[] Actors { get; set; }
}

public class Person
{
    public string? Name { get; set; }
    public Content? Filmography {  get; set; }

    [EdgeDBProperty("@character_name")]
    public string CharacterName { get; set; } // link property
}

public class Movie : Content
{
    public int ReleaseYear { get; set; }
}

public class Show : Content
{
    public long NumSeasons { get; set; }
}

public class Season
{
    public Show Show { get; set; }
    public int Number { get; set; }
}
Copy
type Person = {
    Name: string;
    [<EdgeDBProperty("@character_name")>]
    CharacterName: string;
}

type Movie = {
    ReleaseYear: int;
    Title: string;
    Actors: Person[];
}

type Show = {
    NumSeasons: int64;
    Title: string;
    Actors: Person[];
}

type Season = {
    Show: Show;
    Number: int;
}

type Content =
    | Movie of Movie
    | Show of Show

This demo uses a PascalCase naming strategy in .NET types. This strategy is optional and not enabled by default. To learn more about naming strategies and how to enable implicit conversion to your chosen strategy, refer to the Naming Strategy docs.

We can now query our database with the Content type for the result:

C#
F#
Copy
using System.Linq

var content = await client.QueryAsync<Content>(
    @"select Content {
        title,
        actors: {
            name,
            @character_name
        }
      }
    "
);

var movies = content.Where(x => x is Movie);
var shows = content.Where(x => x is Show);
Copy
open System.Linq

let! content = client.QueryAsync<Content>(
  """select Content {
         title,
         actors: {
             name,
             @character_name
         }
     }
  """)

let movies = content.Where(fun x -> match x with Movie -> true | _ -> false)
let shows = content.Where(fun x -> match x with Show -> true | _ -> false)

By querying with the Content abstract type, EdgeDB.Net will return every Content object—whether it’s a Movie or Show—deserialized as the corresponding .NET type based on their typename.

To learn more about query result and custom types, check out the Custom Types documentation.

EdgeDB.Net supports transactions out of the box, retrying your queries if a retryable error (e.g. a network failure) occurs. If an non-retryable error happens, the queries performed within the transactions are automatically rolled back.

C#
F#
Copy
var result = await client.TransactionAsync(async (tx) =>
{
    return await tx.QueryRequiredSingleAsync<string>(
        "select 'Hello, .NET!'"
    );
});

Console.WriteLine(result);
Copy
let! result = client.TransactionAsync(fun tx ->
    tx.QueryRequiredSingleAsync<string>("select 'Hello, .NET!'")
)

printf "%A" result

Code blocks in transactions may run multiple times. It’s good practice to only perform safe to re-run operations in transaction blocks.

EdgeDB.Net allows to configure state by using the With*() family of methods. This allows creating clients with different state configuration while efficiently sharing the same underlying client pool.

With* methods will always return a new client instance, which contains the applied state changes.

This is incredibly useful in tandem with Globals and Access Policies. Let’s use the demo from the access policy docs as an example:

C#
F#
Copy
// An example UUID; you should use a real one from your DB!
var userId = Guid.NewGuid();

var scopedClient = client
    .WithGlobals(new Dictionary<string, object?>
    {
        { "current_user_id", userId }
    });

var posts = scopedClients.QueryAsync<BlogPost>(
    "select Post { title }"
);
Copy
// An example UUID; you should use a real one from your DB!
let userId = Guid.NewGuid()

let scopedClient = client.WithGlobals(
    dict [ "current_user_id", userId ]
)

let! posts = scopedClients.QueryAsync<BlogPost>(
    "select Post { title }"
)

State API also allows configuring client behavior with extreme granularity:

C#
F#
Copy
using EdgeDB.State;

var configuredClient = client
    .WithConfig(conf =>
    {
        conf.AllowDMLInFunctions = true;
        conf.ApplyAccessPolicies = true;
        conf.DDLPolicy = DDLPolicy.AlwaysAllow;
        conf.QueryExecutionTimeout = TimeSpan.FromSeconds(10);
        conf.IdleTransationTimeout = TimeSpan.FromSeconds(10);
    })
Copy
open EdgeDB.State

let configuredClient = client.WithConfig(fun conf ->
    conf.AllowDMLInFunctions <- true
    conf.ApplyAccessPolicies <- true
    conf.DDLPolicy <- DDLPolicy.AlwaysAllow
    conf.QueryExecutionTimeout <- TimeSpan.FromSeconds(10)
    conf.IdleTransationTimeout <- TimeSpan.FromSeconds(10)
)

See Configuration for state-configuration details.

For more examples using EdgeDB.Net, check out our Github examples repository.

Whats next for EdgeDB.Net? We’re currently working on a query builder to provide an EFCore-like feel without the drawbacks of an ORM. You can preview the beta query builder by installing it via myget:

Copy
$ 
  
dotnet add package EdgeDB.Net.QueryBuilder \
--source https://www.myget.org/F/edgedb-net/api/v3/index.json
Copy
var person = new Person
{
    Email = "example@example.com",
    Name = "example"
};

// A complex insert with links & dealing with conflicts
var result = await QueryBuilder
    .Insert(new Person
    {
        BestFriend = person,
        Name = "example2",
        Email = "example2@example.com"
    })
    .UnlessConflictOn(x => x.Email)
    .ElseReturn()
    .ExecuteAsync(client);

More examples using the query builder can be found on our Github.

The query builder is in the very early stage of development. Be advised: bugs are part of the experience and no API is final! 🤓

We’re also working on a codegen tool to generate .NET code from .edgeql files. You can read the proposed spec on github.

We can’t wait to see what you will build with EdgeDB.Net! ❤️

File feature requests on Github and join the #edgedb-dotnet channel on our Discord to discuss!