diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs
index 56852fa1..2cb6b53b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs
@@ -12,15 +12,16 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
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; }
/// First column of the first row converted to T (default if no rows).
public T? QuerySingle(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> 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;
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
index 3f26fe5d..2a63d32b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs
@@ -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();
+ 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
///
public RouteHelper Route { get; }
+ ///
+ /// Read-only database access for the script (named connections; bound parameters).
+ ///
+ public InboundDatabaseHelper Database { get; }
+
///
/// The cancellation token for script execution.
///
@@ -340,14 +377,17 @@ public class InboundScriptContext
///
/// The input parameters for the script.
/// The route helper for cross-site routing.
+ /// The read-only database helper for the script.
/// The cancellation token.
public InboundScriptContext(
IReadOnlyDictionary parameters,
RouteHelper route,
+ InboundDatabaseHelper database,
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
+ Database = database;
CancellationToken = cancellationToken;
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs
index 5d37c772..ccbec43f 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs
@@ -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(SeededSqliteGateway());
+ using var provider = services.BuildServiceProvider();
+
+ var executor = new InboundScriptExecutor(
+ NullLogger.Instance, provider);
+
+ var method = new ApiMethod(
+ "movein",
+ "return new { v = Database.QuerySingle(\"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 { { "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.Instance, Substitute.For());
+
+ var method = new ApiMethod("no-db", "return 99;") { Id = 1, TimeoutSeconds = 10 };
+
+ var result = await executor.ExecuteAsync(
+ method,
+ new Dictionary(),
+ _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 GetConnectionAsync(string name, CancellationToken ct = default)
+ { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
+ public Task CachedWriteAsync(string c, string s,
+ IReadOnlyDictionary? 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;