Skip to content

Commit 42437da

Browse files
committed
refactor(tests): share engine and corpus-path constants across the report lanes
Folds duplicated engine names, corpus paths and schema-reading helpers in the SQLite, MySQL and D1 report lanes into one definition per language. No behaviour change.
1 parent aa04dbe commit 42437da

38 files changed

Lines changed: 316 additions & 296 deletions

‎server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,10 @@ public async Task Api_contract_report_generated(string scenarioPath)
4040
public async Task Api_contract_report_generated_on_sqlite(string scenarioPath)
4141
{
4242
var scenario = ApiContractScenarioLoader.LoadScenario(scenarioPath);
43-
var file = Path.Combine(Path.GetTempPath(), $"mo-report-api-{Guid.NewGuid():N}.db");
44-
try
45-
{
46-
await using var server = await ReportGeneratedServerFactory.StartSqliteAsync(file);
47-
await server.ApplySeedAsync();
48-
await RunAsync(scenario, server.BaseUrl);
49-
}
50-
finally
51-
{
52-
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
53-
if (File.Exists(file)) File.Delete(file);
54-
}
43+
using var db = new SqliteTempDatabase("mo-report-api-");
44+
await using var server = await ReportGeneratedServerFactory.StartSqliteAsync(db.FilePath);
45+
await server.ApplySeedAsync();
46+
await RunAsync(scenario, server.BaseUrl);
5547
}
5648

5749
// ...and over MySQL 8.4: the schema is report/schema.mysql.sql (the adopter's tables plus

‎server/csharp/MetaObjects.IntegrationTests/Api/ReportFixture.cs‎

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@
1616

1717
using System.Text.Json; // JsonValueKind
1818
using System.Text.Json.Nodes; // JsonNode / JsonObject / JsonArray
19-
using Microsoft.Data.Sqlite;
2019
using MetaObjects.IntegrationTests.Runner;
21-
using MySqlConnector;
22-
using Npgsql;
2320

2421
namespace MetaObjects.IntegrationTests.Api;
2522

@@ -28,32 +25,16 @@ internal static class ReportFixture
2825
/// <summary>The one top-level key of seed.json that is not a base table.</summary>
2926
private const string SeedReportsKey = "reports";
3027

31-
/// <summary>Execute the committed TypeScript-produced schema on a fresh container.</summary>
28+
/// <summary>Execute the committed TypeScript-produced schema for this engine's artifact.</summary>
3229
public static async Task ProvisionSchemaAsync(string connString, ScenarioEngine engine = ScenarioEngine.Postgres)
3330
{
34-
if (engine == ScenarioEngine.Sqlite)
31+
var path = engine switch
3532
{
36-
await using var lite = new SqliteConnection(connString);
37-
await lite.OpenAsync();
38-
await using var liteCmd = lite.CreateCommand();
39-
liteCmd.CommandText = await File.ReadAllTextAsync(ApiContractCorpusPaths.ReportSchemaSqliteSql);
40-
await liteCmd.ExecuteNonQueryAsync();
41-
return;
42-
}
43-
if (engine == ScenarioEngine.MySql)
44-
{
45-
await using var my = new MySqlConnection(connString);
46-
await my.OpenAsync();
47-
await using var myCmd = my.CreateCommand();
48-
myCmd.CommandText = await File.ReadAllTextAsync(ApiContractCorpusPaths.ReportSchemaMysqlSql);
49-
await myCmd.ExecuteNonQueryAsync();
50-
return;
51-
}
52-
await using var c = new NpgsqlConnection(connString);
53-
await c.OpenAsync();
54-
await using var cmd = c.CreateCommand();
55-
cmd.CommandText = await File.ReadAllTextAsync(ApiContractCorpusPaths.ReportSchemaSql);
56-
await cmd.ExecuteNonQueryAsync();
33+
ScenarioEngine.Sqlite => ApiContractCorpusPaths.ReportSchemaSqliteSql,
34+
ScenarioEngine.MySql => ApiContractCorpusPaths.ReportSchemaMysqlSql,
35+
_ => ApiContractCorpusPaths.ReportSchemaSql,
36+
};
37+
await engine.ExecuteScriptAsync(connString, await File.ReadAllTextAsync(path));
5738
}
5839

