Skip to content

Commit 51f510f

Browse files
committed
Round of edits on UserTypes doc.
1 parent 53b2fb3 commit 51f510f

3 files changed

Lines changed: 91 additions & 20 deletions

File tree

doc/Language/UserTypes/README.md

Lines changed: 89 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This allows you to:
1313

1414
This is how an example solution with UserTypes is arranged:
1515

16-
![](SolutionLayout.gv.svg)
16+
![Solution has YourProject.SQLQueries.fsproj referencing Rezoom.SQL.Provider and YourProject.UserTypes.fsproj referencing Rezoom.SQL.Annotations. There is a project reference from YourProject.SQLQueries to YourProject.UserTypes.](SolutionLayout.gv.svg)
1717

1818
Your UserTypes MUST be in a separate assembly from your SQL queries, and must build first.
1919

@@ -32,20 +32,22 @@ It's a good practice to model your domain tightly with types. This helps make co
3232
compiler to catch errors where function arguments are passed out-of-order. For example, if you have a function in your domain:
3333

3434
```fsharp
35-
addUserToGroup (userId : int) -> (groupId : int) -> Plan<unit>
35+
let addUserToGroup (userId : int) (groupId : int) =
36+
// do stuff
3637
```
3738

3839
It's very easy to accidentally call `addUserToGroup group.Id user.Id` and miss the mistake.
3940

4041
If you have wrapper types and your function signature changes to:
4142

4243
```fsharp
43-
addUserToGroup (userId : UserId) -> (groupId : GroupId) -> Plan<unit>
44+
let addUserToGroup (userId : UserId) (groupId : GroupId) =
45+
// do stuff
4446
```
4547

4648
Then you can't make that mixup without the compiler catching it.
4749

48-
However, implementing a domain model with those wrapper types on top of vanilla Rezoom.SQL would be frustrating. You'd
50+
However, implementing a domain model with those wrapper types on top of vanilla RZSQL would be frustrating. You'd
4951
constantly have to convert the raw primitive `int` or `string` or `Guid` values that come out of your SQL query results
5052
to your domain types, and unpack your domain types back to primitives to pass them in as query parameters.
5153

@@ -77,13 +79,14 @@ type EmailAddress(rawEmail : string) =
7779
if isNull rawEmail || not(rawEmail.Contains("@")) then
7880
invalidArg (nameof rawEmail) "Email must be non-null and contain @"
7981
80-
override this.ToString() = raw
82+
override this.ToString() = rawEmail
8183
8284
static member ToPrimitive(email : EmailAddress) : string = email.ToString()
8385
static member FromPrimitive(raw : string) : EmailAddress = EmailAddress(raw)
8486
```
8587

86-
`EmailAddress` will be detected as a valid UserType because of the ToPrimitive and FromPrimitive methods mapping it it to string.
88+
`EmailAddress` will be detected as a valid UserType because of the ToPrimitive and FromPrimitive methods mapping it to
89+
string.
8790

8891
If you don't like having those static methods littering your domain, or you can't add them because the type you're
8992
trying to map is from another library you can't edit, that's not a problem!
@@ -112,7 +115,7 @@ Or my personal preference, F# extension methods:
112115
module MyCustomMappings =
113116
type DateOnly with
114117
member this.ToPrimitive() = this.ToString("o")
115-
static member this.FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
118+
static member FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
116119
```
117120

118121
You can have as many classes as you want defining static custom mappings. But you can't split the mapping for a *single
@@ -124,6 +127,8 @@ usertype* across multiple classes. `ToPrimitive : Foo -> string` has to be defin
124127
Once you've got your UserTypes assembly plugged in via [rzsql.json](../../Configuration/Json.md), you can use your
125128
domain types in your database model. Instead of writing `create table Users(Id guid primary key)`, write `create table Users(Id UserId primary key)`.
126129

130+
**Note that while built-in types in RZSQL are case-insensitive, when you reference a UserType you *must* match its .NET type name exactly, case-sensitively!**
131+
127132
When you `select` from that table, you'll get the `Id` column back out in your F# code as a `UserId`, not just a plain `System.Guid`.
128133

129134
And when your query uses a parameter that you compare with the `Id` column, that parameter will be inferred as a `UserId` as well.
@@ -165,8 +170,8 @@ In your UserTypes assembly, write an interface matching the shape of the columns
165170

166171
```fsharp
167172
type IUserRow =
168-
member Id : UserId
169-
member Email : EmailAddress
173+
abstract member Id : UserId
174+
abstract member Email : EmailAddress
170175
// ... etc
171176
```
172177

