Skip to content

Commit 53b2fb3

Browse files
committed
Flesh out usertypes documentation.
1 parent 3755f4b commit 53b2fb3

1 file changed

Lines changed: 348 additions & 11 deletions

File tree

doc/Language/UserTypes/README.md

Lines changed: 348 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,37 +11,374 @@ This allows you to:
1111

1212
## The layout
1313

14-
This is how a solution with UserTypes might look:
14+
This is how an example solution with UserTypes is arranged:
1515

1616
![](SolutionLayout.gv.svg)
1717

18-
The user types you wish to use MUST be in a separate assembly from your SQL queries, and must build first.
18+
Your UserTypes MUST be in a separate assembly from your SQL queries, and must build first.
1919

2020
The type provider cannot "see" types defined in the same assembly it's trying to compile. They don't exist yet!
2121

22-
Referencing Rezoom.SQL.Annotations is optional. This is a lightweight package that only defines attribute classes.
22+
The fsproj where you're using Rezoom.SQL.Provider must have a project reference to your UserType project(s). It must
23+
**also** name those projects in [rzsql.json's](../../Configuration/Json.md) `"UserTypes"` list. This tells the type
24+
provider to search the listed assemblies at design-time to find your custom types.
25+
26+
Referencing Rezoom.SQL.Annotations is optional. This is a lightweight package that only defines attributes.
2327
Those attributes give you more control over how your custom-mapped UserTypes are translated to SQL.
2428

2529
## Mapping your own primitive types
2630

27-
STUB
31+
It's a good practice to model your domain tightly with types. This helps make code self-documenting and allows the
32+
compiler to catch errors where function arguments are passed out-of-order. For example, if you have a function in your domain:
33+
34+
```fsharp
35+
addUserToGroup (userId : int) -> (groupId : int) -> Plan<unit>
36+
```
37+
38+
It's very easy to accidentally call `addUserToGroup group.Id user.Id` and miss the mistake.
39+
40+
If you have wrapper types and your function signature changes to:
41+
42+
```fsharp
43+
addUserToGroup (userId : UserId) -> (groupId : GroupId) -> Plan<unit>
44+
```
45+
46+
Then you can't make that mixup without the compiler catching it.
47+
48+
However, implementing a domain model with those wrapper types on top of vanilla Rezoom.SQL would be frustrating. You'd
49+
constantly have to convert the raw primitive `int` or `string` or `Guid` values that come out of your SQL query results
50+
to your domain types, and unpack your domain types back to primitives to pass them in as query parameters.
51+
52+
With UserTypes you can solve this. A user-mapped primitive type can take either of the following forms:
53+
54+
### Single-case union
55+
56+
This is the simplest case. Any F# union type with a single case that wraps an underlying [built-in
57+
primitive](../DataTypes.md) will automatically be detected as a valid UserType without needing further annotations or
58+
methods.
59+
60+
```fsharp
61+
// typical single-case DU wrapper pattern
62+
type UserId = UserId of System.Guid
63+
64+
// struct DUs work fine too
65+
[<Struct>]
66+
type FileHash = FileHash of byte[]
67+
```
68+
69+
### ToPrimitive/FromPrimitive static wrappers
70+
71+
This is a more advanced case. Perhaps your type is a little more complicated than a single-case DU wrapper. That's fine,
72+
you can define the mapping directly.
73+
74+
```fsharp
75+
type EmailAddress(rawEmail : string) =
76+
do
77+
if isNull rawEmail || not(rawEmail.Contains("@")) then
78+
invalidArg (nameof rawEmail) "Email must be non-null and contain @"
79+
80+
override this.ToString() = raw
81+
82+
static member ToPrimitive(email : EmailAddress) : string = email.ToString()
83+
static member FromPrimitive(raw : string) : EmailAddress = EmailAddress(raw)
84+
```
85+
86+
`EmailAddress` will be detected as a valid UserType because of the ToPrimitive and FromPrimitive methods mapping it it to string.
87+
88+
If you don't like having those static methods littering your domain, or you can't add them because the type you're
89+
trying to map is from another library you can't edit, that's not a problem!
90+
91+
ToPrimitive and FromPrimitive **do not have to be** declared by the same type that they are mapping.
92+
93+
For example, you can map the BCL type `System.DateOnly` by declaring a static class:
94+
95+
```fsharp
96+
type DateOnlyMapping() =
97+
static member ToPrimitive(date : DateOnly) : string = date.ToString("o")
98+
static member FromPrimitive(str : string) : DateOnly = DateOnly.ParseExact(str, "o")
99+
```
100+
101+
Or even a module:
102+
103+
```fsharp
104+
module DateOnlyMapping =
105+
let ToPrimitive (date : DateOnly) = date.ToString("o")
106+
let FromPrimitive (str : string) = DateOnly.ParseExact(str, "o")
107+
```
108+
109+
Or my personal preference, F# extension methods:
28110

