MicroORM
Documentation menu

Mapping

MicroORM maps a class to a table and its properties to columns. For each setting, the first of these that applies wins:

  1. Fluent configuration in MicroOptions.Entity<T>(...)
  2. Data annotation attributes from System.ComponentModel.DataAnnotations, the same ones EF Core uses
  3. Conventions

Conventions

WhatConvention
Table nameThe class name, run through MicroOptions.Naming
Column nameThe property name, run through MicroOptions.Naming
Mapped propertiesPublic, readable, non-indexer properties of scalar types: numbers, string, bool, char, Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, byte[], enums, and their nullable forms. Collections and other classes are skipped, so navigation properties are ignored.
KeyA property named Id, or {ClassName}Id (case-insensitive)
Generated keyint, long and short keys are database-generated (identity, serial, auto-increment). Guid and string keys are supplied by the application.

Naming conventions

ConventionUserId becomes
NamingConvention.AsIs (default)UserId
NamingConvention.SnakeCaseuser_id
NamingConvention.LowerCaseuserid
var db = new MicroDb(SqlDialect.PostgreSql, dataSource, new MicroOptions { Naming = NamingConvention.SnakeCase });

PostgreSQL tip: MicroORM quotes identifiers, and PostgreSQL treats quoted names as case-sensitive. Tables created without quotes have lowercase names, so use SnakeCase or LowerCase with PostgreSQL.

For other schemes, subclass NamingConvention and override Convert, or TableName and ColumnName for finer control (for example, to pluralize table names).

Attributes

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
 
[Table("invoices", Schema = "billing")]               // billing.invoices
public class Invoice
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.None)]  // the application assigns the number
    public long Number { get; set; }
 
    [Column("customer_ref")]
    public string Customer { get; set; } = "";
 
    [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
    public decimal Total { get; set; }                 // read, never written
 
    [NotMapped]
    public string Label => $"INV-{Number}";
}
AttributeEffect
[Table(name, Schema = ...)]Table name and optional schema
[Column(name)]Column name
[Key]Primary key. Several [Key] properties make a composite key, ordered by [Column(Order = n)]
[ConcurrencyCheck]An int or long optimistic concurrency version (see Concurrency)
[Timestamp]A SQL Server rowversion: read, never written
[DatabaseGenerated(Identity)]Generated on insert; read back after InsertAsync
[DatabaseGenerated(Computed)]Never written by insert or update
[DatabaseGenerated(None)]Always written, even for an integer key
[NotMapped]Not a column

Fluent configuration

Use fluent configuration when you can't or don't want to put attributes on the class:

var options = new MicroOptions()
    .Entity<Invoice>(e => e
        .ToTable("invoices", schema: "billing")
        .HasKey(i => i.Number, ValueGeneration.None)       // composite: .HasKey(i => new { i.Year, i.Number })
        .IsVersion(i => i.Revision)
        .Column(i => i.Customer, "customer_ref")
        .Generated(i => i.Total, ValueGeneration.Computed)
        .Ignore(i => i.Label));

Configure entities before creating the MicroDb. Mappings are built on first use and cached for the lifetime of the MicroDb.

Query filters

A query filter is a condition that every typed query of an entity applies, such as a soft delete or a tenant:

var options = new MicroOptions()
    .Entity<Post>(e => e
        .HasQueryFilter(p => !p.IsDeleted)
        .HasQueryFilter(p => p.TenantId == tenants.CurrentId));
 
var posts = await db.Table<Post>().Where(p => p.Published).ToListAsync();
// WHERE (NOT (("is_deleted" = @p0))) AND ("tenant_id" = @p1) AND ("published" = @p2)
 
var trash = await db.Table<Post>().IgnoreQueryFilters().Where(p => p.IsDeleted).ToListAsync();
  • Filters apply to everything built from Table<T>(): reads, aggregates, FindAsync, ExecuteUpdateAsync, ExecuteDeleteAsync, and subqueries over the entity.
  • They don't apply to raw SQL (QueryAsync($"...")), or to the single-entity writes InsertAsync, UpdateAsync, DeleteAsync, DeleteByKeyAsync and UpsertAsync, which act on the key they're given. Check ownership before you call those with a key from user input.
  • Captured values, such as tenants.CurrentId, are read each time a statement is built, so a long-lived Table<T> always uses the current tenant.
  • Several filters are joined with AND. IgnoreQueryFilters() skips all of them, but keeps the query's own Where filters.

Inspecting a mapping

EntityMap map = db.MapOf<Invoice>();
Console.WriteLine(map.Table);                                         // billing.invoices
foreach (var c in map.Columns) Console.WriteLine($"{c.Property.Name} -> {c.Name} ({c.Generation})");

Composite keys

Mark each key property with [Key] and order them with [Column(Order = n)], or configure them fluently with HasKey(x => new { x.OrderId, x.LineNo }). Composite keys are never database-generated by convention.

[Table("order_lines")]
public class OrderLine
{
    [Key, Column(Order = 0)] public int OrderId { get; set; }
    [Key, Column(Order = 1)] public int LineNo { get; set; }
    public string Product { get; set; } = "";
}
 
var line = await db.Table<OrderLine>().FindAsync((orderId, 2));    // a tuple, in key order
await db.Table<OrderLine>().DeleteByKeyAsync((orderId, 2));

UpdateAsync, DeleteAsync and UpsertAsync match on all key columns.

Custom types

Value objects, strongly typed IDs, JSON documents and enums stored as text are mapped through type handlers.

Limitations

  • No relationships. MicroORM doesn't load navigation properties. Use a JOIN in raw SQL and materialize into a record shaped like the result.