@@ -221,7 +226,7 @@ module MyCustomMappings =
221226
type DateOnly with
222227
[<SQLTypeLength(10)>] // store as nvarchar(10)
223228
member this.ToPrimitive() = this.ToString("o")
224-
static member this.FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
229+
static member FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
225230
```
226231

227232
A more heavy-handed alternative is to override the entire type name used on the backend.
@@ -232,7 +237,7 @@ This is done with the `RawBackendSQLType` attribute.
232237
type DateOnly with
233238
[<RawBackendSQLType("char(10)")>]
234239
member this.ToPrimitive() = this.ToString("o")
235-
static member this.FromPrimitive(str : string) = DateOnly.ParseExact(str.Trim(), "o")
240+
static member FromPrimitive(str : string) = DateOnly.ParseExact(str.Trim(), "o")
236241
```
237242

238243
Note that `RawBackendSQLType` and `SQLTypeLength` cannot be specified on the same type, because the former completely
@@ -241,10 +246,56 @@ overrides the latter and makes it redundant.
241246
The string passed to `RawBackendSQLType` is opaque to RZSQL and not type-checked. It is your responsibility to ensure
242247
that it's syntactically valid and that it can store the data you're mapping into it.
243248

249+
## Re-mapping a builtin type
250+
251+
You can also use UserTypes to change how an already-supported RZSQL primitive type is stored.
252+
253+
This would primarily be useful on SQLite, where the database itself has very few types, so RZSQL made opinionated
254+
decisions on storing GUIDs (as binary BLOBs) and DateTimes (as ISO8601 strings).
255+
256+
If you don't feel those decisions fit your project you can change them the same way you'd override any other UserType:
257+
258+
```fsharp
259+
module ExampleOverrides =
260+
// change DateTime to store as a unix time instead of an ISO string
261+
let unixEpoch = DateTime(1970,1,1)
262+
type System.DateTime with
263+
member this.ToPrimitive() : int64 = int64 (this - unixEpoch).TotalSeconds
264+
static member FromPrimitive(i : int64) = unixEpoch + TimeSpan.FromSeconds(i)
265+
266+
// change Guid to store as a string instead of a byte[] blob
267+
type System.Guid with
268+
member this.ToPrimitive() : string = this.ToString()
269+
static member FromPrimitive(str : string) = Guid.Parse(str)
270+
```
271+
272+
These overrides will apply everywhere your SQL queries reference the `datetime` and `guid` builtin types. Unlike most
273+
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
274+
queries. That would just be far too confusing if typing `DateTime` applied your overridden methods but `datetime`
275+
didn't!
276+
277+
### Decimal and DateTimeOffset on SQLite
278+
279+
I especially recommend doing this for the SQLite backend if you'd like to use `decimal` or `DateTimeOffset`. By default
280+
these types will throw an exception if used with a SQLite backend because I couldn't think of an acceptable *default*
281+
way to support them. For decimal, if we mapped to `REAL` you would lose the precision and basetenity of `decimal`. The
282+
only lossless way to store and retrieve a `decimal` value would be in a SQLite `BLOB` or `TEXT` column, but then
283+
mathematical operators would break or silently decay to binary floating point.
284+
285+
Likewise with `DateTimeOffset`, the obvious choice would be to use `.ToString("o")` like we do with DateTime, but then
286+
comparisons and equality would produce unexpected results. The below expression evalutes TRUE in SQLite using string
287+
comparison, but should be FALSE comparing the actual moment in time the two `DateTimeOffset` types represent. The UTC+0
288+
one is a minute before the UTC-4 one.
289+
290+
`'2026-06-07T21:16:00.0000000-04:00' > '2026-06-08T01:15:00.0000000+00:00'`
291+
292+
If you understand the problem space and have chosen storage format where the tradeoffs work for *your needs*, mapping
293+
these types can be the right call.
294+
244295
## Mapping to vendor-specific database column types
245296

246297
In addition to the aforementioned [built-in primitive](../DataTypes.md) datatypes, your `ToPrimitive` and
247-
`FromPrimitive` methods can map to `System.Object`.
298+
`FromPrimitive` methods can map a UserType to `System.Object`.
248299

249300
This allows you to store and retrieve *anything* your underlying ADO.NET provider can handle.
250301

@@ -278,7 +329,7 @@ representation via `ToPrimitive`. The output of that `ToPrimitive()` call become
278329

279330
By default,
280331
[dbParam.DbType](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter.dbtype?view=net-10.0)
281-
is set based on the underlying type being mapped to. For example, if you mapped to int, Rezoom.SQL will assume
332+
is set based on the underlying type being mapped to. For example, if you mapped to int, RZSQL will assume
282333
`DbType.Int32` is appropriate.
283334

284335
Usually that is fine.
@@ -299,13 +350,20 @@ prop.SetValue(dbParam, Enum.ToObject(prop.PropertyType, intValue))
299350
In the above snippet, `propName` and `intValue` come from the `[<SQLParameterDbType("NpgsqlDbType", 15)>]` attribute, 15
300351
being the integer value of NpgsqlDbType.Point.
301352

