feat(inbound): expose read-only Database helper on InboundScriptContext
This commit is contained in:
@@ -12,15 +12,16 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
/// </summary>
|
||||
public sealed class InboundDatabaseHelper
|
||||
{
|
||||
private readonly IDatabaseGateway _gateway;
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly CancellationToken _ct;
|
||||
|
||||
public InboundDatabaseHelper(IDatabaseGateway gateway, CancellationToken ct)
|
||||
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
|
||||
{ _gateway = gateway; _ct = ct; }
|
||||
|
||||
/// <summary>First column of the first row converted to T (default if no rows).</summary>
|
||||
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
@@ -35,6 +36,7 @@ public sealed class InboundDatabaseHelper
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
|
||||
string connectionName, string sql, object? parameters = null)
|
||||
{
|
||||
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
|
||||
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
@@ -2,8 +2,10 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
@@ -234,8 +236,31 @@ public class InboundScriptExecutor
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, timeoutCts.Token);
|
||||
|
||||
// IpsenMES MoveIn: expose a read-only Database helper to the script.
|
||||
// Resolve the gateway from a fresh DI scope (declared outside the try so it
|
||||
// lives until after the script handler runs and is disposed here) so a scoped
|
||||
// IDatabaseGateway is honoured. GetService (not GetRequiredService) so a method
|
||||
// that never touches Database still runs even when no IDatabaseGateway is
|
||||
// registered; the helper throws on first use if built without a gateway.
|
||||
//
|
||||
// A service provider that cannot produce a scope (e.g. a bare test double with
|
||||
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
|
||||
// a script not using Database still runs, while one that does fails on use.
|
||||
IServiceScope? scope = null;
|
||||
try
|
||||
{
|
||||
scope = _serviceProvider.CreateScope();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// No scope factory available (provider does not support scoping).
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
|
||||
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
|
||||
|
||||
// InboundAPI-016: bind the route helper to the method deadline so a
|
||||
// routed Route.To(...).Call(...) inherits the method-level timeout
|
||||
// without the script having to thread the context token by hand.
|
||||
@@ -246,6 +271,7 @@ public class InboundScriptExecutor
|
||||
var context = new InboundScriptContext(
|
||||
parameters,
|
||||
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
|
||||
dbHelper,
|
||||
cts.Token);
|
||||
|
||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||
@@ -312,6 +338,12 @@ public class InboundScriptExecutor
|
||||
// WP-5: Safe error message, no internal details
|
||||
return new InboundScriptResult(false, null, "Internal script error");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Dispose the per-execution DI scope (if one was created) only after the
|
||||
// script handler has finished running and its result been serialized.
|
||||
scope?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +362,11 @@ public class InboundScriptContext
|
||||
/// </summary>
|
||||
public RouteHelper Route { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Read-only database access for the script (named connections; bound parameters).
|
||||
/// </summary>
|
||||
public InboundDatabaseHelper Database { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The cancellation token for script execution.
|
||||
/// </summary>
|
||||
@@ -340,14 +377,17 @@ public class InboundScriptContext
|
||||
/// </summary>
|
||||
/// <param name="parameters">The input parameters for the script.</param>
|
||||
/// <param name="route">The route helper for cross-site routing.</param>
|
||||
/// <param name="database">The read-only database helper for the script.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public InboundScriptContext(
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
RouteHelper route,
|
||||
InboundDatabaseHelper database,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Parameters = new ScriptParameters(parameters);
|
||||
Route = route;
|
||||
Database = database;
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
@@ -539,6 +543,86 @@ public class InboundScriptExecutorTests
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
// --- IpsenMES MoveIn: the read-only Database helper is exposed on the script
|
||||
// context and resolves IDatabaseGateway from a DI scope per execution. ---
|
||||
|
||||
[Fact]
|
||||
public async Task Script_UsingDatabase_QueriesViaGateway()
|
||||
{
|
||||
// A script that calls Database.QuerySingle runs against an executor whose
|
||||
// ServiceProvider registers an IDatabaseGateway backed by in-memory SQLite.
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IDatabaseGateway>(SeededSqliteGateway());
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
var executor = new InboundScriptExecutor(
|
||||
NullLogger<InboundScriptExecutor>.Instance, provider);
|
||||
|
||||
var method = new ApiMethod(
|
||||
"movein",
|
||||
"return new { v = Database.QuerySingle<string>(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] }) };")
|
||||
{
|
||||
Id = 1,
|
||||
TimeoutSeconds = 10,
|
||||
// null ReturnDefinition → ReturnValueValidator is unconstrained.
|
||||
ReturnDefinition = null,
|
||||
};
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?> { { "sap", "131453" } },
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Contains("Z28061A", result.ResultJson!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_NotUsingDatabase_RunsWithProviderLackingGateway()
|
||||
{
|
||||
// A script that never touches Database must run even when no IDatabaseGateway
|
||||
// is registered — the helper is built via GetService (nullable) and only
|
||||
// throws on first use, which this script never reaches.
|
||||
var executor = new InboundScriptExecutor(
|
||||
NullLogger<InboundScriptExecutor>.Instance, Substitute.For<IServiceProvider>());
|
||||
|
||||
var method = new ApiMethod("no-db", "return 99;") { Id = 1, TimeoutSeconds = 10 };
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
method,
|
||||
new Dictionary<string, object?>(),
|
||||
_route,
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Contains("99", result.ResultJson!);
|
||||
}
|
||||
|
||||
// SQLite-backed IDatabaseGateway fake (mirrors InboundDatabaseHelperTests). The
|
||||
// shared in-memory db is seeded with Machine(Code,SAPID)=('Z28061A','131453').
|
||||
private sealed class SqliteGateway : IDatabaseGateway
|
||||
{
|
||||
private readonly string _cs;
|
||||
public SqliteGateway(string cs) => _cs = cs;
|
||||
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
|
||||
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
|
||||
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
|
||||
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
|
||||
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private static SqliteGateway SeededSqliteGateway()
|
||||
{
|
||||
var keep = new SqliteConnection("DataSource=file:movein-exec?mode=memory&cache=shared");
|
||||
keep.Open(); // keep-alive: shared in-memory db lives until process exit
|
||||
using var cmd = keep.CreateCommand();
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');";
|
||||
cmd.ExecuteNonQuery();
|
||||
return new SqliteGateway("DataSource=file:movein-exec?mode=memory&cache=shared");
|
||||
}
|
||||
|
||||
private sealed class CompileLogCounter
|
||||
{
|
||||
public int CompilationFailures;
|
||||
|
||||
Reference in New Issue
Block a user