Skip to content

Commit fd1b4b1

Browse files
committed
New tests for object-mapping features.
1 parent d058fd4 commit fd1b4b1

14 files changed

Lines changed: 337 additions & 10 deletions

File tree

src/Rezoom.SQL.Test.UserTypes/UserPrimitives.fs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,25 @@ type FileHash = FileHash of byte[]
9393
/// Single-case DU over byte[] with [<SQLTypeLength>] acts like
9494
/// BinaryTypeName(Some 32).
9595
[<SQLTypeLength(32)>]
96-
type ShortHash = ShortHash of byte[]
96+
type ShortHash = ShortHash of byte[]
97+
98+
// --- SQLParameterDbType fixtures --------------------------------------
99+
// These exist purely so the loader-inspection tests in
100+
// Rezoom.SQL.Test.TestUserTypeAnnotations can assert the
101+
// SQLParameterDbType attribute round-trips into UserPrimitiveType. The
102+
// types themselves are never bound at runtime in the Rezoom.SQL.Test
103+
// suite; the assertions read the loaded UserTypeLibrary directly.
104+
105+
/// Exercises the single-arg constructor — SQLParameterDbType(DbType).
106+
/// Expected to surface as ("DbType", int DbType.AnsiString) on the
107+
/// loaded UserPrimitiveType.SQLParameterDbType.
108+
[<SQLParameterDbType(System.Data.DbType.AnsiString)>]
109+
type AnsiLabel = AnsiLabel of string
110+
111+
/// Exercises the two-arg escape-hatch constructor for provider-specific
112+
/// enums — SQLParameterDbType(propertyName, int). The values here are
113+
/// deliberately not bound at runtime; we only care that the metadata
114+
/// round-trips. (36 happens to be NpgsqlDbType.Jsonb but no
115+
/// Postgres-specific assembly is referenced from this fixture.)
116+
[<SQLParameterDbType("NpgsqlDbType", 36)>]
117+
type OpaqueDbTypeProbe = OpaqueDbTypeProbe of string

src/Rezoom.SQL.Test/TestUserTypeAnnotations.fs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
module Rezoom.SQL.Test.TestUserTypeAnnotations
22
open NUnit.Framework
3+
open Rezoom.SQL.Mapping
34

45
// --- SQLite: RawBackendSQLType emits the literal type verbatim --------
56