353+
### Writing SQL dealing with backend-specific types
354+
355+
The above example helped you store a `point` and retrieve it, but you still can't do much with it in your database
356+
queries. RZSQL doesn't know what operations `point` supports, and doesn't have type signatures for Postgres's geometric
357+
functions, because they don't fit into its default backend-agnostic type hierarchy. For doing more than just CRUD
358+
storage and retrieval, you'll want to get familiar with [VENDOR statements](../VendorStmts.md).
359+
302360
## Annotation attributes reference
303361

304362
### RawBackendSQLType
305363

306364
Usage: `[<RawBackendSQLType(sqlType : string)>]`
307365

308-
Specifies the literal type RZSQL should use for columns storing this usertype and in typename-carrying expressions like `CAST(x AS MyUserType)`.
366+
Specifies the literal type RZSQL should use for columns storing this UserType and in typename-carrying expressions like `CAST(x AS MyUserType)`.
309367
This allows you to override the default storage format RZSQL would use for the underlying primitive type.
310368

311369
You SHOULD include the length specifier, if one is needed, in the string such as `"varchar(50)"`.
@@ -325,9 +383,9 @@ Not valid to combine this with `RawBackendSQLType`, since that already includes
325383

326384
### SQLParameterDbType
327385

328-
Usage 1: `[<SQLParameterDbType(dbType : System.Data.DbType)>]`
386+
Constructor 1: `[<SQLParameterDbType(dbType : System.Data.DbType)>]`
329387

330-
Usage 2: `[<SQLParameterDbType(dbParameterPropertyName : string, value : int)>]`
388+
Constructor 2: `[<SQLParameterDbType(dbParameterPropertyName : string, value : int)>]`
331389

332390
Specifies the `DbType` to use when this UserType is passed into a query as a parameter.
333391

@@ -339,16 +397,29 @@ will be resolved by name at runtime and set to the specified integer value.
339397

340398
## Pitfalls and limitations
341399

400+
### Must map each specific type, not just a base type
401+
402+
If you have ToPrimitive and FromPrimitive defined on a base class, that does *not* automatically make all its subclasses valid UserTypes. Each one needs its own mapping.
403+
404+
### No chaining primitives
405+
406+
Your `ToPrimitive` and `FromPrimitive` must map *directly* to a builtin primitive type, not *another* wrapper type.
407+
408+
You can't have `Foo` mapped to underlying type `Bar`, `Bar` mapped to `Baz` and `Baz` mapped to `int`. Even though it
409+
would be possible to follow that chain of wrappers and unwrappers to convert between `Foo` and `int`, RZSQL does not do
410+
this. It would require additional error-checking to prevent cyclical paths and it would just make the mapping of `Foo`
411+
harder to follow for any reader of your code.
412+
342413
### No generics
343414

344-
You cannot map a .NET generic type as a usertype. For example, maybe every entity in your domain has a Guid PK. You
415+
You cannot map a .NET generic type as a UserType. For example, maybe every entity in your domain has a Guid PK. You
345416
might wish to write a single `type Id<'a> = Id of Guid` and then use `Id<User>`, `Id<Group>`, etc. instead of defining
346417
individual types for each one. This is not supported. You'll have to use `type UserId = UserId of Guid` and `type
347418
GroupId = GroupId of Guid` and so on.
348419

349420
### Changes affecting schema
350421

351-
When you change your usertypes library, RZSQL has no way of knowing about the history.
422+
When you change your UserTypes library, RZSQL has no way of knowing about the history.
352423

353424
Suppose for a long time you had `System.TimeOnly` mapped to a `string` (hh:mm:ss) and you have decided to change it to map
354425
to an `int` (seconds since midnight). It's a small task to change your .NET assembly to replace the `ToPrimitive` and

doc/Language/UserTypes/SolutionLayout.gv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ digraph "Solution Layout" {
3535
<tr><td>rzsql.json</td><td align="left">{ &quot;UserTypes&quot;: [&quot;YourProject.UserTypes&quot;] }</td></tr>
3636
<tr><td>V1.model.sql</td><td align="left">create table Users(Id UserId primary key, Email EmailAddress, ...)</td></tr>
3737
<tr><td rowspan="3">Query.fs</td><td align="left">type MyQuery = SQL&lt;&quot;select&lt;IUser&gt; * from Users where Id = @id&quot;&gt;</td></tr>
38-
<tr><td align="left">let rows = MyQuery.Command(id = UserId 123).Execute(connection)</td></tr>
38+
<tr><td align="left">let! rows = MyQuery.Command(id = UserId 123).Plan()</td></tr>
3939
<tr><td align="left">let user = rows.[0] :&gt; IUser</td></tr>
4040

4141
</table>

doc/Language/UserTypes/SolutionLayout.gv.svg

Lines changed: 1 addition & 1 deletion
Loading

0 commit comments

Comments
 (0)