-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
329 lines (307 loc) · 17.8 KB
/
Copy pathProgram.cs
File metadata and controls
329 lines (307 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
using System;
using System.Data;
using Dapper;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using Ydb.Sdk.Ado;
using AuthorsDapper = Authors.Dapper;
using BatchDapper = Batch.Dapper;
using BooktestDapper = Booktest.Dapper;
using JetsDapper = Jets.Dapper;
using OndeckDapper = Ondeck.Dapper;
internal static class Program
{
private static readonly string[] AuthorsSchema = ["examples/authors/schema.sql"];
private static readonly string[] BatchSchema = ["examples/batch/schema.sql"];
private static readonly string[] BooktestSchema = ["examples/booktest/schema.sql"];
private static readonly string[] JetsSchema = ["examples/jets/schema.sql"];
private static readonly string[] OndeckSchema =
[
"examples/ondeck/schema/0001_city.sql",
"examples/ondeck/schema/0002_venue.sql",
"examples/ondeck/schema/0003_rename_venue.sql",
"examples/ondeck/schema/0004_add_created_at.sql",
"examples/ondeck/schema/0005_drop_column.sql",
];
public static async Task<int> Main(string[] args)
{
if (args.Length == 1 && args[0] == "contracts")
{
CheckTimestampContracts();
CheckDapperMapping();
return 0;
}
if (args.Length != 1 || args[0] != "dapper")
{
Console.Error.WriteLine("usage: GeneratedProfiles <dapper|contracts>");
return 2;
}
var dsn = Environment.GetEnvironmentVariable("YDB_CONNECTION_STRING");
if (string.IsNullOrWhiteSpace(dsn))
{
Console.Error.WriteLine("YDB_CONNECTION_STRING is required");
return 2;
}
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120));
await using var dataSource = new YdbDataSource(dsn);
await using var connection = await dataSource.OpenConnectionAsync(timeout.Token);
await ExerciseDapperAsync(connection, timeout.Token);
return 0;
}
private static void CheckDapperMapping()
{
var underscoreSetting = DefaultTypeMap.MatchNamesWithUnderscores;
System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(typeof(BatchDapper.Queries).TypeHandle);
if (DefaultTypeMap.MatchNamesWithUnderscores != underscoreSetting)
throw new InvalidOperationException("Generated mapping changed global Dapper settings");
using var table = new DataTable();
table.Columns.Add("book_id", typeof(ulong));
table.Columns.Add("author_id", typeof(ulong));
table.Columns.Add("isbn", typeof(string));
table.Columns.Add("book_type", typeof(string));
table.Columns.Add("title", typeof(string));
table.Columns.Add("year", typeof(int));
table.Columns.Add("available", typeof(DateTime));
table.Columns.Add("tags", typeof(string));
var timestamp = TestTimestamp();
const string json = "{\"large\":9007199254740993}";
table.Rows.Add(ulong.MaxValue, ulong.MaxValue - 1, "isbn", "paper", "title", 2026, timestamp, json);
using var reader = table.CreateDataReader();
var parse = reader.GetRowParser<BatchDapper.BooksByYearRow>();
if (!reader.Read()) throw new InvalidOperationException("Missing mapping fixture");
var row = parse(reader);
if (row.BookID != ulong.MaxValue || row.AuthorID != ulong.MaxValue - 1 || row.BookType != "paper" || row.Available != timestamp || row.Tags != json)
throw new InvalidOperationException("Dapper constructor mapping changed typed values");
using var optional = new DataTable();
optional.Columns.Add("author_id", typeof(ulong));
optional.Columns.Add("name", typeof(string));
optional.Columns.Add("biography", typeof(string));
optional.Rows.Add(1UL, "author", DBNull.Value);
using var optionalReader = optional.CreateDataReader();
var parseOptional = optionalReader.GetRowParser<BatchDapper.GetAuthorRow>();
optionalReader.Read();
if (parseOptional(optionalReader).Biography is not null)
throw new InvalidOperationException("Dapper nullable constructor mapping changed null");
Console.WriteLine("Dapper record mapping contracts passed");
}
private static void CheckTimestampContracts()
{
var utc = new DateTime(2026, 9, 11, 12, 34, 56, DateTimeKind.Utc).AddTicks(123450);
foreach (var type in new[] { typeof(BatchDapper.Queries) })
{
var normalize = type.GetMethod("NormalizeTimestamp",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static,
null, new[] { typeof(DateTime) }, null)!;
foreach (var input in new[] { utc, utc.ToLocalTime(), DateTime.SpecifyKind(utc, DateTimeKind.Unspecified) })
{
var actual = (DateTime)normalize.Invoke(null, new object[] { input })!;
if (actual.Kind != DateTimeKind.Utc || actual.Ticks != utc.Ticks)
throw new InvalidOperationException($"{type}: Timestamp normalization changed the instant");
var wire = Ydb.Sdk.Value.YdbValue.MakeTimestamp(actual).GetTimestamp();
if (wire.Ticks != utc.Ticks)
throw new InvalidOperationException($"{type}: Timestamp wire conversion changed microseconds");
}
var optional = type.GetMethod("NormalizeTimestamp",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static,
null, new[] { typeof(DateTime?) }, null)!;
if (optional.Invoke(null, new object?[] { null }) is not null)
throw new InvalidOperationException("Optional Timestamp lost null");
}
Console.WriteLine("Timestamp contracts passed");
}
private static async Task ExerciseDapperAsync(YdbConnection connection, CancellationToken cancellationToken)
{
await RunExampleAsync(connection, "authors", AuthorsSchema, token => ExerciseAuthorsDapperAsync(connection, token), cancellationToken);
await RunExampleAsync(connection, "batch", BatchSchema, token => ExerciseBatchDapperAsync(connection, token), cancellationToken);
await RunExampleAsync(connection, "booktest", BooktestSchema, token => ExerciseBooktestDapperAsync(connection, token), cancellationToken);
await RunExampleAsync(connection, "jets", JetsSchema, token => ExerciseJetsDapperAsync(connection, token), cancellationToken);
await RunExampleAsync(connection, "ondeck", OndeckSchema, token => ExerciseOndeckDapperAsync(connection, token), cancellationToken);
}
private static async Task RunExampleAsync(YdbConnection connection, string name, IReadOnlyList<string> schemaPaths, Func<CancellationToken, Task> exercise, CancellationToken cancellationToken)
{
var ownedTables = new List<string>();
Exception? primaryFailure = null;
try
{
await ApplySchemaAsync(connection, schemaPaths, ownedTables, cancellationToken);
await exercise(cancellationToken);
Console.WriteLine($"{name}: passed");
}
catch (Exception error)
{
primaryFailure = error;
}
var cleanupFailure = await DropOwnedTablesAsync(connection, ownedTables);
if (primaryFailure is not null)
{
if (cleanupFailure is not null)
Console.Error.WriteLine($"{name}: cleanup also failed: {cleanupFailure.Message}");
ExceptionDispatchInfo.Capture(primaryFailure).Throw();
}
if (cleanupFailure is not null)
ExceptionDispatchInfo.Capture(cleanupFailure).Throw();
}
private static async Task ApplySchemaAsync(YdbConnection connection, IReadOnlyList<string> schemaPaths, List<string> ownedTables, CancellationToken cancellationToken)
{
foreach (var path in schemaPaths)
{
var sql = await File.ReadAllTextAsync(path, cancellationToken);
foreach (var statement in sql.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
await ExecuteAsync(connection, statement + ";", cancellationToken);
TrackSchemaChange(statement, ownedTables);
}
}
}
private static void TrackSchemaChange(string statement, List<string> ownedTables)
{
var words = statement.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (words.Length >= 3 && words[0].Equals("CREATE", StringComparison.OrdinalIgnoreCase) && words[1].Equals("TABLE", StringComparison.OrdinalIgnoreCase))
{
var tableNameIndex = 2;
if (words[2].Equals("IF", StringComparison.OrdinalIgnoreCase))
{
if (words.Length < 6 ||
!words[3].Equals("NOT", StringComparison.OrdinalIgnoreCase) ||
!words[4].Equals("EXISTS", StringComparison.OrdinalIgnoreCase))
return;
tableNameIndex = 5;
}
ownedTables.Add(words[tableNameIndex]);
return;
}
if (words.Length >= 6 && words[0].Equals("ALTER", StringComparison.OrdinalIgnoreCase) && words[1].Equals("TABLE", StringComparison.OrdinalIgnoreCase) && words[3].Equals("RENAME", StringComparison.OrdinalIgnoreCase) && words[4].Equals("TO", StringComparison.OrdinalIgnoreCase))
{
var index = ownedTables.IndexOf(words[2]);
if (index >= 0)
ownedTables[index] = words[5];
}
}
private static async Task<Exception?> DropOwnedTablesAsync(YdbConnection connection, List<string> ownedTables)
{
Exception? firstFailure = null;
for (var index = ownedTables.Count - 1; index >= 0; index--)
{
try
{
await ExecuteAsync(connection, $"DROP TABLE {ownedTables[index]};", CancellationToken.None);
}
catch (Exception error)
{
firstFailure ??= error;
}
}
return firstFailure;
}
private static async Task ExecuteAsync(YdbConnection connection, string sql, CancellationToken cancellationToken)
{
await using var command = new YdbCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task ExerciseAuthorsDapperAsync(YdbConnection connection, CancellationToken cancellationToken)
{
var queries = new AuthorsDapper.Queries(connection);
const ulong id = ulong.MaxValue;
var created = await queries.CreateAuthorAsync(new AuthorsDapper.CreateAuthorParams(id, "Ada", null), cancellationToken);
await queries.UpsertAuthorAsync(new AuthorsDapper.UpsertAuthorParams(id, "Ada Lovelace", "programmer"), cancellationToken);
var fetched = await queries.GetAuthorAsync(id, cancellationToken, commandTimeout: 10);
if (created.ID != id || created.Bio is not null || fetched.Name != "Ada Lovelace" || fetched.Bio != "programmer" || (await queries.ListAuthorsAsync(cancellationToken)).Single().ID != id)
throw new InvalidOperationException("authors Dapper CRUD mapping changed");
await using (var transaction = (YdbTransaction)await connection.BeginTransactionAsync(cancellationToken))
{
await using var otherConnection = new YdbConnection(connection.ConnectionString);
try
{
_ = new AuthorsDapper.Queries(otherConnection, transaction);
throw new InvalidOperationException("foreign transaction was accepted");
}
catch (ArgumentException error) when (error.ParamName == "transaction")
{
}
var transactional = queries.WithTransaction(transaction);
await transactional.UpsertAuthorAsync(new AuthorsDapper.UpsertAuthorParams(id, "transaction", null), cancellationToken);
if ((await transactional.GetAuthorAsync(id, cancellationToken)).Name != "transaction")
throw new InvalidOperationException("Dapper transaction did not read its write");
await transaction.RollbackAsync(cancellationToken);
}
if ((await queries.GetAuthorAsync(id, cancellationToken)).Name != "Ada Lovelace")
throw new InvalidOperationException("Dapper rollback changed committed data");
await queries.DeleteAuthorAsync(id, cancellationToken);
await AssertMissingAsync(() => queries.GetAuthorAsync(id, cancellationToken));
}
private static async Task ExerciseBatchDapperAsync(YdbConnection connection, CancellationToken cancellationToken)
{
var queries = new BatchDapper.Queries(connection);
const ulong id = ulong.MaxValue;
var at = TestTimestamp();
await queries.CreateAuthorAsync(new BatchDapper.CreateAuthorParams(id, "Dapper", "{\"profile\":\"dapper\"}"), cancellationToken);
await queries.CreateAuthorAsync(new BatchDapper.CreateAuthorParams(id - 1, "Dapper null", null), cancellationToken);
var book = await queries.CreateBookAsync(new BatchDapper.CreateBookParams(id, id, "isbn-dapper", "paper", "Dapper", 2026, at, "[\"typed\"]"), cancellationToken);
var rows = await queries.BooksByYearAsync(2026, cancellationToken);
var biography = await queries.GetBiographyAsync(id, cancellationToken);
var nullBiography = await queries.GetBiographyAsync(id - 1, cancellationToken);
if (book.Available != at || book.Tags != "[\"typed\"]" || rows.Single().BookID != id || biography.Biography != "{\"profile\":\"dapper\"}" || nullBiography.Biography is not null)
throw new InvalidOperationException("batch Dapper Json/Timestamp/result mapping changed");
}
private static async Task ExerciseBooktestDapperAsync(YdbConnection connection, CancellationToken cancellationToken)
{
var queries = new BooktestDapper.Queries(connection);
const ulong id = ulong.MaxValue;
var at = TestTimestamp();
await queries.CreateAuthorAsync(new BooktestDapper.CreateAuthorParams(id, "Octavia"), cancellationToken);
var book = await queries.CreateBookAsync(new BooktestDapper.CreateBookParams(id, id - 1, "orphan", "paper", "Parable", 2026, at, "[\"speculative\"]"), cancellationToken);
var joined = (await queries.BooksByTagsAsync("[\"speculative\"]", cancellationToken)).Single();
var hello = await queries.SayHelloAsync("YDB", cancellationToken);
if (book.Available != at || book.Tags != "[\"speculative\"]" || joined.BookID != id || joined.Name is not null || hello.Greeting != "hello YDB")
throw new InvalidOperationException("booktest Dapper JSON/Timestamp/LEFT JOIN mapping changed");
await queries.DeleteBookAsync(id, cancellationToken);
await AssertMissingAsync(() => queries.GetBookAsync(id, cancellationToken));
}
private static async Task ExerciseJetsDapperAsync(YdbConnection connection, CancellationToken cancellationToken)
{
await InsertPilotsAsync(connection, cancellationToken);
var queries = new JetsDapper.Queries(connection);
var pilots = await queries.ListPilotsAsync(cancellationToken);
if ((await queries.CountPilotsAsync(cancellationToken)).PilotCount != 2 || !pilots.Select(row => row.Name).SequenceEqual(["Amelia", "Bessie"]))
throw new InvalidOperationException("jets Dapper aggregate/list mapping changed");
await queries.DeletePilotAsync(1, cancellationToken);
if ((await queries.CountPilotsAsync(cancellationToken)).PilotCount != 1)
throw new InvalidOperationException("jets Dapper delete changed");
}
private static Task InsertPilotsAsync(YdbConnection connection, CancellationToken cancellationToken) =>
ExecuteAsync(connection, "UPSERT INTO pilots (id, name) VALUES (1, \"Amelia\"u), (2, \"Bessie\"u);", cancellationToken);
private static async Task ExerciseOndeckDapperAsync(YdbConnection connection, CancellationToken cancellationToken)
{
var queries = new OndeckDapper.Queries(connection);
const ulong id = ulong.MaxValue;
var at = TestTimestamp();
await queries.CreateCityAsync(new OndeckDapper.CreateCityParams("London", "london"), cancellationToken);
await queries.CreateVenueAsync(new OndeckDapper.CreateVenueParams(id, "roundhouse", "Roundhouse", "london", at, "playlist", "open", "[\"open\"]", null), cancellationToken);
await queries.CreateVenueAsync(new OndeckDapper.CreateVenueParams(id - 1, "forum", "Forum", "london", null, "playlist", "open", null, "[\"rock\"]"), cancellationToken);
var venue = await queries.GetVenueAsync(new OndeckDapper.GetVenueParams("roundhouse", "london"), cancellationToken);
var count = (await queries.VenueCountByCityAsync(cancellationToken)).Single();
var updated = await queries.UpdateVenueNameAsync(new OndeckDapper.UpdateVenueNameParams("The Roundhouse", "roundhouse"), cancellationToken);
if (venue.ID != id || venue.CreatedAt != at || venue.Statuses != "[\"open\"]" || venue.Tags is not null || count.VenueCount != 2 || updated.ID != id)
throw new InvalidOperationException("ondeck Dapper migration/optional/aggregate mapping changed");
await queries.DeleteVenueAsync("roundhouse", cancellationToken);
await AssertMissingAsync(() => queries.GetVenueAsync(new OndeckDapper.GetVenueParams("roundhouse", "london"), cancellationToken));
}
private static DateTime TestTimestamp() =>
new DateTime(2026, 9, 9, 12, 34, 56, DateTimeKind.Utc).AddTicks(1_234_560);
private static async Task AssertMissingAsync<T>(Func<Task<T>> operation)
{
try
{
await operation();
}
catch (InvalidOperationException)
{
return;
}
throw new InvalidOperationException("expected generated :one method to reject a missing row");
}
}