|
| 1 | +--- |
| 2 | +title: Advanced primitive mapping |
| 3 | +parent: UserTypes |
| 4 | +grand_parent: Language |
| 5 | +nav_order: 2 |
| 6 | +--- |
| 7 | + |
| 8 | +<!-- nav-top --> |
| 9 | +[Home](../../../README.md) > [Language](../README.md) > [UserTypes](README.md) > Advanced primitive mapping |
| 10 | + |
| 11 | +[← Field lengths and storage type](FieldLengthsAndStorage.md) | [Annotation attributes reference →](AttributesReference.md) |
| 12 | +<!-- /nav-top --> |
| 13 | + |
| 14 | +# Advanced primitive mapping |
| 15 | + |
| 16 | +## Re-mapping a builtin type |
| 17 | + |
| 18 | +You can also use UserTypes to change how an already-supported RZSQL primitive type is stored. |
| 19 | + |
| 20 | +This would primarily be useful on SQLite, where the database itself has very few types, so RZSQL made opinionated |
| 21 | +decisions on storing GUIDs (as binary BLOBs) and DateTimes (as ISO8601 strings). |
| 22 | + |
| 23 | +If you don't feel those decisions fit your project you can change them the same way you'd override any other UserType: |
| 24 | + |
| 25 | +```fsharp |
| 26 | +module ExampleOverrides = |
| 27 | + // change DateTime to store as a unix time instead of an ISO string |
| 28 | + let unixEpoch = DateTime(1970,1,1) |
| 29 | + type System.DateTime with |
| 30 | + member this.ToPrimitive() : int64 = int64 (this - unixEpoch).TotalSeconds |
| 31 | + static member FromPrimitive(i : int64) = unixEpoch + TimeSpan.FromSeconds(float i) |
| 32 | +
|
| 33 | + // change Guid to store as a string instead of a byte[] blob |
| 34 | + type System.Guid with |
| 35 | + member this.ToPrimitive() : string = this.ToString() |
| 36 | + static member FromPrimitive(str : string) = Guid.Parse(str) |
| 37 | +``` |
| 38 | + |
| 39 | +These overrides will apply everywhere your SQL queries reference the `datetime` and `guid` builtin types. Unlike most |
| 40 | +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 |
| 41 | +queries. That would just be far too confusing if typing `DateTime` applied your overridden methods but `datetime` |
| 42 | +didn't! |
| 43 | + |
| 44 | +### Supporting Decimal and DateTimeOffset on SQLite |
| 45 | + |
| 46 | +Custom mapping can help you with the SQLite backend if you'd like to use `decimal` or `DateTimeOffset`. By default these |
| 47 | +types will throw an exception if used with a SQLite backend because I couldn't think of an acceptable *default* way to |
| 48 | +support them. For decimal, if we mapped to `REAL` you would lose the precision and base-10 math of `decimal`. The only |
| 49 | +lossless way to store and retrieve a `decimal` value would be in a SQLite `BLOB` or `TEXT` column, but then mathematical |
| 50 | +operators would break or silently decay to binary floating point. |
| 51 | + |
| 52 | +Likewise with `DateTimeOffset`, the obvious choice would be to use `.ToString("o")` like we do with DateTime, but then |
| 53 | +comparisons and equality would produce unexpected results. The below expression evaluates FALSE in SQLite using string |
| 54 | +comparison, but should be TRUE comparing the actual moment in time two `DateTimeOffset` types represent. The UTC+0 |
| 55 | +one is a minute before the UTC-4 one. |
| 56 | + |
| 57 | +`'2026-06-08T01:15:00.0000000+00:00' < '2026-06-07T21:16:00.0000000-04:00'` |
| 58 | + |
| 59 | +If you understand the problem space and have chosen storage format where the tradeoffs work for *your needs*, mapping |
| 60 | +these types can be the right call. |
| 61 | + |
| 62 | +## Mapping to vendor-specific database column types |
| 63 | + |
| 64 | +In addition to the aforementioned [built-in primitive](../DataTypes.md) datatypes, your `ToPrimitive` and |
| 65 | +`FromPrimitive` methods can map a UserType to `System.Object`. |
| 66 | + |
| 67 | +This allows you to store and retrieve *anything* your underlying ADO.NET provider can handle. |
| 68 | + |
| 69 | +For example, you can map to the `point` type in `Postgres` like so: |
| 70 | + |
| 71 | +```fsharp |
| 72 | +[<RawBackendSQLType("point")>] |
| 73 | +[<SQLParameterDbType("NpgsqlDbType", 15)>] |
| 74 | +type Point2D = |
| 75 | + { X : double |
| 76 | + Y : double |
| 77 | + } |
| 78 | + static member ToPrimitive(p : Point2D) : System.Object = |
| 79 | + box (NpgsqlTypes.NpgsqlPoint(p.X, p.Y)) |
| 80 | + static member FromPrimitive(o : System.Object) : Point2D = |
| 81 | + let pt = o :?> NpgsqlTypes.NpgsqlPoint |
| 82 | + { X = pt.X; Y = pt.Y } |
| 83 | +``` |
| 84 | + |
| 85 | +When mapping to `System.Object`, the `RawBackendSQLType` attribute is **required**. |
| 86 | + |
| 87 | +Otherwise RZSQL would have no clue what underlying datatype to use on a `Point2D` column! |
| 88 | + |
| 89 | +You'll also notice a new attribute on the above example, `[<SQLParameterDbType("NpgsqlDbType", 15)>]`. |
| 90 | + |
| 91 | +This is used when you write a query that takes a `Point2D` as a *parameter*. |
| 92 | + |
| 93 | +When the RZSQL runtime executes a query with UserType parameters, it first converts them to their underlying |
| 94 | +representation via `ToPrimitive`. The output of that `ToPrimitive()` call becomes the |
| 95 | +[dbParam.Value](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter.value?view=net-10.0). |
| 96 | + |
| 97 | +By default, |
| 98 | +[dbParam.DbType](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter.dbtype?view=net-10.0) |
| 99 | +is set based on the underlying type being mapped to. For example, if you mapped to int, RZSQL will assume |
| 100 | +`DbType.Int32` is appropriate. |
| 101 | + |
| 102 | +Usually that is fine. |
| 103 | + |
| 104 | +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. |
| 105 | + |
| 106 | +In this case the correct thing to do, knowing that the `DbParameter` is specifically an instance of |
| 107 | +[NpgsqlParameter](https://www.npgsql.org/doc/api/Npgsql.NpgsqlParameter.html), is to set `dbParam.NpgsqlDbType <- NpgsqlDbType.Point`. |
| 108 | + |
| 109 | +The attribute here gives the runtime the information it needs to do that via reflection. The runtime doesn't carry an |
| 110 | +Npgsql dependency and doesn't directly know about those data types, but it essentially does this: |
| 111 | + |
| 112 | +```fsharp |
| 113 | +let prop = dbParam.GetType().GetProperty(propName, BindingFlags.Instance|||BindingFlags.Public) |
| 114 | +prop.SetValue(dbParam, Enum.ToObject(prop.PropertyType, intValue)) |
| 115 | +``` |
| 116 | + |
| 117 | +In the above snippet, `propName` and `intValue` come from the `[<SQLParameterDbType("NpgsqlDbType", 15)>]` attribute, 15 |
| 118 | +being the integer value of NpgsqlDbType.Point. |
| 119 | + |
| 120 | +### Writing SQL dealing with backend-specific types |
| 121 | + |
| 122 | +The above example helped you store a `point` and retrieve it, but you still can't do much with it in your database |
| 123 | +queries. RZSQL doesn't know what operations `point` supports, and doesn't have type signatures for Postgres's geometric |
| 124 | +functions, because they don't fit into its default backend-agnostic type hierarchy. For doing more than just CRUD |
| 125 | +storage and retrieval, you'll want to get familiar with [VENDOR statements](../VendorStatements.md). |
| 126 | + |
| 127 | +--- |
| 128 | +<!-- nav-bottom --> |
| 129 | +[← Field lengths and storage type](FieldLengthsAndStorage.md) | [Annotation attributes reference →](AttributesReference.md) |
| 130 | +<!-- /nav-bottom --> |
| 131 | + |
0 commit comments