29-
## Mapping externally owned primitive types
111+
```fsharp
112+
module MyCustomMappings =
113+
type DateOnly with
114+
member this.ToPrimitive() = this.ToString("o")
115+
static member this.FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
116+
```
30117

31-
STUB
118+
You can have as many classes as you want defining static custom mappings. But you can't split the mapping for a *single
119+
usertype* across multiple classes. `ToPrimitive : Foo -> string` has to be defined in the *same* class as `FromPrimitive
120+
: string -> Foo` for the mapping to be valid.
121+
122+
## Using the mapped types
123+
124+
Once you've got your UserTypes assembly plugged in via [rzsql.json](../../Configuration/Json.md), you can use your
125+
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+
127+
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+
129+
And when your query uses a parameter that you compare with the `Id` column, that parameter will be inferred as a `UserId` as well.
130+
131+
```fsharp
132+
type MyQuery = SQL<"select * from Users where Id = @id">
133+
134+
let someGuid = Guid.Parse("6f626f4e-7964-6957-6c6c-526561644974")
135+
136+
plan {
137+
// command requires a UserId parameter
138+
let! row = MyQuery.Command(id = UserId someGuid).ExactlyOne()
139+
let id = row.Id // type is UserId
140+
let email = row.Email // type is EmailAddress
141+
return id, email
142+
}
143+
```
32144

33145
## Row interfaces
34146

35-
STUB
147+
Another problem the UserTypes features solves is that RZSQL generates a new row type for *every* SQL query you write.
148+
149+
```fsharp
150+
type QueryUserById = SQL<"select * from Users where Id = @id">
151+
type QueryUserByEmail = SQL<"select * from Users where Email = @email">
152+
```
153+
154+
The above two queries both select all columns from the `Users` table, but they have two different row types,
155+
`QueryUserById.Row` and `QueryUserByEmail.Row`.
156+
157+
Those types are *structurally identical* but they are *nominally different*, so you can't easily write code that works on both.
158+
159+
Unfortunately, there is no good way for the provider to make these return the same row type. Each `SQL<...>` invocation
160+
can only generate types *nested under* itself.
161+
162+
However, with UserTypes we can do the next best thing. We can make the generated types implement *the same interface*.
163+
164+
In your UserTypes assembly, write an interface matching the shape of the columns in the query:
165+
166+
```fsharp
167+
type IUserRow =
168+
member Id : UserId
169+
member Email : EmailAddress
170+
// ... etc
171+
```
172+
173+
Now in your queries, you can specify that you want the resulting row type to implement your `IUserRow` interface.
174+
This is done by changing the `select` to `select<IUserRow>`.
175+
176+
```fsharp
177+
type QueryUserById = SQL<"select<IUserRow> * from Users where Id = @id">
178+
type QueryUserByEmail = SQL<"select<IUserRow> * from Users where Email = @email">
179+
```
180+
181+
As long as the columns specified in the `IUserRow` interface are found in the result set, both `QueryUserById.Row` and
182+
`QueryUserByEmail.Row` will implement `IUserRow`.
183+
184+
Now you can write downstream code to consume that interface, such as mapping `IUserRow` to a DTO type that your web API
185+
returns to clients. You no longer have to deal with duplicating boilerplate mapping code on a bunch of different
186+
basically-identical row types.
187+
188+
If the columns needed to implement the interface are *not* present, you'll get an error **at compile-time**.
189+
190+
You can also declare a query implements *multiple* interfaces by separating with commas:
191+
192+
```sql
193+
select<IUserRow, ISoftDelete, IHasThumbnail, IHaveALotOfInterfaces> * from Users
194+
```
195+
196+
## Controlling field lengths and storage type
197+
198+
In most SQL databases string and binary columns can (and should) have a max length specified.
199+
200+
But when you map a UserType to a `string` or a `byte[]`, by default it will come through without a length specifier.
201+
202+
This means the above examples like the `DateOnly` mapping or the `EmailAddress` mapping would be stored as
203+
`nvarchar(max)` in TSQL.
204+
205+
You can override this by using the `SQLTypeLength` attribute from the `Rezoom.SQL.Annotations` NuGet package.
206+
The attribute can go on the type being mapped...
207+
208+
```fsharp
209+
open Rezoom.SQL.Annotations
210+
211+
[<SQLTypeLength(255)>] // store as nvarchar(255)
212+
type EmailAddress(rawEmail : string) =
213+
...
214+
215+
```
216+
217+
...Or on one of the methods doing the mapping:
218+
219+
```fsharp
220+
module MyCustomMappings =
221+
type DateOnly with
222+
[<SQLTypeLength(10)>] // store as nvarchar(10)
223+
member this.ToPrimitive() = this.ToString("o")
224+
static member this.FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
225+
```
226+
227+
A more heavy-handed alternative is to override the entire type name used on the backend.
228+
For example, if you want more compact storage for the 10-char `DateOnly` type, you could make it a `char(10)` instead of `nvarchar`.
229+
This is done with the `RawBackendSQLType` attribute.
230+
231+
```fsharp
232+
type DateOnly with
233+
[<RawBackendSQLType("char(10)")>]
234+
member this.ToPrimitive() = this.ToString("o")
235+
static member this.FromPrimitive(str : string) = DateOnly.ParseExact(str.Trim(), "o")
236+
```
237+
238+
Note that `RawBackendSQLType` and `SQLTypeLength` cannot be specified on the same type, because the former completely
239+
overrides the latter and makes it redundant.
240+
241+
The string passed to `RawBackendSQLType` is opaque to RZSQL and not type-checked. It is your responsibility to ensure
242+
that it's syntactically valid and that it can store the data you're mapping into it.
36243