@@ -250,3 +251,48 @@ let ``tsql DU over byte[] without SQLTypeLength emits VARBINARY(max)`` () =
250251
|> Some
251252
} |> Good
252253
} |> assertSimple
254+
255+
// --- SQLParameterDbType loader-inspection regression tests ------------
256+
// These do not exercise SQL emission. Instead they load the user-types
257+
// library and inspect UserPrimitiveType.SQLParameterDbType directly,
258+
// catching attribute-loader breakage at compiler-test speed instead of
259+
// having to wait for a TPU run to surface it as a downstream PG error
260+
// like `column "home" is of type jsonb but expression is of type text`.
261+
262+
let private userTypesLib =
263+
lazy ((userModelByName "user-model-7-usertypes").UserTypeLibrary)
264+
265+
let private primitive name =
266+
match userTypesLib.Value.UserPrimitiveByName(name) with
267+
| FoundType prim -> prim
268+
| AmbiguousType _ ->
269+
Assert.Fail(sprintf "User primitive '%s' is ambiguous in the loaded library." name)
270+
Unchecked.defaultof<_>
271+
| NotFoundType _ ->
272+
Assert.Fail(sprintf "User primitive '%s' was not found in the loaded library." name)
273+
Unchecked.defaultof<_>
274+
275+
[<Test>]
276+
let ``SQLParameterDbType single-arg ctor on AnsiLabel loads as Some(DbType, int)`` () =
277+
// [<SQLParameterDbType(System.Data.DbType.AnsiString)>] on AnsiLabel
278+
// is the standard-DbType ctor; the C# attribute delegates to the
279+
// two-arg form with property name "DbType" and value (int)dbType,
280+
// and the loader records the same shape.
281+
let expected = Some ("DbType", int System.Data.DbType.AnsiString)
282+
Assert.That((primitive "AnsiLabel").SQLParameterDbType, Is.EqualTo(expected))
283+
284+
[<Test>]
285+
let ``SQLParameterDbType two-arg ctor on OpaqueDbTypeProbe loads as Some(prop, int)`` () =
286+
// [<SQLParameterDbType("NpgsqlDbType", 36)>] on OpaqueDbTypeProbe is
287+
// the escape-hatch ctor used by provider-specific enums; the
288+
// attribute identity check, ctor-arity branch, and tuple read all
289+
// have to survive for the metadata to round-trip.
290+
let expected = Some ("NpgsqlDbType", 36)
291+
Assert.That((primitive "OpaqueDbTypeProbe").SQLParameterDbType, Is.EqualTo(expected))
292+
293+
[<Test>]
294+
let ``SQLParameterDbType is None on a primitive without the attribute`` () =
295+
// CompactInt has [<RawBackendSQLType>] but no [<SQLParameterDbType>],
296+
// so the loader should leave the field as None. Guards against a
297+
// future change that accidentally always-Somes the field.
298+
Assert.That((primitive "CompactInt").SQLParameterDbType, Is.EqualTo(None))
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace TypeProviderUser.Postgres.UserTypes
2+
3+
open System.Text.Json
4+
open Rezoom.SQL.Annotations
5+
6+
/// Address as a user primitive that stores as PG jsonb. Demonstrates the
7+
/// System.Object underlying-CLR-type escape hatch: ToPrimitive returns
8+
/// `obj` (a JSON-serialized string boxed), and FromPrimitive accepts the
9+
/// same `obj` shape coming back from the driver. The RawBackendSQLType
10+
/// pins the SQL type as "jsonb" and the ParameterDbType attribute tells
11+
/// the runtime to set NpgsqlDbType.Jsonb on the parameter so Npgsql
12+
/// binds it as the right backend type.
13+
// Note: 36 = NpgsqlTypes.NpgsqlDbType.Jsonb (Npgsql 8.x).
14+
// F# does not accept enum-to-int casts in attribute argument
15+
// position so the integer literal is the cleanest available form.
16+
[<RawBackendSQLType("jsonb")>]
17+
[<SQLParameterDbType("NpgsqlDbType", 36)>]
18+
type Address =
19+
{ Street : string
20+
City : string
21+
State : string
22+
Zip : string
23+
}
24+
static member ToPrimitive(a : Address) : obj =
25+
box (JsonSerializer.Serialize(a))
26+
static member FromPrimitive(o : obj) : Address =
27+
JsonSerializer.Deserialize<Address>(o :?> string)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<Compile Include="Library.fs" />
10+
</ItemGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\..\Rezoom.SQL.Annotations\Rezoom.SQL.Annotations.csproj" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<!-- Pulled in only so the NpgsqlDbType enum is available at compile
18+
time for the [<ParameterDbType("NpgsqlDbType", int NpgsqlDbType.Jsonb)>]
19+
attribute argument. The attribute itself stores the int; Rezoom's
20+
MLC loader doesn't need to resolve Npgsql at TP design time. -->
21+
<PackageReference Include="Npgsql" Version="8.0.5" />
22+
</ItemGroup>
23+
24+
</Project>

src/TypeProviderUsers/TypeProviderUser.Postgres/Shared.fs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ type TestModel = SQLModel<".">
1212