5940
/// <summary>
@@ -70,13 +51,7 @@ public static async Task ApplySeedAsync(string connString, string seedPath, Scen
7051
if (tables.Count == 0)
7152
throw new InvalidOperationException($"{seedPath}: no base-table rows to seed");
7253

73-
await using System.Data.Common.DbConnection c = engine switch
74-
{
75-
ScenarioEngine.Sqlite => new SqliteConnection(connString),
76-
ScenarioEngine.MySql => new MySqlConnection(connString),
77-
_ => new NpgsqlConnection(connString),
78-
};
79-
await c.OpenAsync();
54+
await using var c = await engine.OpenConnectionAsync(connString);
8055

8156
foreach (var (table, rowsNode) in tables)
8257
{
@@ -86,7 +61,7 @@ public static async Task ApplySeedAsync(string connString, string seedPath, Scen
8661
{
8762
if (rowNode is not JsonObject row)
8863
throw new InvalidOperationException($"{seedPath}: '{table}' has a row that is not an object");
89-
var quote = engine == ScenarioEngine.MySql ? "`" : "\"";
64+
var quote = engine.Quote();
9065
var colList = string.Join(", ", row.Select(kv => quote + kv.Key + quote));
9166
var valueList = string.Join(", ", row.Select(kv => SqlLiteral(seedPath, table, kv.Key, kv.Value)));
9267
await using var ins = c.CreateCommand();

‎server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,9 @@ private static void RegisterGeneratedDbContext(
228228
&& m.GetParameters().Length == 4)
229229
.MakeGenericMethod(dbContextType);
230230

231-
Action<DbContextOptionsBuilder> configure = engine switch
232-
{
233-
ScenarioEngine.Sqlite => opts => opts.UseSqlite(connString),
234-
ScenarioEngine.MySql => opts => opts.UseMySql(connString, new MySqlServerVersion(new Version(8, 4, 0))),
235-
_ => opts => opts.UseNpgsql(connString),
236-
};
231+
// Provider choice lives on the engine (ScenarioEngineExtensions.UseEngine) — the one
232+
// MySqlServerVersion pin too, so the provider and the mysql:8.4 image cannot drift apart.
233+
Action<DbContextOptionsBuilder> configure = opts => opts.UseEngine(engine, connString);
237234
addDbContext.Invoke(null, new object?[]
238235
{
239236
services, configure, ServiceLifetime.Scoped, ServiceLifetime.Scoped,

‎server/csharp/MetaObjects.IntegrationTests/QueryScenarioMySqlTests.cs‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,5 @@ public async Task Report_scenario_on_mysql(string scenarioPath)
2424
await QueryScenarioRunner.RunMySqlAsync(scenario, mysql.ConnectionString);
2525
}
2626

27-
public static IEnumerable<object[]> Scenarios() =>
28-
Directory.EnumerateFiles(CorpusPaths.QueriesDir, "report-*.yaml", SearchOption.TopDirectoryOnly)
29-
.OrderBy(p => p, StringComparer.Ordinal)
30-
.Select(p => new object[] { p });
27+
public static IEnumerable<object[]> Scenarios() => CorpusPaths.ReportQueryScenarios();
3128
}

‎server/csharp/MetaObjects.IntegrationTests/QueryScenarioSqliteTests.cs‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,9 @@ public sealed class QueryScenarioSqliteTests
1919
public async Task Report_scenario_on_sqlite(string scenarioPath)
2020
{
2121
var scenario = ScenarioLoader.LoadQuery(scenarioPath);
22-
var file = Path.Combine(Path.GetTempPath(), $"mo-report-{Guid.NewGuid():N}.db");
23-
try
24-
{
25-
await QueryScenarioRunner.RunSqliteAsync(scenario, file);
26-
}
27-
finally
28-
{
29-
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
30-
if (File.Exists(file)) File.Delete(file);
31-
}
22+
using var db = new SqliteTempDatabase("mo-report-");
23+
await QueryScenarioRunner.RunSqliteAsync(scenario, db.FilePath);
3224
}
3325

34-
public static IEnumerable<object[]> Scenarios() =>
35-
Directory.EnumerateFiles(CorpusPaths.QueriesDir, "report-*.yaml", SearchOption.TopDirectoryOnly)
36-
.OrderBy(p => p, StringComparer.Ordinal)
37-
.Select(p => new object[] { p });
26+
public static IEnumerable<object[]> Scenarios() => CorpusPaths.ReportQueryScenarios();
3827
}

‎server/csharp/MetaObjects.IntegrationTests/Runner/CorpusPaths.cs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ internal static class CorpusPaths
1515
public static readonly string CanonicalDir = Path.Combine(Corpus, "canonical");
1616
public static readonly string QueriesDir = Path.Combine(Corpus, "queries");
1717

18+
/// <summary>
19+
/// The <c>report-*</c> query scenarios as xUnit member data — the only corpus subset whose
20+
/// tables exist in the SQLite and MySQL schema artifacts. One home for the name predicate,
21+
/// so a dialect-gated scenario cannot be silently skipped by one lane's enumeration.
22+
/// </summary>
23+
public static IEnumerable<object[]> ReportQueryScenarios() =>
24+
Directory.EnumerateFiles(QueriesDir, "report-*.yaml", SearchOption.TopDirectoryOnly)
25+
.OrderBy(p => p, StringComparer.Ordinal)
26+
.Select(p => new object[] { p });
27+
1828
/// <summary>
1929
/// The committed canonical Postgres schema artifact. TypeScript is the single
2030
/// producer of this DDL (base tables + projection views, literal column naming);

‎server/csharp/MetaObjects.IntegrationTests/Runner/QueryScenarioRunner.cs‎

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@
2121
using MetaObjects.Loader;
2222
using MetaObjects.Meta;
2323
using Microsoft.EntityFrameworkCore;
24-
using Microsoft.Data.Sqlite;
25-
using MySqlConnector;
2624
using Npgsql;
2725
using Xunit.Sdk;
2826
using YamlDotNet.Core;
@@ -54,7 +52,7 @@ private static async Task RunAsync(QueryScenario scenario, ScenarioEngine engine
5452
{
5553
// Provision the schema from the committed canonical DDL — the single
5654
// TS-produced artifact every port executes. (No per-scenario synthesis.)
57-
await ExecuteAsync(engine, connectionString, ReadCanonicalSchemaSql(engine));
55+
await engine.ExecuteScriptAsync(connectionString, ReadCanonicalSchemaSql(engine));
5856

5957
// Optional seed data (a per-engine `seed-data-engine` entry wins over `seed-data`).
6058
var seed = scenario.SeedFor(engine.Name());
@@ -64,17 +62,13 @@ private static async Task RunAsync(QueryScenario scenario, ScenarioEngine engine
6462
// identifier quote differs, so a Postgres-spelled seed is translated.
6563
if (engine == ScenarioEngine.MySql && !scenario.HasSeedFor(engine.Name()))
6664
seed = MySqlSeed.FromPostgres(seed);
67-
await ExecuteAsync(engine, connectionString, seed);
65+
await engine.ExecuteScriptAsync(connectionString, seed);
6866
}
6967

70-
// Open a DbContext + run each query.
68+
// Open a DbContext + run each query. (Provider choice: ScenarioEngineExtensions.UseEngine.)
7169
var builder = new DbContextOptionsBuilder<AppDbContext>();
72-
var options = (engine switch
73-
{
74-
ScenarioEngine.Sqlite => builder.UseSqlite(connectionString),
75-
ScenarioEngine.MySql => builder.UseMySql(connectionString, new MySqlServerVersion(new System.Version(8, 4, 0))),
76-
_ => builder.UseNpgsql(connectionString),
77-
}).Options;
70+
builder.UseEngine(engine, connectionString);
71+
var options = builder.Options;
7872
// Postgres runs the whole generated context; the other engines read the report views
7973
// through the view-only subset (see ReportViewsDbContext).
8074
await using AppDbContext db = engine == ScenarioEngine.Postgres
@@ -179,31 +173,6 @@ private static string ReadCanonicalSchemaSql(ScenarioEngine engine)
179173
return File.ReadAllText(path);
180174
}
181175

182-
private static async Task ExecuteAsync(ScenarioEngine engine, string connString, string sql)
183-
{
184-
if (engine == ScenarioEngine.Sqlite)
185-
{
186-
await using var lite = new SqliteConnection(connString);
187-
await lite.OpenAsync();
188-
await using var liteCmd = lite.CreateCommand();
189-
liteCmd.CommandText = sql;
190-
await liteCmd.ExecuteNonQueryAsync();
191-
return;
192-
}
193-
if (engine == ScenarioEngine.MySql)
194-
{
195-
await using var my = new MySqlConnection(connString);
196-
await my.OpenAsync();
197-
await using var myCmd = new MySqlCommand(sql, my);
198-
await myCmd.ExecuteNonQueryAsync();
199-
return;
200-
}
201-
await using var conn = new NpgsqlConnection(connString);
202-
await conn.OpenAsync();
203-
await using var cmd = new NpgsqlCommand(sql, conn);
204-
await cmd.ExecuteNonQueryAsync();
205-
}
206-
207176
// -----------------------------------------------------------------------
208177
// Result assertion (every comparison is JSON-canonical byte equality)
209178
// -----------------------------------------------------------------------

‎server/csharp/MetaObjects.IntegrationTests/Runner/ScenarioEngine.cs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// ScenarioEngine — the database engine a query scenario runs against. Postgres is the
22
// reference; SQLite and MySQL are the other engines the FR-044 report scenarios run on in C#.
33

4+
using System.Data.Common;
45
using System.Text;
56
using System.Text.RegularExpressions;
7+
using Microsoft.Data.Sqlite;
8+
using Microsoft.EntityFrameworkCore;
9+
using MySqlConnector;
10+
using Npgsql;
611

712
namespace MetaObjects.IntegrationTests.Runner;
813

@@ -13,6 +18,12 @@ public enum ScenarioEngine
1318
MySql,
1419
}
1520

21+
/// <summary>
22+
/// The engine-keyed facts of the corpus lanes live HERE, not in per-caller switches: one
23+
/// provider choice, one connection factory, one script executor, one identifier quote. A
24+
/// fourth engine means one more arm in each of these switches (and the Postgres-default arm
25+
/// is a loud TODO there), not a hunt through the fixture, runner and server factory.
26+
/// </summary>
1627
public static class ScenarioEngineExtensions
1728
{
1829
/// <summary>The engine's name as the corpus spells it (the key of a <c>seed-data-engine</c> entry).</summary>
@@ -23,6 +34,44 @@ public static class ScenarioEngineExtensions
2334
ScenarioEngine.MySql => "mysql",
2435
_ => throw new ArgumentOutOfRangeException(nameof(engine), engine, null),
2536
};
37+
38+
/// <summary>The identifier quote seeds and inserts use: MySQL backticks, everything else the corpus' double quotes.</summary>
39+
public static string Quote(this ScenarioEngine engine) =>
40+
engine == ScenarioEngine.MySql ? "`" : "\"";
41+
42+
/// <summary>The server version Pomelo generates against. Keep in step with the mysql:8.4 image MySqlDatabase starts.</summary>
43+
public static readonly Version MySqlProviderVersion = new(8, 4, 0);
44+
45+
/// <summary>Point this engine's EF Core provider at <paramref name="connString"/> (mutates and returns the builder).</summary>
46+
public static DbContextOptionsBuilder UseEngine(this DbContextOptionsBuilder builder, ScenarioEngine engine, string connString) =>
47+
engine switch
48+
{
49+
ScenarioEngine.Sqlite => builder.UseSqlite(connString),
50+
ScenarioEngine.MySql => builder.UseMySql(connString, new MySqlServerVersion(MySqlProviderVersion)),
51+
_ => builder.UseNpgsql(connString),
52+
};
53+
54+
/// <summary>Open this engine's ADO.NET connection. Callers treat it as the provider-neutral <see cref="DbConnection"/>.</summary>
55+
public static async Task<DbConnection> OpenConnectionAsync(this ScenarioEngine engine, string connString)
56+
{
57+
DbConnection conn = engine switch
58+
{
59+
ScenarioEngine.Sqlite => new SqliteConnection(connString),
60+
ScenarioEngine.MySql => new MySqlConnection(connString),
61+
_ => new NpgsqlConnection(connString),
62+
};
63+
await conn.OpenAsync();
64+
return conn;
65+
}
66+
67+
/// <summary>Execute one whole SQL script (schema artifact or seed) on a fresh connection of this engine.</summary>
68+
public static async Task ExecuteScriptAsync(this ScenarioEngine engine, string connString, string sql)
69+
{
70+
await using var conn = await engine.OpenConnectionAsync(connString);
71+
await using var cmd = conn.CreateCommand();
72+
cmd.CommandText = sql;
73+
await cmd.ExecuteNonQueryAsync();
74+
}
2675
}
2776

2877
public static partial class MySqlSeed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SqliteTempDatabase — a throwaway SQLite file for one scenario, owned by the scenario.
2+
//
3+
// The teardown order IS the contract: Microsoft.Data.Sqlite pools connections, so the pools
4+
// must be cleared BEFORE the file is deleted — otherwise a pooled connection holds the file
5+
// and the next "fresh" scenario reads the previous scenario's rows. That step is easy to
6+
// forget in a copy-paste, so it lives once.
7+
8+
using Microsoft.Data.Sqlite;
9+
10+
namespace MetaObjects.IntegrationTests.Runner;
11+
12+
internal sealed class SqliteTempDatabase : IDisposable
13+
{
14+
/// <summary>Full path of the (not yet created) database file; the driver creates it on first use.</summary>
15+
public string FilePath { get; }
16+
17+
public SqliteTempDatabase(string prefix) =>
18+
FilePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{prefix}{Guid.NewGuid():N}.db");
19+
20+
public void Dispose()
21+
{
22+
SqliteConnection.ClearAllPools();
23+
if (System.IO.File.Exists(FilePath)) System.IO.File.Delete(FilePath);
24+
}
25+
}

‎server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,9 @@ object QueryScenarioRunner {
9292
* spelled for the engine. The Postgres `expect` blocks are asserted unchanged.
9393
*/
9494
fun run(scenario: QueryScenario, baseUrl: String, username: String, password: String, engine: ScenarioEngine) {
95-
// A MySQL DATETIME has no zone and the corpus stores UTC wall clocks: read and write them
96-
// as UTC whatever the JVM zone is, so a report's instant column reads back as the same instant.
97-
val jdbcUrl = if (engine == ScenarioEngine.MYSQL) {
98-
baseUrl + (if ('?' in baseUrl) "&" else "?") + "connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true"
99-
} else if (engine == ScenarioEngine.SQLITE) {
100-
// SQLite keeps an instant as the TEXT the view built (`...T03:00:00.000Z`); the driver
101-
// parses a timestamp with this format, which reads that spelling back as the UTC instant.
102-
baseUrl + (if ('?' in baseUrl) "&" else "?") + "date_string_format=yyyy-MM-dd'T'HH:mm:ss.SSSX"
103-
} else baseUrl
95+
// The engine appends its own session parameters (timezone / date-format policy) — see
96+
// ScenarioEngine.jdbcUrl.
97+
val jdbcUrl = engine.jdbcUrl(baseUrl)
10498
val db = Database.connect(jdbcUrl, user = username, password = password)
10599

106100
val corpus = ScenarioLoader.findCorpusRoot()

0 commit comments

Comments
 (0)