You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: doc/Language/UserTypes/README.md
+89-18Lines changed: 89 additions & 18 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -13,7 +13,7 @@ This allows you to:
13
13
14
14
This is how an example solution with UserTypes is arranged:
15
15
16
-

16
+

17
17
18
18
Your UserTypes MUST be in a separate assembly from your SQL queries, and must build first.
19
19
@@ -32,20 +32,22 @@ It's a good practice to model your domain tightly with types. This helps make co
32
32
compiler to catch errors where function arguments are passed out-of-order. For example, if you have a function in your domain:
let addUserToGroup (userId : UserId) (groupId : GroupId) =
45
+
// do stuff
44
46
```
45
47
46
48
Then you can't make that mixup without the compiler catching it.
47
49
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
49
51
constantly have to convert the raw primitive `int` or `string` or `Guid` values that come out of your SQL query results
50
52
to your domain types, and unpack your domain types back to primitives to pass them in as query parameters.
51
53
@@ -77,13 +79,14 @@ type EmailAddress(rawEmail : string) =
77
79
if isNull rawEmail || not(rawEmail.Contains("@")) then
78
80
invalidArg (nameof rawEmail) "Email must be non-null and contain @"
79
81
80
-
override this.ToString() = raw
82
+
override this.ToString() = rawEmail
81
83
82
84
static member ToPrimitive(email : EmailAddress) : string = email.ToString()
83
85
static member FromPrimitive(raw : string) : EmailAddress = EmailAddress(raw)
84
86
```
85
87
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.
87
90
88
91
If you don't like having those static methods littering your domain, or you can't add them because the type you're
89
92
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:
112
115
module MyCustomMappings =
113
116
type DateOnly with
114
117
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")
116
119
```
117
120
118
121
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
124
127
Once you've got your UserTypes assembly plugged in via [rzsql.json](../../Configuration/Json.md), you can use your
125
128
domain types in your database model. Instead of writing `create table Users(Id guid primary key)`, write `create table Users(Id UserId primary key)`.
126
129
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
+
127
132
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`.
128
133
129
134
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
165
170
166
171
```fsharp
167
172
type IUserRow =
168
-
member Id : UserId
169
-
member Email : EmailAddress
173
+
abstract member Id : UserId
174
+
abstract member Email : EmailAddress
170
175
// ... etc
171
176
```
172
177
@@ -221,7 +226,7 @@ module MyCustomMappings =
221
226
type DateOnly with
222
227
[<SQLTypeLength(10)>] // store as nvarchar(10)
223
228
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")
225
230
```
226
231
227
232
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.
232
237
type DateOnly with
233
238
[<RawBackendSQLType("char(10)")>]
234
239
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")
236
241
```
237
242
238
243
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.
241
246
The string passed to `RawBackendSQLType` is opaque to RZSQL and not type-checked. It is your responsibility to ensure
242
247
that it's syntactically valid and that it can store the data you're mapping into it.
243
248
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
Usage 2: `[<SQLParameterDbType(dbParameterPropertyName : string, value : int)>]`
388
+
Constructor 2: `[<SQLParameterDbType(dbParameterPropertyName : string, value : int)>]`
331
389
332
390
Specifies the `DbType` to use when this UserType is passed into a query as a parameter.
333
391
@@ -339,16 +397,29 @@ will be resolved by name at runtime and set to the specified integer value.
339
397
340
398
## Pitfalls and limitations
341
399
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
+
342
413
### No generics
343
414
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
345
416
might wish to write a single `type Id<'a> = Id of Guid` and then use `Id<User>`, `Id<Group>`, etc. instead of defining
346
417
individual types for each one. This is not supported. You'll have to use `type UserId = UserId of Guid` and `type
347
418
GroupId = GroupId of Guid` and so on.
348
419
349
420
### Changes affecting schema
350
421
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.
352
423
353
424
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
354
425
to an `int` (seconds since midnight). It's a small task to change your .NET assembly to replace the `ToPrimitive` and
0 commit comments