1313
type CleanTestData = SQL<"""
1414
vendor postgres {
15-
drop table __RZSQL_MIGRATIONS;
16-
drop table ArticleComments;
17-
drop table Articles;
18-
drop table Users;
19-
drop table Pictures;
15+
drop table if exists __RZSQL_MIGRATIONS;
16+
drop table if exists UserAddresses;
17+
drop table if exists ArticleComments;
18+
drop table if exists Articles;
19+
drop table if exists Users;
20+
drop table if exists Pictures;
2021
}
2122
""">
2223

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
module TypeProviderUser.Postgres.TestUserPrimitiveSystemObject
2+
open NUnit.Framework
3+
open Rezoom.SQL
4+
open Rezoom.SQL.Raw
5+
open TypeProviderUser.Postgres.UserTypes
6+
7+
let private homerAddr =
8+
{ Street = "742 Evergreen Terrace"
9+
City = "Springfield"
10+
State = "OR"
11+
Zip = "97477"
12+
}
13+
14+
let private margeAddr =
15+
{ Street = "742 Evergreen Terrace"
16+
City = "Springfield"
17+
State = "OR"
18+
Zip = "97477"
19+
}
20+
21+
let private bartAddr =
22+
{ Street = "1313 Mockingbird Lane"
23+
City = "Shelbyville"
24+
State = "OR"
25+
Zip = "97001"
26+
}
27+
28+
type InsertAndSelectAddresses = SQL<"""
29+
insert into UserAddresses(UserId, Home)
30+
values((select Id from Users where Name = 'Homer'), @homer);
31+
insert into UserAddresses(UserId, Home)
32+
values((select Id from Users where Name = 'Marge'), @marge);
33+
select Home from UserAddresses order by Id;
34+
""">
35+
36+
[<Test>]
37+
let ``select roundtrips an Address user primitive over System.Object`` () =
38+
let results = InsertAndSelectAddresses.Command(homerAddr, margeAddr) |> runOnTestData
39+
Assert.AreEqual(2, results.Count)
40+
Assert.AreEqual(homerAddr, results.[0].Home)
41+
Assert.AreEqual(margeAddr, results.[1].Home)
42+
43+
type FindAddressByParameterEquality = SQL<"""
44+
insert into UserAddresses(UserId, Home)
45+
values((select Id from Users where Name = 'Homer'), @homer);
46+
insert into UserAddresses(UserId, Home)
47+
values((select Id from Users where Name = 'Marge'), @bart);
48+
select Home from UserAddresses where Home = @needle;
49+
""">
50+
51+
[<Test>]
52+
let ``select with Address parameter equality matches via PG jsonb = operator`` () =
53+
let results =
54+
FindAddressByParameterEquality.Command(bartAddr, homerAddr, homerAddr)
55+
|> runOnTestData
56+
Assert.AreEqual(1, results.Count)
57+
Assert.AreEqual(homerAddr, results.[0].Home)
58+
59+
type FindAddressByStateViaJsonOperator = SQL<"""
60+
insert into UserAddresses(UserId, Home)
61+
values((select Id from Users where Name = 'Homer'), @homer);
62+
insert into UserAddresses(UserId, Home)
63+
values((select Id from Users where Name = 'Marge'), @marge);
64+
select Home from UserAddresses ua where unsafe_inject_raw(@filter) order by ua.Id;
65+
""">
66+
67+
[<Test>]
68+
let ``PG jsonb path operator on Address column works via unsafe_inject_raw`` () =
69+
// We alias UserAddresses as ua in the SQL above so the raw filter
70+
// can reference the column with a known qualifier. Rezoom's PG
71+
// backend emits identifiers unquoted, so PG folds them to lowercase
72+
// — the raw filter must use ua.home (lowercase) to resolve.
73+
let results =
74+
FindAddressByStateViaJsonOperator.Command
75+
( filter = [| sql "ua.home ->> 'City' = 'Shelbyville'" |]
76+
, homer = homerAddr
77+
, marge = bartAddr
78+
)
79+
|> runOnTestData
80+
Assert.AreEqual(1, results.Count)
81+
Assert.AreEqual(bartAddr, results.[0].Home)
82+
83+
type FindAddressByInList = SQL<"""
84+
insert into UserAddresses(UserId, Home)
85+
values((select Id from Users where Name = 'Homer'), @homer);
86+
insert into UserAddresses(UserId, Home)
87+
values((select Id from Users where Name = 'Marge'), @marge);
88+
select Home from UserAddresses where Home in @needles;
89+
""">
90+
91+
[<Test>]
92+
let ``select Address where in non-empty list matches the expected row`` () =
93+
let results =
94+
FindAddressByInList.Command(homerAddr, bartAddr, [| homerAddr |])
95+
|> runOnTestData
96+
Assert.AreEqual(1, results.Count)
97+
Assert.AreEqual(homerAddr, results.[0].Home)
98+
99+
[<Test>]
100+
let ``select Address where in empty list returns zero rows via jsonb empty-IN substitution`` () =
101+
let results =
102+
FindAddressByInList.Command(homerAddr, bartAddr, [||])
103+
|> runOnTestData
104+
Assert.AreEqual(0, results.Count)

