Skip to content

Commit 3e2dda3

Browse files
committed
Break out UserTypes documentation into separate pages.
1 parent 4ec11c9 commit 3e2dda3

7 files changed

Lines changed: 338 additions & 259 deletions

File tree

SUMMARY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
* [Navigation properties](doc/Language/NavigationProperties.md)
2929
* [Vendor statements](doc/Language/VendorStatements.md)
3030
* [UserTypes](doc/Language/UserTypes/README.md)
31+
* [Field lengths and storage type](doc/Language/UserTypes/FieldLengthsAndStorage.md)
32+
* [Advanced primitive mapping](doc/Language/UserTypes/AdvancedMapping.md)
33+
* [Annotation attributes reference](doc/Language/UserTypes/AttributesReference.md)
34+
* [Pitfalls and limitations](doc/Language/UserTypes/Pitfalls.md)
3135
* [Functions](doc/Language/Functions/README.md)
3236
* [SQLite](doc/Language/Functions/SQLiteFunctions.md)
3337
* [TSQL](doc/Language/Functions/TSQLFunctions.md)

doc/Language/Functions/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ has_children: true
88
<!-- nav-top -->
99
[Home](../../../README.md) &gt; [Language](../README.md) &gt; Functions
1010

11-
[&larr; UserTypes](../UserTypes/README.md) | [SQLite &rarr;](SQLiteFunctions.md)
11+
[&larr; Pitfalls and limitations](../UserTypes/Pitfalls.md) | [SQLite &rarr;](SQLiteFunctions.md)
1212
<!-- /nav-top -->
1313

1414
# Functions
@@ -119,6 +119,6 @@ This way, only `@b` will be nullable.
119119

