Home > UserTypes > Advanced primitive mapping
← Field lengths and storage type | Annotation attributes reference →
Advanced primitive mapping
Re-mapping a builtin type
You can also use UserTypes to change how an already-supported RZSQL primitive type is stored.
This would primarily be useful on SQLite, where the database itself has very few types, so RZSQL made opinionated decisions on storing GUIDs (as binary BLOBs) and DateTimes (as ISO8601 strings).
If you don’t feel those decisions fit your project you can change them the same way you’d override any other UserType:
module ExampleOverrides =
// change DateTime to store as a unix time instead of an ISO string
let unixEpoch = DateTime(1970,1,1)
type System.DateTime with
member this.ToPrimitive() : int64 = int64 (this - unixEpoch).TotalSeconds
static member FromPrimitive(i : int64) = unixEpoch + TimeSpan.FromSeconds(float i)
// change Guid to store as a string instead of a byte[] blob
type System.Guid with
member this.ToPrimitive() : string = this.ToString()
static member FromPrimitive(str : string) = Guid.Parse(str)
These overrides will apply everywhere your SQL queries reference the datetime and guid builtin types. Unlike most
UserTypes, when it’s a builtin type you’ve re-mapped, you don’t have to be case-sensitive to use it in your schema and
queries. That would just be far too confusing if typing DateTime applied your overridden methods but datetime
didn’t!
Supporting Decimal and DateTimeOffset on SQLite
Custom mapping can help you with the SQLite backend if you’d like to use decimal or DateTimeOffset. By default these
types will throw an exception if used with a SQLite backend because I couldn’t think of an acceptable default way to
support them. For decimal, if we mapped to REAL you would lose the precision and base-10 math of decimal. The only
lossless way to store and retrieve a decimal value would be in a SQLite BLOB or TEXT column, but then mathematical
operators would break or silently decay to binary floating point.
Likewise with DateTimeOffset, the obvious choice would be to use .ToString("o") like we do with DateTime, but then
comparisons and equality would produce unexpected results. The below expression evaluates FALSE in SQLite using string
comparison, but should be TRUE comparing the actual moment in time two DateTimeOffset types represent. The UTC+0
one is a minute before the UTC-4 one.
'2026-06-08T01:15:00.0000000+00:00' < '2026-06-07T21:16:00.0000000-04:00'
If you understand the problem space and have chosen storage format where the tradeoffs work for your needs, mapping these types can be the right call.
Mapping to vendor-specific database column types
In addition to the aforementioned built-in primitive datatypes, your ToPrimitive and
FromPrimitive methods can map a UserType to System.Object.
This allows you to store and retrieve anything your underlying ADO.NET provider can handle.
For example, you can map to the point type in Postgres like so:
[<RawBackendSQLType("point")>]
[<SQLParameterDbType("NpgsqlDbType", 15)>]
type Point2D =
{ X : double
Y : double
}
static member ToPrimitive(p : Point2D) : System.Object =
box (NpgsqlTypes.NpgsqlPoint(p.X, p.Y))
static member FromPrimitive(o : System.Object) : Point2D =
let pt = o :?> NpgsqlTypes.NpgsqlPoint
{ X = pt.X; Y = pt.Y }
When mapping to System.Object, the RawBackendSQLType attribute is required.
Otherwise RZSQL would have no clue what underlying datatype to use on a Point2D column!
You’ll also notice a new attribute on the above example, [<SQLParameterDbType("NpgsqlDbType", 15)>].
This is used when you write a query that takes a Point2D as a parameter.
When the RZSQL runtime executes a query with UserType parameters, it first converts them to their underlying
representation via ToPrimitive. The output of that ToPrimitive() call becomes the
dbParam.Value.
By default,
dbParam.DbType
is set based on the underlying type being mapped to. For example, if you mapped to int, RZSQL will assume
DbType.Int32 is appropriate.
Usually that is fine.
However, if you are mapping to System.Object to represent a custom type, RZSQL’s guess of DbType.Object might not work with your ADO.NET provider.
In this case the correct thing to do, knowing that the DbParameter is specifically an instance of
NpgsqlParameter, is to set dbParam.NpgsqlDbType <- NpgsqlDbType.Point.
The attribute here gives the runtime the information it needs to do that via reflection. The runtime doesn’t carry an Npgsql dependency and doesn’t directly know about those data types, but it essentially does this:
let prop = dbParam.GetType().GetProperty(propName, BindingFlags.Instance|||BindingFlags.Public)
prop.SetValue(dbParam, Enum.ToObject(prop.PropertyType, intValue))
In the above snippet, propName and intValue come from the [<SQLParameterDbType("NpgsqlDbType", 15)>] attribute, 15
being the integer value of NpgsqlDbType.Point.
Writing SQL dealing with backend-specific types
The above example helped you store a point and retrieve it, but you still can’t do much with it in your database
queries. RZSQL doesn’t know what operations point supports, and doesn’t have type signatures for Postgres’s geometric
functions, because they don’t fit into its default backend-agnostic type hierarchy. For doing more than just CRUD
storage and retrieval, you’ll want to get familiar with VENDOR statements.
← Field lengths and storage type | Annotation attributes reference →