37244
## Mapping to vendor-specific database column types
38245

39-
STUB
246+
In addition to the aforementioned [built-in primitive](../DataTypes.md) datatypes, your `ToPrimitive` and
247+
`FromPrimitive` methods can map to `System.Object`.
248+
249+
This allows you to store and retrieve *anything* your underlying ADO.NET provider can handle.
250+
251+
For example, you can map to the `point` type in `Postgres` like so:
252+
253+
```fsharp
254+
[<RawBackendSQLType("point")>]
255+
[<SQLParameterDbType("NpgsqlDbType", 15)>]
256+
type Point2D =
257+
{ X : double
258+
Y : double
259+
}
260+
static member ToPrimitive(p : Point2D) : System.Object =
261+
box (NpgsqlTypes.NpgsqlPoint(p.X, p.Y))
262+
static member FromPrimitive(o : System.Object) : Point2D =
263+
let pt = o :?> NpgsqlTypes.NpgsqlPoint
264+
{ X = pt.X; Y = pt.Y }
265+
```
266+
267+
When mapping to `System.Object`, the `RawBackendSQLType` attribute is **required**.
268+
269+
Otherwise RZSQL would have no clue what underlying datatype to use on a `Point2D` column!
270+
271+
You'll also notice a new attribute on the above example, `[<SQLParameterDbType("NpgsqlDbType", 15)>]`.
272+
273+
This is used when you write a query that takes a `Point2D` as a *parameter*.
274+
275+
When the RZSQL runtime executes a query with UserType parameters, it first converts them to their underlying
276+
representation via `ToPrimitive`. The output of that `ToPrimitive()` call becomes the
277+
[dbParam.Value](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbparameter.value?view=net-10.0).
278+
279+
By default,
280+
[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
282+
`DbType.Int32` is appropriate.
283+
284+
Usually that is fine.
285+
286+
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.
287+
288+
In this case the correct thing to do, knowing that the `DbParameter` is specifically an instance of
289+
[NpgsqlParameter](https://www.npgsql.org/doc/api/Npgsql.NpgsqlParameter.html), is to set `dbParam.NpgsqlDbType <- NpgsqlDbType.Point`.
290+
291+
The attribute here gives the runtime the information it needs to do that via reflection. The runtime doesn't carry an
292+
Npgsql dependency and doesn't directly know about those data types, but it essentially does this:
293+
294+
```fsharp
295+
let prop = dbParam.GetType().GetProperty(propName, BindingFlags.Instance|||BindingFlags.Public)
296+
prop.SetValue(dbParam, Enum.ToObject(prop.PropertyType, intValue))
297+
```
298+
299+
In the above snippet, `propName` and `intValue` come from the `[<SQLParameterDbType("NpgsqlDbType", 15)>]` attribute, 15
300+
being the integer value of NpgsqlDbType.Point.
40301

41302
## Annotation attributes reference
42303

43-
STUB
304+
### RawBackendSQLType
305+
306+
Usage: `[<RawBackendSQLType(sqlType : string)>]`
307+
308+
Specifies the literal type RZSQL should use for columns storing this usertype and in typename-carrying expressions like `CAST(x AS MyUserType)`.
309+
This allows you to override the default storage format RZSQL would use for the underlying primitive type.
310+
311+
You SHOULD include the length specifier, if one is needed, in the string such as `"varchar(50)"`.
312+
313+
You SHOULD NOT include nullability information like `"varchar(50) NOT NULL"` in the string. RZSQL will already add
314+
nullability annotations where appropriate so this would generate redundant, invalid syntax.
315+
316+
The `RawBackendSQLType` attribute is REQUIRED if the data type you map To/From is `System.Object`.
317+
318+
### SQLTypeLength
319+
320+
Usage: `[<SQLTypeLength(length : int)>]`
321+
322+
Specifies the maximum length for a UserType mapped to string (`nvarchar(N)`) or byte[] (`varbinary(N)`).
323+
324+
Not valid to combine this with `RawBackendSQLType`, since that already includes a length.
325+
326+
### SQLParameterDbType
327+
328+
Usage 1: `[<SQLParameterDbType(dbType : System.Data.DbType)>]`
329+
330+
Usage 2: `[<SQLParameterDbType(dbParameterPropertyName : string, value : int)>]`
331+
332+
Specifies the `DbType` to use when this UserType is passed into a query as a parameter.
333+
334+
You can change to a different `DbType` using the first constructor, like `[<SQLParameterDbType(DbType.Xml)>]`.
335+
336+
For advanced use cases where the standard `DbType` set is not sufficient and you need to set a different integer-valued
337+
property on the ADO.NET provider's implementation of `DbParameter`, you can use the second constructor. The property
338+
will be resolved by name at runtime and set to the specified integer value.
339+
340+
## Pitfalls and limitations
341+
342+
### No generics
343+
344+
You cannot map a .NET generic type as a usertype. For example, maybe every entity in your domain has a Guid PK. You
345+
might wish to write a single `type Id<'a> = Id of Guid` and then use `Id<User>`, `Id<Group>`, etc. instead of defining
346+
individual types for each one. This is not supported. You'll have to use `type UserId = UserId of Guid` and `type
347+
GroupId = GroupId of Guid` and so on.
348+
349+
### Changes affecting schema
350+
351+
When you change your usertypes library, RZSQL has no way of knowing about the history.
352+
353+
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+
to an `int` (seconds since midnight). It's a small task to change your .NET assembly to replace the `ToPrimitive` and
355+
`FromPrimitive` methods, but there is still data in your DB with the old string type.
356+
357+
As far as RZSQL is concerned, it has no idea. One of your migrations from a year ago said `create table Foo(TimeOfDay TimeOnly)`.
358+
That migration created an `nvarchar` column in your SQL Server database and there's live data in there.
359+
360+
Now that you've changed the `TimeOnly` mapping, RZSQL thinks that old migration made an `int` column and always has. The
361+
existence of the `nvarchar` column has been memory-holed: we have always been at war with Eastasia. Your queries will
362+
fail at runtime because RZSQL's idea of your database model no longer matches reality.
363+
364+
The solution is to write a new migration and use a [VENDOR statement](../VendorStmts.md) to port data over from the old
365+
format to the new. The vendor statement will allow you to bypass RZSQL's outdated conception of the data types and work
366+
on the real data in the table. Something like:
367+
368+
```sql
369+
// migration to handle changing storage format from string to int
370+
VENDOR tsql {
371+
// create a new column
372+
ALTER TABLE dbo.Foo ADD [NewTime] INT;
373+
// port the data over from the old format
374+
UPDATE dbo.Foo SET [NewTime] = DATEDIFF(SECOND, 0, CAST([TimeOfDay] AS TIME));
375+
// drop the old column and swap in the new one
376+
ALTER TABLE dbo.Foo DROP COLUMN [TimeOfDay];
377+
EXEC sp_rename 'dbo.Foo.NewTime', 'TimeOfDay', 'COLUMN';
378+
} IMAGINE {
379+
// nothing here, so the typechecker thinks nothing happened
380+
}
381+
```
44382

45-
## Pitfalls to know about
383+
The key thing to remember here is it is up to you to be disciplined about changing your storage representation!
46384

47-
STUB

0 commit comments

Comments
 (0)