120120
---
121121
<!-- nav-bottom -->
122-
[&larr; UserTypes](../UserTypes/README.md) | [SQLite &rarr;](SQLiteFunctions.md)
122+
[&larr; Pitfalls and limitations](../UserTypes/Pitfalls.md) | [SQLite &rarr;](SQLiteFunctions.md)
123123
<!-- /nav-bottom -->
124124

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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) &gt; [Language](../README.md) &gt; [UserTypes](README.md) &gt; Advanced primitive mapping
10+
11+
[&larr; Field lengths and storage type](FieldLengthsAndStorage.md) | [Annotation attributes reference &rarr;](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+
[&larr; Field lengths and storage type](FieldLengthsAndStorage.md) | [Annotation attributes reference &rarr;](AttributesReference.md)
130+
<!-- /nav-bottom -->
131+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
title: Annotation attributes reference
3+
parent: UserTypes
4+
grand_parent: Language
5+
nav_order: 3
6+
---
7+
8+
<!-- nav-top -->
9+
[Home](../../../README.md) &gt; [Language](../README.md) &gt; [UserTypes](README.md) &gt; Annotation attributes reference
10+
11+
[&larr; Advanced primitive mapping](AdvancedMapping.md) | [Pitfalls and limitations &rarr;](Pitfalls.md)
12+
<!-- /nav-top -->
13+
14+
# Annotation attributes reference
15+
16+
## RawBackendSQLType
17+
18+
Usage: `[<RawBackendSQLType(sqlType : string)>]`
19+
20+
Specifies the literal type RZSQL should use for columns storing this UserType and in typename-carrying expressions like `CAST(x AS MyUserType)`.
21+
This allows you to override the default storage format RZSQL would use for the underlying primitive type.
22+
23+
You SHOULD include the length specifier, if one is needed, in the string such as `"varchar(50)"`.
24+
25+
You SHOULD NOT include nullability information like `"varchar(50) NOT NULL"` in the string. RZSQL will already add
26+
nullability annotations where appropriate so this would generate redundant, invalid syntax.
27+
28+
The `RawBackendSQLType` attribute is REQUIRED if the data type you map To/From is `System.Object`.
29+
30+
## SQLTypeLength
31+
32+
Usage: `[<SQLTypeLength(length : int)>]`
33+
34+
Specifies the maximum length for a UserType mapped to string (`nvarchar(N)`) or byte[] (`varbinary(N)`).
35+
36+
Not valid to combine this with `RawBackendSQLType`, since that already includes a length.
37+
38+
## SQLParameterDbType
39+
40+
Constructor 1: `[<SQLParameterDbType(dbType : System.Data.DbType)>]`
41+
42+
Constructor 2: `[<SQLParameterDbType(dbParameterPropertyName : string, value : int)>]`
43+
44+
Specifies the `DbType` to use when this UserType is passed into a query as a parameter.
45+
46+
You can change to a different `DbType` using the first constructor, like `[<SQLParameterDbType(DbType.Xml)>]`.
47+
48+
For advanced use cases where the standard `DbType` set is not sufficient and you need to set a different integer-valued
49+
property on the ADO.NET provider's implementation of `DbParameter`, you can use the second constructor. The property
50+
will be resolved by name at runtime and set to the specified integer value.
51+
52+
---
53+
<!-- nav-bottom -->
54+
[&larr; Advanced primitive mapping](AdvancedMapping.md) | [Pitfalls and limitations &rarr;](Pitfalls.md)
55+
<!-- /nav-bottom -->
56+
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
title: Field lengths and storage type
3+
parent: UserTypes
4+
grand_parent: Language
5+
nav_order: 1
6+
---
7+
8+
<!-- nav-top -->
9+
[Home](../../../README.md) &gt; [Language](../README.md) &gt; [UserTypes](README.md) &gt; Field lengths and storage type
10+
11+
[&larr; UserTypes](README.md) | [Advanced primitive mapping &rarr;](AdvancedMapping.md)
12+
<!-- /nav-top -->
13+
14+
# Field lengths and storage type
15+
16+
In most SQL databases string and binary columns can (and should) have a max length specified.
17+
18+
But when you map a UserType to a `string` or a `byte[]`, by default it will come through without a length specifier.
19+
20+
This means the above examples like the `DateOnly` mapping or the `EmailAddress` mapping would be stored as
21+
`nvarchar(max)` in TSQL.
22+
23+
You can override this by using the `SQLTypeLength` attribute from the `Rezoom.SQL.Annotations` NuGet package.
24+
The attribute can go on the type being mapped...
25+
26+
```fsharp
27+
open Rezoom.SQL.Annotations
28+
29+
[<SQLTypeLength(255)>] // store as nvarchar(255)
30+
type EmailAddress(rawEmail : string) =
31+
...
32+
33+
```
34+
35+
...Or on one of the methods doing the mapping:
36+
37+
```fsharp
38+
module MyCustomMappings =
39+
type DateOnly with
40+
[<SQLTypeLength(10)>] // store as nvarchar(10)
41+
member this.ToPrimitive() = this.ToString("o")
42+
static member FromPrimitive(str : string) = DateOnly.ParseExact(str, "o")
43+
```
44+
45+
A more heavy-handed alternative is to override the entire type name used on the backend.
46+
For example, if you want more compact storage for the 10-char `DateOnly` type, you could make it a `char(10)` instead of `nvarchar`.
47+
This is done with the `RawBackendSQLType` attribute.
48+
49+
```fsharp
50+
type DateOnly with
51+
[<RawBackendSQLType("char(10)")>]
52+
member this.ToPrimitive() = this.ToString("o")
53+
static member FromPrimitive(str : string) = DateOnly.ParseExact(str.Trim(), "o")
54+
```
55+
56+
Note that `RawBackendSQLType` and `SQLTypeLength` cannot be specified on the same type, because the former completely
57+
overrides the latter and makes it redundant.
58+
59+
The string passed to `RawBackendSQLType` is opaque to RZSQL and not type-checked. It is your responsibility to ensure
60+
that it's syntactically valid and that it can store the data you're mapping into it.
61+
62+
---
63+
<!-- nav-bottom -->
64+
[&larr; UserTypes](README.md) | [Advanced primitive mapping &rarr;](AdvancedMapping.md)
65+
<!-- /nav-bottom -->
66+

doc/Language/UserTypes/Pitfalls.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: Pitfalls and limitations
3+
parent: UserTypes
4+
grand_parent: Language
5+
nav_order: 4
6+
---
7+
8+
<!-- nav-top -->
9+
[Home](../../../README.md) &gt; [Language](../README.md) &gt; [UserTypes](README.md) &gt; Pitfalls and limitations
10+
11+
[&larr; Annotation attributes reference](AttributesReference.md) | [Functions &rarr;](../Functions/README.md)
12+
<!-- /nav-top -->
13+
14+
# Pitfalls and limitations
15+
16+
## Must map each specific type, not just a base type
17+
18+
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.
19+
20+
## No chaining primitives
21+
22+
Your `ToPrimitive` and `FromPrimitive` must map *directly* to a builtin primitive type, not *another* wrapper type.
23+
24+
You can't have `Foo` mapped to underlying type `Bar`, `Bar` mapped to `Baz` and `Baz` mapped to `int`. Even though it
25+
would be possible to follow that chain of wrappers and unwrappers to convert between `Foo` and `int`, RZSQL does not do
26+
this. It would require additional error-checking to prevent cyclical paths and it would just make the mapping of `Foo`
27+
harder to follow for any reader of your code.
28+
29+
## No generics
30+
31+
You cannot map a .NET generic type as a UserType. For example, maybe every entity in your domain has a Guid PK. You
32+
might wish to write a single `type Id<'a> = Id of Guid` and then use `Id<User>`, `Id<Group>`, etc. instead of defining
33+
individual types for each one. This is not supported. You'll have to use
34+
`type UserId = UserId of Guid` and `type GroupId = GroupId of Guid` and so on.
35+
36+
## Changes affecting schema
37+
38+
When you change your UserTypes library, RZSQL has no way of knowing about the history.
39+
40+
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
41+
to an `int` (seconds since midnight). It's a small task to change your .NET assembly to replace the `ToPrimitive` and
42+
`FromPrimitive` methods, but there is still data in your DB with the old string type.
43+
44+
As far as RZSQL is concerned, it has no idea. One of your migrations from a year ago said `create table Foo(TimeOfDay TimeOnly)`.
45+
That migration created an `nvarchar` column in your SQL Server database and there's live data in there.
46+
47+
Now that you've changed the `TimeOnly` mapping, RZSQL thinks that old migration made an `int` column and always has. The
48+
existence of the `nvarchar` column has been memory-holed: we have always been at war with Eastasia. Your queries will
49+
fail at runtime because RZSQL's idea of your database model no longer matches reality.
50+
51+
The solution is to write a new migration and use a [VENDOR statement](../VendorStatements.md) to port data over from the old
52+
format to the new. The vendor statement will allow you to bypass RZSQL's outdated conception of the data types and work
53+
on the real data in the table. Something like:
54+
55+
```sql
56+
// migration to handle changing storage format from string to int
57+
VENDOR tsql {
58+
// create a new column
59+
ALTER TABLE dbo.Foo ADD [NewTime] INT;
60+
// port the data over from the old format
61+
UPDATE dbo.Foo SET [NewTime] = DATEDIFF(SECOND, 0, CAST([TimeOfDay] AS TIME));
62+
// drop the old column and swap in the new one
63+
ALTER TABLE dbo.Foo DROP COLUMN [TimeOfDay];
64+
EXEC sp_rename 'dbo.Foo.NewTime', 'TimeOfDay', 'COLUMN';
65+
} IMAGINE {
66+
// nothing here, so the typechecker thinks nothing happened
67+
}
68+
```
69+
70+
The key thing to remember here is it is up to you to be disciplined about changing your storage representation!
71+
72+
---
73+
<!-- nav-bottom -->
74+
[&larr; Annotation attributes reference](AttributesReference.md) | [Functions &rarr;](../Functions/README.md)
75+
<!-- /nav-bottom -->
76+

0 commit comments

Comments
 (0)