src/TypeProviderUsers/TypeProviderUser.Postgres/TypeProviderUser.Postgres.fsproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<Compile Include="AssemblyInfo.fs" />
1717
<Compile Include="Shared.fs" />
1818
<Compile Include="TestSelects.fs" />
19+
<Compile Include="TestUserPrimitiveSystemObject.fs" />
1920
<Compile Include="TestMigrateConnectionString.fs" />
2021
<Compile Include="Program.fs" />
2122
<None Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
@@ -39,4 +40,8 @@
3940
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
4041
</ItemGroup>
4142

43+
<ItemGroup>
44+
<ProjectReference Include="..\TypeProviderUser.Postgres.UserTypes\TypeProviderUser.Postgres.UserTypes.fsproj" />
45+
</ItemGroup>
46+
4247
</Project>

src/TypeProviderUsers/TypeProviderUser.Postgres/V1.model.sql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,9 @@ create table ArticleComments
3030

3131
create index IX_ArticleComments_AuthorId on ArticleComments(AuthorId);
3232

33+
create table UserAddresses
34+
( Id int64 primary key autoincrement
35+
, UserId int64 references Users(Id)
36+
, Home Address
37+
);
38+
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1-
{
2-
"backend": "postgres"
3-
}
1+
{
2+
"backend": "postgres",
3+
"usertypes": [ "TypeProviderUser.Postgres.UserTypes" ]
4+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
module TypeProviderUser.SQLite.TestUserPrimitiveByteArray
2+
open NUnit.Framework
3+
open Rezoom.SQL
4+
open TypeProviderUser.UserTypes
5+
6+
type InsertAndSelectFileHashes = SQL<"""
7+
insert into HashedBlobs(Hash) values(@h1);
8+
insert into HashedBlobs(Hash) values(@h2);
9+
select Hash from HashedBlobs order by Id;
10+
""">
11+
12+
[<Test>]
13+
let ``select roundtrips a FileHash user primitive over byte[]`` () =
14+
let h1 = FileHash [| 0x01uy; 0x02uy; 0x03uy; 0x04uy |]
15+
let h2 = FileHash [| 0xFFuy; 0xEEuy; 0xDDuy; 0xCCuy |]
16+
let results = InsertAndSelectFileHashes.Command(h1, h2) |> runOnTestData
17+
Assert.AreEqual(2, results.Count)
18+
Assert.AreEqual(h1, results.[0].Hash)
19+
Assert.AreEqual(h2, results.[1].Hash)
20+
21+
type FindFileHashByParameter = SQL<"""
22+
insert into HashedBlobs(Hash) values(@seed1);
23+
insert into HashedBlobs(Hash) values(@seed2);
24+
select Hash from HashedBlobs where Hash = @needle;
25+
""">
26+
27+
[<Test>]
28+
let ``select with FileHash parameter equality returns the matching row only`` () =
29+
let target = FileHash [| 0xCAuy; 0xFEuy; 0xBAuy; 0xBEuy |]
30+
let other = FileHash [| 0xDEuy; 0xADuy; 0xBEuy; 0xEFuy |]
31+
let results = FindFileHashByParameter.Command(target, other, target) |> runOnTestData
32+
Assert.AreEqual(1, results.Count)
33+
Assert.AreEqual(target, results.[0].Hash)
34+
35+
type FindFileHashByOptionalParameter = SQL<"""
36+
insert into HashedBlobs(Hash) values(@seed1);
37+
insert into HashedBlobs(Hash) values(@seed2);
38+
select Hash from HashedBlobs where Hash = @needle or @needle is null;
39+
""">
40+
41+
[<Test>]
42+
let ``select with optional FileHash parameter filters when Some and returns all when None`` () =
43+
let target = FileHash [| 0x12uy; 0x34uy; 0x56uy; 0x78uy |]
44+
let other = FileHash [| 0x9Auy; 0xBCuy; 0xDEuy; 0xF0uy |]
45+
// Rezoom orders Command args alphabetically by name: needle, seed1, seed2.
46+
let withSome =
47+
FindFileHashByOptionalParameter.Command(Some target, target, other)
48+
|> runOnTestData
49+
Assert.AreEqual(1, withSome.Count)
50+
Assert.AreEqual(target, withSome.[0].Hash)
51+
let withNone =
52+
FindFileHashByOptionalParameter.Command(None, target, other)
53+
|> runOnTestData
54+
Assert.AreEqual(2, withNone.Count)

0 commit comments

Comments
 (0)