From fa77b6cb963f7d1025eee86330b8d9b64182da91 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Mon, 27 Apr 2026 22:50:58 -0700 Subject: [PATCH 01/39] Add trusted C# scalar functions --- README.md | 1 + docs/configuration.md | 2 + docs/sql-reference.md | 5 + docs/trusted-csharp-functions/README.md | 417 ++++++++++++++++++ .../CSharpDB.Admin.Forms.csproj | 1 + .../Evaluation/FormulaEvaluator.cs | 77 +++- .../CSharpDB.Admin.Reports.csproj | 1 + .../Services/DefaultReportPreviewService.cs | 62 ++- .../Services/ReportFormulaEvaluator.cs | 249 ++++++++++- .../Pipelines/CSharpDbPipelineComponents.cs | 6 +- .../Pipelines/CSharpDbPipelineRunner.cs | 5 +- src/CSharpDB.Engine/Database.cs | 30 +- src/CSharpDB.Engine/DatabaseOptions.cs | 7 + .../DatabaseOptionsExtensions.cs | 21 + src/CSharpDB.Execution/ExpressionCompiler.cs | 128 ++++-- src/CSharpDB.Execution/ExpressionEvaluator.cs | 80 ++-- src/CSharpDB.Execution/Operators.cs | 118 +++-- src/CSharpDB.Execution/QueryPlanner.cs | 61 +-- .../ScalarFunctionEvaluator.cs | 91 +++- .../DefaultPipelineComponentFactory.cs | 14 +- .../Runtime/BuiltIns/TransformSupport.cs | 180 +++++++- .../Runtime/BuiltIns/Transforms.cs | 13 +- .../Validation/PipelinePackageValidator.cs | 123 ++++++ src/CSharpDB.Primitives/DbFunctions.cs | 180 ++++++++ .../Evaluation/FormulaEvaluatorTests.cs | 20 + .../ReportFormulaEvaluatorFunctionTests.cs | 46 ++ .../PipelinePackageValidatorTests.cs | 33 ++ .../TrustedScalarFunctionPipelineTests.cs | 109 +++++ .../ClientDirectDatabaseOptionsTests.cs | 48 ++ .../TrustedScalarFunctionTests.cs | 142 ++++++ 30 files changed, 2066 insertions(+), 204 deletions(-) create mode 100644 docs/trusted-csharp-functions/README.md create mode 100644 src/CSharpDB.Primitives/DbFunctions.cs create mode 100644 tests/CSharpDB.Admin.Reports.Tests/Services/ReportFormulaEvaluatorFunctionTests.cs create mode 100644 tests/CSharpDB.Pipelines.Tests/TrustedScalarFunctionPipelineTests.cs create mode 100644 tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs diff --git a/README.md b/README.md index c92e7732..8ca510b6 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ The native library exports 20 C functions. See the [Native Library Reference](ht | [Architecture Guide](https://csharpdb.com/architecture.html) | Engine design deep dive | | [Tools & Ecosystem](https://csharpdb.com/docs/ecosystem.html) | APIs, hosts, designers, and integrations | | [EF Core Provider](https://csharpdb.com/docs/entity-framework-core.html) | Embedded EF Core 10 provider guide | +| [Trusted C# Scalar Functions](docs/trusted-csharp-functions/README.md) | Register in-process C# functions for SQL, forms, reports, and pipelines | | [Admin UI Guide](https://csharpdb.com/docs/admin-ui.html) | Querying, schema, pipelines, forms, reports, and storage | | [CSharpDB.Client](src/CSharpDB.Client/README.md) | Unified client API and transports | | [Pipelines](https://csharpdb.com/docs/pipelines.html) | ETL package model and visual designer | diff --git a/docs/configuration.md b/docs/configuration.md index c0c40866..64f77182 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,7 @@ await using var db = await Database.OpenHybridAsync("mydata.db", ``` DatabaseOptions ├── ImplicitInsertExecutionMode +├── Functions ├── StorageEngineOptions │ ├── DurabilityMode │ ├── DurableGroupCommit @@ -86,6 +87,7 @@ Top-level database composition and execution-shape configuration. | Option | Type | Default | Description | |--------|------|---------|-------------| | `ImplicitInsertExecutionMode` | `ImplicitInsertExecutionMode` | `Serialized` | Controls whether shared auto-commit `INSERT` statements stay behind the legacy database write gate or run as isolated `WriteTransaction` commits. This does not disable the explicit multi-writer `WriteTransaction` APIs. | +| `Functions` | `DbFunctionRegistry` | `DbFunctionRegistry.Empty` | Trusted in-process scalar functions available to SQL and embedded expression surfaces. See [Trusted C# Scalar Functions](trusted-csharp-functions/README.md). | | `StorageEngineOptions` | `StorageEngineOptions` | default instance | Storage engine durability, pager, WAL, and checkpoint settings | | `StorageEngineFactory` | `IStorageEngineFactory` | `DefaultStorageEngineFactory` | Factory used to compose the backing storage engine | diff --git a/docs/sql-reference.md b/docs/sql-reference.md index a18c8b28..e2bb355c 100644 --- a/docs/sql-reference.md +++ b/docs/sql-reference.md @@ -313,6 +313,11 @@ SELECT COUNT(DISTINCT status), AVG(age) FROM users; |----------|-----------|---------|-------------| | `TEXT(expr)` | 1 | TEXT | Converts any value to its text representation | +Host applications can also register trusted in-process C# scalar functions and call +them from SQL expression positions such as `SELECT`, `WHERE`, `ORDER BY`, +`INSERT`, `UPDATE`, trigger bodies, and SQL procedure bodies. See +[Trusted C# Scalar Functions](trusted-csharp-functions/README.md). + --- ## Parameters diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md new file mode 100644 index 00000000..45c8102f --- /dev/null +++ b/docs/trusted-csharp-functions/README.md @@ -0,0 +1,417 @@ +# Trusted C# Scalar Functions + +CSharpDB can call host-registered C# scalar functions from SQL and the embedded expression surfaces that sit on top of the engine. This is the CSharpDB equivalent of an Access-style application function integration: the application owns the C# code, registers it while opening or hosting the database, and users call the function by name in database expressions. + +This feature is intentionally trusted and in-process. It does not store C# source code in the database, sandbox user code, load plugin assemblies from database files, or serialize delegates over HTTP or gRPC. + +--- + +## What You Can Register + +V1 supports synchronous scalar functions: + +```csharp +public delegate DbValue DbScalarFunctionDelegate( + DbScalarFunctionContext context, + ReadOnlySpan arguments); +``` + +A scalar function receives database values and returns one database value. Supported value types are: + +| CSharpDB type | Read with | Return with | +| --- | --- | --- | +| `DbType.Integer` | `value.AsInteger` | `DbValue.FromInteger(...)` | +| `DbType.Real` | `value.AsReal` | `DbValue.FromReal(...)` | +| `DbType.Text` | `value.AsText` | `DbValue.FromText(...)` | +| `DbType.Blob` | `value.AsBlob` | `DbValue.FromBlob(...)` | +| `DbType.Null` | `value.IsNull` | `DbValue.Null` | + +Functions are registered with: + +```csharp +using CSharpDB.Engine; +using CSharpDB.Primitives; + +var options = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromText(args[0].AsText.ToLowerInvariant().Replace(' ', '-'))); + }); +``` + +Open the database with those options: + +```csharp +await using var db = await Database.OpenAsync("app.db", options); +``` + +For tests or transient data: + +```csharp +await using var db = await Database.OpenInMemoryAsync(options); +``` + +--- + +## Complete Example + +```csharp +using CSharpDB.Engine; +using CSharpDB.Primitives; + +static string Slugify(string text) +{ + return text.Trim().ToLowerInvariant().Replace(' ', '-'); +} + +var options = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); + + functions.AddScalar( + "IsEven", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Integer, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromInteger(args[0].AsInteger % 2 == 0 ? 1 : 0)); + }); + +await using var db = await Database.OpenAsync("app.db", options); + +await db.ExecuteAsync(""" + CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT + ); + """); + +await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello World', Slugify('Hello World'))"); +await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Second Post', Slugify('Second Post'))"); + +await using var result = await db.ExecuteAsync(""" + SELECT id, Slugify(title) + FROM articles + WHERE IsEven(id) = 1 + ORDER BY Slugify(title); + """); +``` + +--- + +## Registration Rules + +Function names are SQL identifiers: + +- They must start with a letter or `_`. +- Remaining characters must be letters, digits, or `_`. +- Lookup is case-insensitive, so `Slugify`, `slugify`, and `SLUGIFY` refer to the same function. +- A user function name can only be registered once. V1 does not support overloads by arity. +- Reserved built-ins cannot be overridden. Current reserved names are `TEXT`, `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`. +- `arity` must match the number of arguments used by the expression. + +Registration failures throw immediately so host applications fail at startup instead of later during a query. + +`ConfigureFunctions` sets the function registry for the returned `DatabaseOptions`. If you chain multiple option helpers, keep all function registrations in one `ConfigureFunctions` call or assign a single `DbFunctionRegistry` to `DatabaseOptions.Functions`. + +--- + +## Function Options + +Each function can include `DbScalarFunctionOptions`: + +```csharp +new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true) +``` + +| Option | Meaning | +| --- | --- | +| `ReturnType` | Optional metadata describing the expected return type. | +| `IsDeterministic` | Marks the function as returning the same output for the same inputs. V1 exposes the metadata but does not use it for constant folding or index planning. | +| `NullPropagating` | If any argument is `NULL`, CSharpDB returns `NULL` without invoking the delegate. | + +Without `NullPropagating`, `DbValue.Null` is passed to the delegate and the function decides what to do. + +```csharp +functions.AddScalar( + "CoalesceText", + arity: 2, + options: new DbScalarFunctionOptions(DbType.Text), + invoke: static (_, args) => + args[0].IsNull ? args[1] : args[0]); +``` + +--- + +## SQL Usage + +Registered scalar functions can be used in non-aggregate SQL expression positions: + +```sql +SELECT Slugify(title) FROM articles; +SELECT * FROM articles WHERE IsEven(id) = 1; +SELECT * FROM articles ORDER BY Slugify(title); +INSERT INTO articles VALUES (3, 'New Title', Slugify('New Title')); +UPDATE articles SET slug = Slugify(title) WHERE slug IS NULL; +``` + +They also work in trigger bodies and SQL procedure bodies because those paths execute through the same SQL expression evaluator: + +```sql +CREATE TABLE article_audit (article_id INTEGER, slug TEXT); + +CREATE TRIGGER articles_ai AFTER INSERT ON articles +BEGIN + INSERT INTO article_audit VALUES (NEW.id, Slugify(NEW.title)); +END; +``` + +Custom functions stay on the residual expression path in V1: + +- No index pushdown is inferred from a custom function. +- No generated-column or expression-index behavior is added. +- No constant folding or cost assumptions are made from custom function metadata. + +That keeps existing query and storage paths unchanged unless a query actually calls a registered function. + +--- + +## Direct Client Usage + +Direct clients pass functions through `DirectDatabaseOptions`: + +```csharp +using CSharpDB.Client; +using CSharpDB.Engine; +using CSharpDB.Primitives; + +await using var client = CSharpDbClient.Create(new CSharpDbClientOptions +{ + DataSource = "app.db", + DirectDatabaseOptions = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "AddOne", + 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1)); + }), +}); + +await client.ExecuteSqlAsync("CREATE TABLE numbers (value INTEGER);"); +await client.ExecuteSqlAsync("INSERT INTO numbers VALUES (41);"); + +var result = await client.ExecuteSqlAsync("SELECT AddOne(value) FROM numbers;"); +``` + +`DirectDatabaseOptions` is only valid for direct transport. It is rejected for HTTP and gRPC clients because delegates cannot be serialized to another process. + +--- + +## Remote Host Usage + +HTTP and gRPC clients cannot send C# delegates. Remote SQL can call a custom function only when that function is registered inside the host process that owns the database. + +The practical rule is: + +- Embedded or direct client: register functions in `DatabaseOptions` or `DirectDatabaseOptions`. +- Remote client: register functions where the daemon, API host, or application server opens the database. +- Pipeline packages, report definitions, form definitions, procedures, and SQL text store function names and expressions only. They do not store C# function bodies. + +--- + +## Admin Forms + +Admin Forms computed formulas can call registered scalar functions when the formula evaluator receives a `DbFunctionRegistry`. + +```csharp +using CSharpDB.Admin.Forms.Evaluation; +using CSharpDB.Primitives; + +var functions = DbFunctionRegistry.Create(builder => +{ + builder.AddScalar( + "Tax", + 1, + new DbScalarFunctionOptions(DbType.Real, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromReal(args[0].AsReal * 0.0825)); +}); + +double? tax = FormulaEvaluator.Evaluate( + "=Tax(Subtotal)", + fieldResolver: name => name == "Subtotal" ? 100.00 : null, + functions: functions); +``` + +Forms formulas are numeric formulas. A custom function used from `FormulaEvaluator.Evaluate` should return `INTEGER` or `REAL`; other return types evaluate to `null` in that surface. Existing aggregate formulas such as `=SUM(OrderItems.LineTotal)` remain built-in form behavior and are not replaced by custom scalar functions. + +--- + +## Admin Reports + +Admin Reports preview rendering accepts the same registry through `DefaultReportPreviewService`: + +```csharp +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; + +var previewService = new DefaultReportPreviewService( + dbClient, + sourceProvider, + functions); +``` + +Numeric calculated expressions can call numeric-returning functions: + +```text +=Tax([Subtotal]) +``` + +Calculated text can use a scalar function as the whole expression, including text-returning functions: + +```text +=FormatInvoiceLabel([InvoiceNumber], [CustomerName]) +``` + +Report aggregate formulas such as `=SUM([Subtotal])` remain built-in report behavior. + +--- + +## Pipelines + +Pipelines can call registered scalar functions in filter expressions and derived-column expressions when the runner or component factory is constructed with a registry. + +```csharp +using CSharpDB.Client.Pipelines; +using CSharpDB.Pipelines.Models; +using CSharpDB.Primitives; + +var functions = DbFunctionRegistry.Create(builder => +{ + builder.AddScalar( + "NormalizeStatus", + 1, + new DbScalarFunctionOptions(DbType.Text, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromText(args[0].AsText.Trim().ToLowerInvariant())); +}); + +var runner = new CSharpDbPipelineRunner(client, functions); + +var package = new PipelinePackageDefinition +{ + Name = "active-customers", + Version = "1.0.0", + Source = new PipelineSourceDefinition + { + Kind = PipelineSourceKind.CsvFile, + Path = "customers.csv", + HasHeaderRow = true, + }, + Transforms = + [ + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Filter, + FilterExpression = "NormalizeStatus(status) == 'active'", + }, + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn + { + Name = "status_key", + Expression = "NormalizeStatus(status)", + }, + ], + }, + ], + Destination = new PipelineDestinationDefinition + { + Kind = PipelineDestinationKind.JsonFile, + Path = "active-customers.json", + }, +}; + +await runner.RunPackageAsync(package); +``` + +Pipeline package JSON stores only expressions such as `NormalizeStatus(status)`. The C# delegate must be registered by the process that runs the package. + +--- + +## Error Handling + +Missing SQL functions fail with the existing unknown scalar function error. Function exceptions are wrapped with the function name and the surrounding statement follows normal rollback behavior. + +```csharp +functions.AddScalar( + "RequirePositive", + 1, + new DbScalarFunctionOptions(DbType.Integer, NullPropagating: true), + static (context, args) => + { + long value = args[0].AsInteger; + if (value <= 0) + throw new ArgumentOutOfRangeException(context.FunctionName, "Value must be positive."); + + return DbValue.FromInteger(value); + }); +``` + +For SQL write statements, a failing function aborts the statement. If the statement is inside a transaction, normal transaction rollback rules apply. + +Admin Forms formulas intentionally return `null` for invalid formulas, unsupported function return types, missing functions, division by zero, or exceptions. Pipeline functions throw runtime errors unless the pipeline error mode handles the affected row. + +--- + +## Performance Guidance + +Custom functions run only when an expression calls them. Queries and writes that do not use custom functions stay on the existing paths. + +For low overhead: + +- Prefer `NullPropagating = true` when a function naturally returns null for null input. +- Avoid database calls, blocking I/O, sleeps, and long network calls inside delegates. +- Keep delegates thread-safe. A function may be called by concurrent queries in the same host process. +- Capture immutable services or thread-safe services in closures when application integration is needed. +- Use `IsDeterministic = true` for accurate metadata, but do not rely on V1 to optimize from it. + +--- + +## Current Limitations + +V1 does not support: + +- Aggregate UDFs. +- Table-valued UDFs. +- Stored C# source code or database-owned compiled modules. +- Sandboxed execution. +- Async delegates. +- Passing a database handle into the function context. +- Sending delegates over HTTP, gRPC, or pipeline package files. +- Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. diff --git a/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj b/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj index f7d414f9..6b923cd2 100644 --- a/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj +++ b/src/CSharpDB.Admin.Forms/CSharpDB.Admin.Forms.csproj @@ -10,6 +10,7 @@ + diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs index 46df04c1..4eb51c02 100644 --- a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Forms.Evaluation; /// @@ -18,6 +20,12 @@ public static class FormulaEvaluator /// Returns null if any referenced field is null, formula is invalid, or division by zero. /// public static double? Evaluate(string? formula, Func fieldResolver) + => Evaluate(formula, fieldResolver, DbFunctionRegistry.Empty); + + public static double? Evaluate( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions) { if (string.IsNullOrWhiteSpace(formula)) return null; @@ -28,7 +36,7 @@ public static class FormulaEvaluator try { - var parser = new Parser(expr, fieldResolver); + var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty); var result = parser.ParseExpression(); // Ensure we consumed all input if (parser.Position < parser.Input.Length) @@ -115,12 +123,14 @@ private ref struct Parser public ReadOnlySpan Input; public int Position; private readonly Func _fieldResolver; + private readonly DbFunctionRegistry _functions; - public Parser(string input, Func fieldResolver) + public Parser(string input, Func fieldResolver, DbFunctionRegistry functions) { Input = input.AsSpan(); Position = 0; _fieldResolver = fieldResolver; + _functions = functions; } public double? ParseExpression() @@ -234,12 +244,75 @@ public Parser(string input, Func fieldResolver) while (Position < Input.Length && (char.IsLetterOrDigit(Input[Position]) || Input[Position] == '_')) Position++; var fieldName = Input[start..Position].ToString(); + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == '(') + return ParseFunctionCall(fieldName); + return _fieldResolver(fieldName); } return null; // Unexpected character } + private double? ParseFunctionCall(string functionName) + { + Position++; + var arguments = new List(); + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); + } + + while (Position < Input.Length) + { + double? argument = ParseExpression(); + arguments.Add(argument.HasValue ? DbValue.FromReal(argument.Value) : DbValue.Null); + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ',') + { + Position++; + continue; + } + + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); + } + + return null; + } + + return null; + } + + private double? InvokeFunction(string functionName, List arguments) + { + if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) + return null; + + if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + return null; + + try + { + DbValue value = definition.Invoke(arguments.ToArray()); + return value.Type switch + { + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + _ => null, + }; + } + catch + { + return null; + } + } + private void SkipWhitespace() { while (Position < Input.Length && char.IsWhiteSpace(Input[Position])) diff --git a/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj b/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj index 37b2988f..56136941 100644 --- a/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj +++ b/src/CSharpDB.Admin.Reports/CSharpDB.Admin.Reports.csproj @@ -10,6 +10,7 @@ + diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs index d7d02027..6d0b292a 100644 --- a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs @@ -1,10 +1,14 @@ using CSharpDB.Admin.Reports.Contracts; using CSharpDB.Admin.Reports.Models; using CSharpDB.Client; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Reports.Services; -public sealed class DefaultReportPreviewService(ICSharpDbClient dbClient, IReportSourceProvider sourceProvider) : IReportPreviewService +public sealed class DefaultReportPreviewService( + ICSharpDbClient dbClient, + IReportSourceProvider sourceProvider, + DbFunctionRegistry? functions = null) : IReportPreviewService { internal const int MaxPreviewRows = 10000; internal const int MaxPreviewPages = 250; @@ -25,7 +29,7 @@ public async Task BuildPreviewAsync(ReportDefinition report bool rowTruncated = loadedRows.Count > MaxPreviewRows; List> rows = loadedRows.Take(MaxPreviewRows).ToList(); - IReadOnlyList pages = Paginate(report, rows, out bool pageTruncated); + IReadOnlyList pages = Paginate(report, rows, functions ?? DbFunctionRegistry.Empty, out bool pageTruncated); bool hasSchemaDrift = !string.Equals(source.SourceSchemaSignature, report.SourceSchemaSignature, StringComparison.Ordinal); string? warning = BuildWarning(rowTruncated, pageTruncated, hasSchemaDrift); @@ -120,7 +124,11 @@ private static int CompareValues(object? left, object? right) Convert.ToString(normalizedRight, System.Globalization.CultureInfo.InvariantCulture)); } - private static IReadOnlyList Paginate(ReportDefinition report, List> rows, out bool pageTruncated) + private static IReadOnlyList Paginate( + ReportDefinition report, + List> rows, + DbFunctionRegistry functions, + out bool pageTruncated) { pageTruncated = false; var pages = new List(); @@ -159,7 +167,7 @@ void StartPage() remainingBodyHeight = bodyHeight; if (pageHeader is not null) - currentBands.Add(RenderBand(pageHeader, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc)); + currentBands.Add(RenderBand(pageHeader, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions)); } void FinalizePage() @@ -168,7 +176,7 @@ void FinalizePage() return; if (pageFooter is not null) - currentBands.Add(RenderBand(pageFooter, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc)); + currentBands.Add(RenderBand(pageFooter, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions)); pages.Add(new ReportPreviewPage(pages.Count + 1, currentBands.ToArray())); currentBands = null; @@ -202,7 +210,7 @@ bool TryAddBand(ReportRenderedBand band) return pages; } - if (reportHeader is not null && !TryAddBand(RenderBand(reportHeader, row: rows.FirstOrDefault(), rows, pageNumber: pages.Count + 1, generatedUtc))) + if (reportHeader is not null && !TryAddBand(RenderBand(reportHeader, row: rows.FirstOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) { pageTruncated = truncated; return pages; @@ -230,7 +238,7 @@ bool TryAddBand(ReportRenderedBand band) continue; IReadOnlyList> groupRows = rows.Skip(groupStartIndices[groupIndex]).Take(rowIndex - groupStartIndices[groupIndex]).ToArray(); - if (!TryAddBand(RenderBand(footerBand, row: rows[rowIndex - 1], groupRows, pageNumber: pages.Count + 1, generatedUtc))) + if (!TryAddBand(RenderBand(footerBand, row: rows[rowIndex - 1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) { pageTruncated = truncated; return pages; @@ -253,7 +261,7 @@ bool TryAddBand(ReportRenderedBand band) if (headerBand is null) continue; - if (!TryAddBand(RenderBand(headerBand, row, [row], pageNumber: pages.Count + 1, generatedUtc))) + if (!TryAddBand(RenderBand(headerBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) { pageTruncated = truncated; return pages; @@ -263,7 +271,7 @@ bool TryAddBand(ReportRenderedBand band) havePreviousRow = true; - if (detailBand is not null && !TryAddBand(RenderBand(detailBand, row, [row], pageNumber: pages.Count + 1, generatedUtc))) + if (detailBand is not null && !TryAddBand(RenderBand(detailBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) { pageTruncated = truncated; return pages; @@ -283,7 +291,7 @@ bool TryAddBand(ReportRenderedBand band) continue; IReadOnlyList> groupRows = rows.Skip(groupStartIndices[groupIndex]).ToArray(); - if (!TryAddBand(RenderBand(footerBand, row: rows[^1], groupRows, pageNumber: pages.Count + 1, generatedUtc))) + if (!TryAddBand(RenderBand(footerBand, row: rows[^1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) { pageTruncated = truncated; return pages; @@ -292,7 +300,7 @@ bool TryAddBand(ReportRenderedBand band) } if (reportFooter is not null) - TryAddBand(RenderBand(reportFooter, row: rows.LastOrDefault(), rows, pageNumber: pages.Count + 1, generatedUtc)); + TryAddBand(RenderBand(reportFooter, row: rows.LastOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions)); FinalizePage(); pageTruncated = truncated; @@ -320,28 +328,46 @@ private static int FindChangedGroupIndex(IReadOnlyList gr private static ReportBandDefinition? FindBand(ReportDefinition report, ReportBandKind kind, string? groupId) => report.Bands.FirstOrDefault(band => band.BandKind == kind && string.Equals(band.GroupId, groupId, StringComparison.Ordinal)); - private static ReportRenderedBand RenderBand(ReportBandDefinition band, IReadOnlyDictionary? row, IReadOnlyList> rows, int pageNumber, DateTime generatedUtc) + private static ReportRenderedBand RenderBand( + ReportBandDefinition band, + IReadOnlyDictionary? row, + IReadOnlyList> rows, + int pageNumber, + DateTime generatedUtc, + DbFunctionRegistry functions) { ReportRenderedControl[] renderedControls = band.Controls - .Select(control => RenderControl(control, row, rows, pageNumber, generatedUtc)) + .Select(control => RenderControl(control, row, rows, pageNumber, generatedUtc, functions)) .ToArray(); return new ReportRenderedBand(band.BandId, band.BandKind, band.GroupId, band.Height, renderedControls); } - private static ReportRenderedControl RenderControl(ReportControlDefinition control, IReadOnlyDictionary? row, IReadOnlyList> rows, int pageNumber, DateTime generatedUtc) + private static ReportRenderedControl RenderControl( + ReportControlDefinition control, + IReadOnlyDictionary? row, + IReadOnlyList> rows, + int pageNumber, + DateTime generatedUtc, + DbFunctionRegistry functions) { string? text = control.ControlType switch { ReportControlType.Label => LookupProp(control.Props, "text"), ReportControlType.BoundText => ReportSql.FormatDisplayValue(LookupFieldValue(row, control.BoundFieldName), control.FormatString), - ReportControlType.CalculatedText => RenderCalculatedText(control, row, rows, pageNumber, generatedUtc), + ReportControlType.CalculatedText => RenderCalculatedText(control, row, rows, pageNumber, generatedUtc, functions), _ => null, }; return new ReportRenderedControl(control.ControlId, control.ControlType, control.Rect, text, control.Props); } - private static string RenderCalculatedText(ReportControlDefinition control, IReadOnlyDictionary? row, IReadOnlyList> rows, int pageNumber, DateTime generatedUtc) + private static string RenderCalculatedText( + ReportControlDefinition control, + IReadOnlyDictionary? row, + IReadOnlyList> rows, + int pageNumber, + DateTime generatedUtc, + DbFunctionRegistry functions) { string expression = control.Expression?.Trim() ?? string.Empty; string? prefix = LookupProp(control.Props, "prefix"); @@ -352,6 +378,8 @@ private static string RenderCalculatedText(ReportControlDefinition control, IRea "=PrintDate" => ReportSql.FormatDisplayValue(generatedUtc, control.FormatString), _ when ReportFormulaEvaluator.TryParseAggregate(expression, out string functionName, out string fieldName) => ReportSql.FormatDisplayValue(ReportFormulaEvaluator.EvaluateAggregate(functionName, rows.Select(item => LookupFieldValue(item, fieldName))), control.FormatString), + _ when ReportFormulaEvaluator.TryEvaluateScalar(expression, field => LookupFieldValue(row, field), functions, out object? scalarValue) + => ReportSql.FormatDisplayValue(scalarValue, control.FormatString), _ when row is not null && ReportFormulaEvaluator.TryReadFieldReference(expression.TrimStart('='), out string boundFieldName) => ReportSql.FormatDisplayValue(LookupFieldValue(row, boundFieldName), control.FormatString), _ when row is not null @@ -360,7 +388,7 @@ _ when row is not null { object? fieldValue = LookupFieldValue(row, field); return ReportSql.TryConvertToDouble(fieldValue, out double numeric) ? numeric : null; - }), + }, functions), control.FormatString), _ => string.Empty, }; diff --git a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs index f1d7ec69..fbd36af0 100644 --- a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Reports.Services; public static class ReportFormulaEvaluator @@ -5,6 +8,12 @@ public static class ReportFormulaEvaluator private static readonly string[] AggregateFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; public static double? EvaluateNumeric(string? expression, Func fieldResolver) + => EvaluateNumeric(expression, fieldResolver, DbFunctionRegistry.Empty); + + public static double? EvaluateNumeric( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions) { if (string.IsNullOrWhiteSpace(expression)) return null; @@ -19,7 +28,7 @@ public static class ReportFormulaEvaluator try { - var parser = new Parser(expr, fieldResolver); + var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty); double? result = parser.ParseExpression(); if (parser.Position < parser.Input.Length) return null; @@ -32,6 +41,28 @@ public static class ReportFormulaEvaluator } } + public static bool TryEvaluateScalar( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions, + out object? value) + { + value = null; + if (functions == null || string.IsNullOrWhiteSpace(expression)) + return false; + + string expr = expression.Trim(); + if (!expr.StartsWith('=')) + return false; + + expr = expr[1..].Trim(); + if (!TryEvaluateFunctionCall(expr, fieldResolver, functions, out DbValue dbValue)) + return false; + + value = FromDbValue(dbValue); + return true; + } + public static bool TryParseAggregate(string? expression, out string functionName, out string fieldName) { functionName = fieldName = string.Empty; @@ -109,12 +140,14 @@ private ref struct Parser public int Position; private readonly Func _fieldResolver; + private readonly DbFunctionRegistry _functions; - public Parser(string input, Func fieldResolver) + public Parser(string input, Func fieldResolver, DbFunctionRegistry functions) { Input = input.AsSpan(); Position = 0; _fieldResolver = fieldResolver; + _functions = functions; } public double? ParseExpression() @@ -226,7 +259,14 @@ public Parser(string input, Func fieldResolver) return ParseNumber(); string? fieldReference = ParseFieldReference(); - return fieldReference is null ? null : _fieldResolver(fieldReference); + if (fieldReference is null) + return null; + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == '(' && IsIdentifier(fieldReference)) + return ParseFunctionCall(fieldReference); + + return _fieldResolver(fieldReference); } private double? ParseNumber() @@ -270,10 +310,213 @@ public Parser(string input, Func fieldResolver) return Input[identifierStart..Position].ToString(); } + private double? ParseFunctionCall(string functionName) + { + Position++; + var arguments = new List(); + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); + } + + while (Position < Input.Length) + { + double? argument = ParseExpression(); + arguments.Add(argument.HasValue ? DbValue.FromReal(argument.Value) : DbValue.Null); + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == ',') + { + Position++; + continue; + } + + if (Position < Input.Length && Input[Position] == ')') + { + Position++; + return InvokeFunction(functionName, arguments); + } + + return null; + } + + return null; + } + + private double? InvokeFunction(string functionName, List arguments) + { + if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) + return null; + + if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + return null; + + try + { + DbValue value = definition.Invoke(arguments.ToArray()); + return value.Type switch + { + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + _ => null, + }; + } + catch + { + return null; + } + } + private void SkipWhitespace() { while (Position < Input.Length && char.IsWhiteSpace(Input[Position])) Position++; } } + + private static bool TryEvaluateFunctionCall( + string expression, + Func fieldResolver, + DbFunctionRegistry functions, + out DbValue value) + { + value = DbValue.Null; + int openParen = expression.IndexOf('('); + if (openParen <= 0 || !expression.EndsWith(')')) + return false; + + string name = expression[..openParen].Trim(); + if (!IsIdentifier(name)) + return false; + + string[] argumentTokens = SplitArguments(expression[(openParen + 1)..^1]); + if (!functions.TryGetScalar(name, argumentTokens.Length, out var definition)) + return false; + + var arguments = new DbValue[argumentTokens.Length]; + for (int i = 0; i < argumentTokens.Length; i++) + arguments[i] = EvaluateScalarArgument(argumentTokens[i].Trim(), fieldResolver, functions); + + if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + { + value = DbValue.Null; + return true; + } + + value = definition.Invoke(arguments); + return true; + } + + private static DbValue EvaluateScalarArgument( + string token, + Func fieldResolver, + DbFunctionRegistry functions) + { + if (TryEvaluateFunctionCall(token, fieldResolver, functions, out DbValue nestedValue)) + return nestedValue; + + if (token.StartsWith('\'') && token.EndsWith('\'') && token.Length >= 2) + return DbValue.FromText(token[1..^1]); + + if (string.Equals(token, "null", StringComparison.OrdinalIgnoreCase)) + return DbValue.Null; + + if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + return DbValue.FromInteger(integer); + + if (double.TryParse(token, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double real)) + return DbValue.FromReal(real); + + if (TryReadFieldReference(token, out string fieldName)) + return ToDbValue(fieldResolver(fieldName)); + + return DbValue.FromText(token); + } + + private static string[] SplitArguments(string argumentsText) + { + if (string.IsNullOrWhiteSpace(argumentsText)) + return []; + + var arguments = new List(); + int start = 0; + int depth = 0; + bool inString = false; + for (int i = 0; i < argumentsText.Length; i++) + { + char ch = argumentsText[i]; + if (ch == '\'') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + if (depth < 0) + return []; + continue; + } + + if (ch == ',' && depth == 0) + { + arguments.Add(argumentsText[start..i].Trim()); + start = i + 1; + } + } + + if (inString || depth != 0) + return []; + + arguments.Add(argumentsText[start..].Trim()); + return arguments.Any(static argument => argument.Length == 0) ? [] : arguments.ToArray(); + } + + private static DbValue ToDbValue(object? value) => value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolean => DbValue.FromInteger(boolean ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + byte[] bytes => DbValue.FromBlob(bytes), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + + private static object? FromDbValue(DbValue value) => value.Type switch + { + DbType.Null => null, + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + DbType.Text => value.AsText, + DbType.Blob => value.AsBlob, + _ => null, + }; + + private static bool IsIdentifier(string value) + { + if (value.Length == 0 || (!char.IsLetter(value[0]) && value[0] != '_')) + return false; + + for (int i = 1; i < value.Length; i++) + { + if (!char.IsLetterOrDigit(value[i]) && value[i] != '_') + return false; + } + + return true; + } } diff --git a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineComponents.cs b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineComponents.cs index baf7bcba..e3e58043 100644 --- a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineComponents.cs +++ b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineComponents.cs @@ -4,17 +4,19 @@ using CSharpDB.Pipelines.Runtime.BuiltIns; using System.Globalization; using System.Text.RegularExpressions; +using DbFunctionRegistry = CSharpDB.Primitives.DbFunctionRegistry; namespace CSharpDB.Client.Pipelines; public sealed class CSharpDbPipelineComponentFactory : IPipelineComponentFactory { private readonly ICSharpDbClient _client; - private readonly DefaultPipelineComponentFactory _fallback = new(); + private readonly DefaultPipelineComponentFactory _fallback; - public CSharpDbPipelineComponentFactory(ICSharpDbClient client) + public CSharpDbPipelineComponentFactory(ICSharpDbClient client, DbFunctionRegistry? functions = null) { _client = client; + _fallback = new DefaultPipelineComponentFactory(functions); } public IPipelineSource CreateSource(PipelineSourceDefinition definition) => definition.Kind switch diff --git a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs index 4ee14a02..98aeff80 100644 --- a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs +++ b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs @@ -1,6 +1,7 @@ using CSharpDB.Pipelines.Models; using CSharpDB.Pipelines.Runtime; using CSharpDB.Pipelines.Serialization; +using CSharpDB.Primitives; namespace CSharpDB.Client.Pipelines; @@ -8,9 +9,9 @@ public sealed class CSharpDbPipelineRunner { private readonly IPipelineOrchestrator _orchestrator; - public CSharpDbPipelineRunner(ICSharpDbClient client) + public CSharpDbPipelineRunner(ICSharpDbClient client, DbFunctionRegistry? functions = null) : this(new PipelineOrchestrator( - new CSharpDbPipelineComponentFactory(client), + new CSharpDbPipelineComponentFactory(client, functions), new CSharpDbPipelineCheckpointStore(client), new CSharpDbPipelineRunLogger(client))) { diff --git a/src/CSharpDB.Engine/Database.cs b/src/CSharpDB.Engine/Database.cs index d35afbe4..9dfb215a 100644 --- a/src/CSharpDB.Engine/Database.cs +++ b/src/CSharpDB.Engine/Database.cs @@ -36,6 +36,7 @@ public sealed class Database : IAsyncDisposable private readonly IIndexProvider _indexProvider; private readonly ICatalogStore _catalogStore; private readonly AdvisoryStatisticsPersistenceMode _advisoryStatisticsPersistenceMode; + private readonly DbFunctionRegistry _functions; private readonly StatementCache _statementCache; private readonly HybridDatabasePersistenceCoordinator? _hybridPersistenceCoordinator; private readonly Dictionary _collectionCache = new(StringComparer.Ordinal); @@ -90,6 +91,7 @@ private Database( ICatalogStore catalogStore, AdvisoryStatisticsPersistenceMode advisoryStatisticsPersistenceMode, ImplicitInsertExecutionMode implicitInsertExecutionMode = ImplicitInsertExecutionMode.Serialized, + DbFunctionRegistry? functions = null, HybridDatabasePersistenceCoordinator? hybridPersistenceCoordinator = null) { _pager = pager; @@ -99,6 +101,7 @@ private Database( _indexProvider = indexProvider; _catalogStore = catalogStore; _advisoryStatisticsPersistenceMode = advisoryStatisticsPersistenceMode; + _functions = functions ?? DbFunctionRegistry.Empty; _implicitInsertExecutionMode = implicitInsertExecutionMode; _hybridPersistenceCoordinator = hybridPersistenceCoordinator; _planner = new QueryPlanner( @@ -107,7 +110,8 @@ private Database( _recordSerializer, nextRowIdHintProvider: TryGetSharedNextRowIdHint, nextRowIdReservationProvider: ReserveSharedNextRowId, - nextRowIdObservationProvider: ObserveSharedNextRowId); + nextRowIdObservationProvider: ObserveSharedNextRowId, + functions: _functions); _statementCache = new StatementCache(DefaultStatementCacheCapacity); _observedSchemaVersion = catalog.SchemaVersion; RefreshSharedNextRowIdHintsFromCatalog(); @@ -139,7 +143,8 @@ public async ValueTask BeginWriteTransactionAsync(Cancellation nextRowIdHintProvider: TryGetSharedNextRowIdHint, nextRowIdReservationProvider: ReserveSharedNextRowId, nextRowIdObservationProvider: ObserveSharedNextRowId, - useTransientNextRowIdHints: true) + useTransientNextRowIdHints: true, + functions: _functions) { PreferSyncPointLookups = PreferSyncPointLookups, }; @@ -462,7 +467,8 @@ public static async ValueTask OpenInMemoryAsync( context.IndexProvider, context.CatalogStore, context.AdvisoryStatisticsPersistenceMode, - options.ImplicitInsertExecutionMode); + options.ImplicitInsertExecutionMode, + options.Functions); } /// @@ -525,6 +531,7 @@ public static async ValueTask OpenHybridAsync( snapshotContext.CatalogStore, snapshotContext.AdvisoryStatisticsPersistenceMode, options.ImplicitInsertExecutionMode, + options.Functions, new HybridDatabasePersistenceCoordinator(fullPath, hybridOptions.PersistenceTriggers)); return snapshotDatabase; } @@ -538,7 +545,8 @@ public static async ValueTask OpenHybridAsync( context.IndexProvider, context.CatalogStore, context.AdvisoryStatisticsPersistenceMode, - options.ImplicitInsertExecutionMode); + options.ImplicitInsertExecutionMode, + options.Functions); try { await database.WarmHybridHotSetAsync(hybridOptions, ct); @@ -596,7 +604,8 @@ public static async ValueTask LoadIntoMemoryAsync( context.IndexProvider, context.CatalogStore, context.AdvisoryStatisticsPersistenceMode, - options.ImplicitInsertExecutionMode); + options.ImplicitInsertExecutionMode, + options.Functions); } /// @@ -618,7 +627,8 @@ public static async ValueTask OpenAsync( context.IndexProvider, context.CatalogStore, context.AdvisoryStatisticsPersistenceMode, - options.ImplicitInsertExecutionMode); + options.ImplicitInsertExecutionMode, + options.Functions); } /// @@ -1014,6 +1024,7 @@ public ReaderSession CreateReaderSession() _recordSerializer, snapshot, _statementCache, + _functions, snapshotRowCounts); } @@ -1845,6 +1856,7 @@ public sealed class ReaderSession : IDisposable private readonly SchemaCatalog _catalog; private readonly IRecordSerializer _recordSerializer; private readonly IRecordSerializer? _collectionReadSerializer; + private readonly DbFunctionRegistry _functions; private readonly Func _releaseActiveQueryCallback; private readonly StatementCache _statementCache; private readonly WalSnapshot _snapshot; @@ -1862,6 +1874,7 @@ internal ReaderSession( IRecordSerializer recordSerializer, WalSnapshot snapshot, StatementCache statementCache, + DbFunctionRegistry functions, IReadOnlyDictionary snapshotRowCounts) { _pager = pager; @@ -1870,6 +1883,7 @@ internal ReaderSession( _collectionReadSerializer = recordSerializer is DefaultRecordSerializer ? new CollectionAwareRecordSerializer(recordSerializer) : null; + _functions = functions; _releaseActiveQueryCallback = ReleaseActiveQueryAsync; _statementCache = statementCache; _snapshot = snapshot; @@ -1944,7 +1958,7 @@ public ValueTask ExecuteReadAsync(Statement stmt, CancellationToken } } - _planner ??= new QueryPlanner(GetOrCreateSnapshotPager(), _catalog, _recordSerializer); + _planner ??= new QueryPlanner(GetOrCreateSnapshotPager(), _catalog, _recordSerializer, functions: _functions); ValueTask plannerTask = _planner.ExecuteAsync(stmt, ct); if (plannerTask.IsCompletedSuccessfully) { @@ -1992,7 +2006,7 @@ private async ValueTask CompleteReadWithPrimaryKeyFastPathAsync( return fastLookupResult; } - _planner ??= new QueryPlanner(GetOrCreateSnapshotPager(), _catalog, _recordSerializer); + _planner ??= new QueryPlanner(GetOrCreateSnapshotPager(), _catalog, _recordSerializer, functions: _functions); QueryResult plannerResult = await _planner.ExecuteAsync(stmt, ct); plannerResult.SetDisposeCallback(_releaseActiveQueryCallback); return plannerResult; diff --git a/src/CSharpDB.Engine/DatabaseOptions.cs b/src/CSharpDB.Engine/DatabaseOptions.cs index 1378d98f..36304f59 100644 --- a/src/CSharpDB.Engine/DatabaseOptions.cs +++ b/src/CSharpDB.Engine/DatabaseOptions.cs @@ -1,4 +1,6 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Engine; /// @@ -16,6 +18,11 @@ public sealed class DatabaseOptions /// public ImplicitInsertExecutionMode ImplicitInsertExecutionMode { get; init; } = ImplicitInsertExecutionMode.Serialized; + /// + /// Trusted in-process scalar functions available to SQL and embedded expression surfaces. + /// + public DbFunctionRegistry Functions { get; init; } = DbFunctionRegistry.Empty; + /// /// Factory used to compose storage engine components. /// diff --git a/src/CSharpDB.Engine/DatabaseOptionsExtensions.cs b/src/CSharpDB.Engine/DatabaseOptionsExtensions.cs index 317aabd5..11988a5e 100644 --- a/src/CSharpDB.Engine/DatabaseOptionsExtensions.cs +++ b/src/CSharpDB.Engine/DatabaseOptionsExtensions.cs @@ -1,3 +1,4 @@ +using CSharpDB.Primitives; using CSharpDB.Storage.StorageEngine; namespace CSharpDB.Engine; @@ -16,9 +17,29 @@ public static DatabaseOptions ConfigureStorageEngine( return new DatabaseOptions { + Functions = options.Functions, ImplicitInsertExecutionMode = options.ImplicitInsertExecutionMode, StorageEngineFactory = options.StorageEngineFactory, StorageEngineOptions = options.StorageEngineOptions.Configure(configure), }; } + + /// + /// Registers trusted in-process scalar functions and returns a new DatabaseOptions instance. + /// + public static DatabaseOptions ConfigureFunctions( + this DatabaseOptions options, + Action configure) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(configure); + + return new DatabaseOptions + { + Functions = DbFunctionRegistry.Create(configure), + ImplicitInsertExecutionMode = options.ImplicitInsertExecutionMode, + StorageEngineFactory = options.StorageEngineFactory, + StorageEngineOptions = options.StorageEngineOptions, + }; + } } diff --git a/src/CSharpDB.Execution/ExpressionCompiler.cs b/src/CSharpDB.Execution/ExpressionCompiler.cs index 928b4f69..1ad9d555 100644 --- a/src/CSharpDB.Execution/ExpressionCompiler.cs +++ b/src/CSharpDB.Execution/ExpressionCompiler.cs @@ -12,13 +12,13 @@ namespace CSharpDB.Execution; /// internal static class ExpressionCompiler { - public static Func Compile(Expression expr, TableSchema schema) + public static Func Compile(Expression expr, TableSchema schema, DbFunctionRegistry? functions = null) { - var spanEvaluator = CompileSpan(expr, schema); + var spanEvaluator = CompileSpan(expr, schema, functions); return row => spanEvaluator(row); } - public static SpanExpressionEvaluator CompileSpan(Expression expr, TableSchema schema) + public static SpanExpressionEvaluator CompileSpan(Expression expr, TableSchema schema, DbFunctionRegistry? functions = null) { var evaluator = CompileMappedCore( expr, @@ -26,20 +26,22 @@ public static SpanExpressionEvaluator CompileSpan(Expression expr, TableSchema s leftColumnCount: schema.Columns.Count, leftColumnMap: null, rightColumnMap: null, - singleRowOnly: true); + singleRowOnly: true, + functions: functions); return row => evaluator(row, default); } - public static JoinSpanExpressionEvaluator CompileJoinSpan(Expression expr, TableSchema schema, int leftColumnCount) - => CompileJoinSpan(expr, schema, leftColumnCount, leftColumnMap: null, rightColumnMap: null); + public static JoinSpanExpressionEvaluator CompileJoinSpan(Expression expr, TableSchema schema, int leftColumnCount, DbFunctionRegistry? functions = null) + => CompileJoinSpan(expr, schema, leftColumnCount, leftColumnMap: null, rightColumnMap: null, functions: functions); public static JoinSpanExpressionEvaluator CompileJoinSpan( Expression expr, TableSchema schema, int leftColumnCount, int[]? leftColumnMap, - int[]? rightColumnMap) - => CompileMappedCore(expr, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly: false); + int[]? rightColumnMap, + DbFunctionRegistry? functions = null) + => CompileMappedCore(expr, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly: false, functions: functions); /// /// Shared compiler for row spans and join spans. Single-row mode treats the left span @@ -52,22 +54,23 @@ private static JoinSpanExpressionEvaluator CompileMappedCore( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { return expr switch { LiteralExpression lit => CompileMappedLiteral(lit), ParameterExpression param => CompileMappedParameter(param), ColumnRefExpression col => CompileMappedColumn(col, schema, leftColumnCount, leftColumnMap, rightColumnMap), - BinaryExpression bin => CompileMappedBinary(bin, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - UnaryExpression un => CompileMappedUnary(un, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - CollateExpression collate => CompileMappedCore(collate.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - FunctionCallExpression func => CompileMappedFunction(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - LikeExpression like => CompileMappedLike(like, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - InExpression inExpr => CompileMappedIn(inExpr, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - BetweenExpression between => CompileMappedBetween(between, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - IsNullExpression isNull => CompileMappedIsNull(isNull, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - _ => CompileMappedFallback(expr, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), + BinaryExpression bin => CompileMappedBinary(bin, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + UnaryExpression un => CompileMappedUnary(un, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + CollateExpression collate => CompileMappedCore(collate.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + FunctionCallExpression func => CompileMappedFunction(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + LikeExpression like => CompileMappedLike(like, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + InExpression inExpr => CompileMappedIn(inExpr, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + BetweenExpression between => CompileMappedBetween(between, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + IsNullExpression isNull => CompileMappedIsNull(isNull, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + _ => CompileMappedFallback(expr, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), }; } @@ -108,10 +111,11 @@ private static JoinSpanExpressionEvaluator CompileMappedBinary( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { - var left = CompileMappedCore(bin.Left, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); - var right = CompileMappedCore(bin.Right, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var left = CompileMappedCore(bin.Left, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); + var right = CompileMappedCore(bin.Right, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); string? collation = CollationSupport.ResolveComparisonCollation(bin.Left, bin.Right, schema); return (leftRow, rightRow) => @@ -148,9 +152,10 @@ private static JoinSpanExpressionEvaluator CompileMappedUnary( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { - var operand = CompileMappedCore(un.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var operand = CompileMappedCore(un.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); return (leftRow, rightRow) => { @@ -175,16 +180,17 @@ private static JoinSpanExpressionEvaluator CompileMappedFunction( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { string functionName = func.FunctionName.ToUpperInvariant(); if (ScalarFunctionEvaluator.IsAggregateFunction(functionName)) - return CompileMappedFallback(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + return CompileMappedFallback(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); return functionName switch { - "TEXT" => CompileMappedTextFunction(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), - _ => CompileMappedFallback(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly), + "TEXT" => CompileMappedTextFunction(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), + _ => CompileMappedUserFunction(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions), }; } @@ -194,27 +200,55 @@ private static JoinSpanExpressionEvaluator CompileMappedTextFunction( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { if (func.IsStarArg || func.IsDistinct || func.Arguments.Count != 1) - return CompileMappedFallback(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + return CompileMappedFallback(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); - var argumentEvaluator = CompileMappedCore(func.Arguments[0], schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var argumentEvaluator = CompileMappedCore(func.Arguments[0], schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); return (leftRow, rightRow) => ScalarFunctionEvaluator.EvaluateTextValue(argumentEvaluator(leftRow, rightRow)); } + private static JoinSpanExpressionEvaluator CompileMappedUserFunction( + FunctionCallExpression func, + TableSchema schema, + int leftColumnCount, + int[]? leftColumnMap, + int[]? rightColumnMap, + bool singleRowOnly, + DbFunctionRegistry? functions) + { + if (func.IsStarArg || func.IsDistinct) + return CompileMappedFallback(func, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); + + var argumentEvaluators = new JoinSpanExpressionEvaluator[func.Arguments.Count]; + for (int i = 0; i < argumentEvaluators.Length; i++) + argumentEvaluators[i] = CompileMappedCore(func.Arguments[i], schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); + + return (leftRow, rightRow) => + { + var arguments = new DbValue[argumentEvaluators.Length]; + for (int i = 0; i < argumentEvaluators.Length; i++) + arguments[i] = argumentEvaluators[i](leftRow, rightRow); + + return ScalarFunctionEvaluator.Evaluate(func, arguments, functions); + }; + } + private static JoinSpanExpressionEvaluator CompileMappedLike( LikeExpression like, TableSchema schema, int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { - var operandEval = CompileMappedCore(like.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); - var patternEval = CompileMappedCore(like.Pattern, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var operandEval = CompileMappedCore(like.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); + var patternEval = CompileMappedCore(like.Pattern, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); var escapeEval = like.EscapeChar != null - ? CompileMappedCore(like.EscapeChar, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly) + ? CompileMappedCore(like.EscapeChar, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions) : null; return (leftRow, rightRow) => @@ -247,12 +281,13 @@ private static JoinSpanExpressionEvaluator CompileMappedIn( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { - var operandEval = CompileMappedCore(inExpr.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var operandEval = CompileMappedCore(inExpr.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); var valueEvals = new JoinSpanExpressionEvaluator[inExpr.Values.Count]; for (int i = 0; i < inExpr.Values.Count; i++) - valueEvals[i] = CompileMappedCore(inExpr.Values[i], schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + valueEvals[i] = CompileMappedCore(inExpr.Values[i], schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); string? collation = CollationSupport.ResolveExpressionCollation(inExpr.Operand, schema); return (leftRow, rightRow) => @@ -293,11 +328,12 @@ private static JoinSpanExpressionEvaluator CompileMappedBetween( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { - var operandEval = CompileMappedCore(between.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); - var lowEval = CompileMappedCore(between.Low, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); - var highEval = CompileMappedCore(between.High, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var operandEval = CompileMappedCore(between.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); + var lowEval = CompileMappedCore(between.Low, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); + var highEval = CompileMappedCore(between.High, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); string? collation = CollationSupport.ResolveExpressionCollation(between.Operand, schema); return (leftRow, rightRow) => @@ -321,9 +357,10 @@ private static JoinSpanExpressionEvaluator CompileMappedIsNull( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { - var operandEval = CompileMappedCore(isNull.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly); + var operandEval = CompileMappedCore(isNull.Operand, schema, leftColumnCount, leftColumnMap, rightColumnMap, singleRowOnly, functions); return (leftRow, rightRow) => { var operand = operandEval(leftRow, rightRow); @@ -338,7 +375,8 @@ private static JoinSpanExpressionEvaluator CompileMappedFallback( int leftColumnCount, int[]? leftColumnMap, int[]? rightColumnMap, - bool singleRowOnly) + bool singleRowOnly, + DbFunctionRegistry? functions) { return (leftRow, rightRow) => { @@ -350,7 +388,7 @@ private static JoinSpanExpressionEvaluator CompileMappedFallback( leftColumnMap, rightColumnMap, singleRowOnly); - return ExpressionEvaluator.Evaluate(expr, materializedRow, schema); + return ExpressionEvaluator.Evaluate(expr, materializedRow, schema, functions); }; } diff --git a/src/CSharpDB.Execution/ExpressionEvaluator.cs b/src/CSharpDB.Execution/ExpressionEvaluator.cs index f12dbb7d..e41bea6e 100644 --- a/src/CSharpDB.Execution/ExpressionEvaluator.cs +++ b/src/CSharpDB.Execution/ExpressionEvaluator.cs @@ -6,23 +6,29 @@ namespace CSharpDB.Execution; public static class ExpressionEvaluator { public static DbValue Evaluate(Expression expr, DbValue[] row, TableSchema schema) - => Evaluate(expr, row.AsSpan(), schema); + => Evaluate(expr, row.AsSpan(), schema, DbFunctionRegistry.Empty); + + public static DbValue Evaluate(Expression expr, DbValue[] row, TableSchema schema, DbFunctionRegistry? functions) + => Evaluate(expr, row.AsSpan(), schema, functions); public static DbValue Evaluate(Expression expr, ReadOnlySpan row, TableSchema schema) + => Evaluate(expr, row, schema, DbFunctionRegistry.Empty); + + public static DbValue Evaluate(Expression expr, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { return expr switch { LiteralExpression lit => EvalLiteral(lit), ParameterExpression param => EvalParameter(param), ColumnRefExpression col => EvalColumn(col, row, schema), - BinaryExpression bin => EvalBinary(bin, row, schema), - UnaryExpression un => EvalUnary(un, row, schema), - CollateExpression collate => Evaluate(collate.Operand, row, schema), - FunctionCallExpression func => EvalFunction(func, row, schema), - LikeExpression like => EvalLike(like, row, schema), - InExpression inExpr => EvalIn(inExpr, row, schema), - BetweenExpression bet => EvalBetween(bet, row, schema), - IsNullExpression isNull => EvalIsNull(isNull, row, schema), + BinaryExpression bin => EvalBinary(bin, row, schema, functions), + UnaryExpression un => EvalUnary(un, row, schema, functions), + CollateExpression collate => Evaluate(collate.Operand, row, schema, functions), + FunctionCallExpression func => EvalFunction(func, row, schema, functions), + LikeExpression like => EvalLike(like, row, schema, functions), + InExpression inExpr => EvalIn(inExpr, row, schema, functions), + BetweenExpression bet => EvalBetween(bet, row, schema, functions), + IsNullExpression isNull => EvalIsNull(isNull, row, schema, functions), _ => throw new CSharpDbException(ErrorCode.Unknown, $"Unknown expression type: {expr.GetType().Name}"), }; } @@ -63,10 +69,10 @@ private static DbValue EvalColumn(ColumnRefExpression col, ReadOnlySpan return idx < row.Length ? row[idx] : DbValue.Null; } - private static DbValue EvalBinary(BinaryExpression bin, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalBinary(BinaryExpression bin, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { - var left = Evaluate(bin.Left, row, schema); - var right = Evaluate(bin.Right, row, schema); + var left = Evaluate(bin.Left, row, schema, functions); + var right = Evaluate(bin.Right, row, schema, functions); string? collation = CollationSupport.ResolveComparisonCollation(bin.Left, bin.Right, schema); return bin.Op switch @@ -87,9 +93,9 @@ private static DbValue EvalBinary(BinaryExpression bin, ReadOnlySpan ro }; } - private static DbValue EvalUnary(UnaryExpression un, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalUnary(UnaryExpression un, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { - var operand = Evaluate(un.Operand, row, schema); + var operand = Evaluate(un.Operand, row, schema, functions); return un.Op switch { TokenType.Not => BoolToDb(!operand.IsTruthy), @@ -103,17 +109,17 @@ private static DbValue EvalUnary(UnaryExpression un, ReadOnlySpan row, }; } - private static DbValue EvalFunction(FunctionCallExpression func, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalFunction(FunctionCallExpression func, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { string functionName = func.FunctionName.ToUpperInvariant(); if (ScalarFunctionEvaluator.IsAggregateFunction(functionName)) throw new CSharpDbException(ErrorCode.Unknown, $"Aggregate function '{func.FunctionName}' requires aggregate context."); - return functionName switch - { - "TEXT" => EvalTextFunction(func, row, schema), - _ => throw new CSharpDbException(ErrorCode.Unknown, $"Unknown scalar function: {func.FunctionName}"), - }; + var materializedRow = row.ToArray(); + return ScalarFunctionEvaluator.Evaluate( + func, + arg => Evaluate(arg, materializedRow, schema, functions), + functions); } private static DbValue BoolToDb(bool value) => DbValue.FromInteger(value ? 1 : 0); @@ -135,24 +141,16 @@ private static DbValue ArithmeticOp(DbValue left, DbValue right, private static Exception DivZero() => new CSharpDbException(ErrorCode.Unknown, "Division by zero."); - private static DbValue EvalTextFunction(FunctionCallExpression func, ReadOnlySpan row, TableSchema schema) - { - if (func.IsStarArg || func.IsDistinct || func.Arguments.Count != 1) - throw new CSharpDbException(ErrorCode.SyntaxError, "TEXT() requires exactly one argument."); - - return ScalarFunctionEvaluator.EvaluateTextValue(Evaluate(func.Arguments[0], row, schema)); - } - - private static DbValue EvalLike(LikeExpression like, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalLike(LikeExpression like, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { - var operand = Evaluate(like.Operand, row, schema); - var pattern = Evaluate(like.Pattern, row, schema); + var operand = Evaluate(like.Operand, row, schema, functions); + var pattern = Evaluate(like.Pattern, row, schema, functions); if (operand.IsNull || pattern.IsNull) return DbValue.Null; char? escape = null; if (like.EscapeChar != null) { - var esc = Evaluate(like.EscapeChar, row, schema); + var esc = Evaluate(like.EscapeChar, row, schema, functions); if (!esc.IsNull) { string escStr = esc.AsText; @@ -164,9 +162,9 @@ private static DbValue EvalLike(LikeExpression like, ReadOnlySpan row, return BoolToDb(like.Negated ? !match : match); } - private static DbValue EvalIn(InExpression inExpr, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalIn(InExpression inExpr, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { - var operand = Evaluate(inExpr.Operand, row, schema); + var operand = Evaluate(inExpr.Operand, row, schema, functions); if (operand.IsNull) return DbValue.Null; string? collation = CollationSupport.ResolveExpressionCollation(inExpr.Operand, schema); @@ -174,7 +172,7 @@ private static DbValue EvalIn(InExpression inExpr, ReadOnlySpan row, Ta bool hasNull = false; foreach (var valExpr in inExpr.Values) { - var val = Evaluate(valExpr, row, schema); + var val = Evaluate(valExpr, row, schema, functions); if (val.IsNull) { hasNull = true; continue; } if (CollationSupport.Compare(operand, val, collation) == 0) { found = true; break; } } @@ -184,11 +182,11 @@ private static DbValue EvalIn(InExpression inExpr, ReadOnlySpan row, Ta return BoolToDb(inExpr.Negated); } - private static DbValue EvalBetween(BetweenExpression bet, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalBetween(BetweenExpression bet, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { - var operand = Evaluate(bet.Operand, row, schema); - var low = Evaluate(bet.Low, row, schema); - var high = Evaluate(bet.High, row, schema); + var operand = Evaluate(bet.Operand, row, schema, functions); + var low = Evaluate(bet.Low, row, schema, functions); + var high = Evaluate(bet.High, row, schema, functions); if (operand.IsNull || low.IsNull || high.IsNull) return DbValue.Null; string? collation = CollationSupport.ResolveExpressionCollation(bet.Operand, schema); @@ -197,9 +195,9 @@ private static DbValue EvalBetween(BetweenExpression bet, ReadOnlySpan return BoolToDb(bet.Negated ? !inRange : inRange); } - private static DbValue EvalIsNull(IsNullExpression isNull, ReadOnlySpan row, TableSchema schema) + private static DbValue EvalIsNull(IsNullExpression isNull, ReadOnlySpan row, TableSchema schema, DbFunctionRegistry? functions) { - var operand = Evaluate(isNull.Operand, row, schema); + var operand = Evaluate(isNull.Operand, row, schema, functions); bool result = operand.IsNull; return BoolToDb(isNull.Negated ? !result : result); } diff --git a/src/CSharpDB.Execution/Operators.cs b/src/CSharpDB.Execution/Operators.cs index 02d8fe94..b85e160a 100644 --- a/src/CSharpDB.Execution/Operators.cs +++ b/src/CSharpDB.Execution/Operators.cs @@ -817,8 +817,8 @@ public sealed class FilterOperator : IOperator, IBatchOperator, IRowBufferReuseC public DbValue[] Current => _source.Current; IOperator IUnaryOperatorSource.Source => _source; - public FilterOperator(IOperator source, Expression predicate, TableSchema schema) - : this(source, ExpressionCompiler.CompileSpan(predicate, schema)) + public FilterOperator(IOperator source, Expression predicate, TableSchema schema, DbFunctionRegistry? functions = null) + : this(source, ExpressionCompiler.CompileSpan(predicate, schema, functions)) { } @@ -1042,7 +1042,13 @@ public sealed class ProjectionOperator : IOperator, IBatchOperator, IRowBufferRe public DbValue[] Current { get; private set; } = Array.Empty(); IOperator IUnaryOperatorSource.Source => _source; - public ProjectionOperator(IOperator source, int[] columnIndices, ColumnDefinition[] outputSchema, TableSchema schema, Expression[]? expressions = null) + public ProjectionOperator( + IOperator source, + int[] columnIndices, + ColumnDefinition[] outputSchema, + TableSchema schema, + Expression[]? expressions = null, + DbFunctionRegistry? functions = null) { _source = source; _columnIndices = columnIndices; @@ -1050,7 +1056,7 @@ public ProjectionOperator(IOperator source, int[] columnIndices, ColumnDefinitio { _spanExpressionEvaluators = new SpanExpressionEvaluator[expressions.Length]; for (int i = 0; i < expressions.Length; i++) - _spanExpressionEvaluators[i] = ExpressionCompiler.CompileSpan(expressions[i], schema); + _spanExpressionEvaluators[i] = ExpressionCompiler.CompileSpan(expressions[i], schema, functions); } OutputSchema = outputSchema; } @@ -3615,6 +3621,7 @@ public SimpleGroupedBatchKeyPlan(BatchProjectionTerm[] groupKeyTerms) private readonly List? _groupByExprs; private readonly Expression? _havingExpr; private readonly TableSchema _inputSchema; + private readonly DbFunctionRegistry _functions; private readonly List _aggregateFunctions = new(); private readonly Dictionary _aggregateIndices = new(); private readonly SpanExpressionEvaluator[]? _groupByEvaluators; @@ -3638,17 +3645,19 @@ public HashAggregateOperator( List? groupByExprs, Expression? havingExpr, TableSchema inputSchema, - ColumnDefinition[] outputSchema) + ColumnDefinition[] outputSchema, + DbFunctionRegistry? functions = null) { _source = source; _selectColumns = selectColumns; _groupByExprs = groupByExprs; _havingExpr = havingExpr; _inputSchema = inputSchema; + _functions = functions ?? DbFunctionRegistry.Empty; OutputSchema = outputSchema; if (_groupByExprs is { Count: > 0 }) { - _groupByEvaluators = BuildGroupByEvaluators(_groupByExprs, _inputSchema); + _groupByEvaluators = BuildGroupByEvaluators(_groupByExprs, _inputSchema, _functions); _groupByIsConstant = _groupByExprs.All(e => e is LiteralExpression); } @@ -4175,7 +4184,7 @@ private AggregateState[] CreateAggregateStates() { var states = new AggregateState[_aggregateFunctions.Count]; for (int i = 0; i < states.Length; i++) - states[i] = new AggregateState(_aggregateFunctions[i], _inputSchema); + states[i] = new AggregateState(_aggregateFunctions[i], _inputSchema, _functions); return states; } @@ -4216,17 +4225,17 @@ private GroupKey BuildGroupKey(ReadOnlySpan row) return new GroupKey(values, hash.ToHashCode()); } - private static SpanExpressionEvaluator[] BuildGroupByEvaluators(List expressions, TableSchema schema) + private static SpanExpressionEvaluator[] BuildGroupByEvaluators(List expressions, TableSchema schema, DbFunctionRegistry functions) { var evaluators = new SpanExpressionEvaluator[expressions.Count]; for (int i = 0; i < expressions.Count; i++) - evaluators[i] = BuildGroupByEvaluator(expressions[i], schema); + evaluators[i] = BuildGroupByEvaluator(expressions[i], schema, functions); return evaluators; } - private static SpanExpressionEvaluator BuildGroupByEvaluator(Expression expr, TableSchema schema) + private static SpanExpressionEvaluator BuildGroupByEvaluator(Expression expr, TableSchema schema, DbFunctionRegistry functions) { - return ExpressionCompiler.CompileSpan(expr, schema); + return ExpressionCompiler.CompileSpan(expr, schema, functions); } private static Func BuildColumnEvaluator(ColumnRefExpression col, TableSchema schema) @@ -4273,12 +4282,12 @@ private DbValue EvalWithAggregates(Expression expr, GroupState group) { FunctionCallExpression func => ScalarFunctionEvaluator.IsAggregateFunction(func.FunctionName) ? EvaluateAggregate(func, group) - : ScalarFunctionEvaluator.Evaluate(func, arg => EvalWithAggregates(arg, group)), + : ScalarFunctionEvaluator.Evaluate(func, arg => EvalWithAggregates(arg, group), _functions), BinaryExpression bin => EvalBinaryWithAgg(bin, group), UnaryExpression un => EvalUnaryWithAgg(un, group), CollateExpression collate => EvalWithAggregates(collate.Operand, group), _ => group.FirstRow != null - ? ExpressionEvaluator.Evaluate(expr, group.FirstRow, _inputSchema) + ? ExpressionEvaluator.Evaluate(expr, group.FirstRow, _inputSchema, _functions) : DbValue.Null, }; } @@ -4415,10 +4424,10 @@ private sealed class AggregateState private bool _hasAny; private DbValue? _best; - public AggregateState(FunctionCallExpression func, TableSchema schema) + public AggregateState(FunctionCallExpression func, TableSchema schema, DbFunctionRegistry functions) { _name = func.FunctionName; - _argumentEvaluator = BuildAggregateArgumentEvaluator(func, schema); + _argumentEvaluator = BuildAggregateArgumentEvaluator(func, schema, functions); _isDistinct = func.IsDistinct; _isStarArg = func.IsStarArg; _directColumnIndex = TryResolveDirectColumnIndex(func, schema); @@ -4604,12 +4613,12 @@ private DbValue EvaluateArgument(ReadOnlySpan row, ref DbValue[]? rowBu private static DbValue GetValue(ReadOnlySpan row, int columnIndex) => (uint)columnIndex < (uint)row.Length ? row[columnIndex] : DbValue.Null; - private static SpanExpressionEvaluator? BuildAggregateArgumentEvaluator(FunctionCallExpression func, TableSchema schema) + private static SpanExpressionEvaluator? BuildAggregateArgumentEvaluator(FunctionCallExpression func, TableSchema schema, DbFunctionRegistry functions) { if (func.IsStarArg || func.Arguments.Count == 0) return null; - return ExpressionCompiler.CompileSpan(func.Arguments[0], schema); + return ExpressionCompiler.CompileSpan(func.Arguments[0], schema, functions); } private static int TryResolveDirectColumnIndex(FunctionCallExpression func, TableSchema schema) @@ -4721,6 +4730,7 @@ public sealed class ScalarAggregateOperator : IOperator, IEstimatedRowCountProvi private readonly List _selectColumns; private readonly Expression? _havingExpr; private readonly TableSchema _inputSchema; + private readonly DbFunctionRegistry _functions; private readonly Dictionary _aggregateStates = new(); private readonly List _aggregateFunctions = new(); private readonly AggregateState[] _aggregateStateList; @@ -4739,12 +4749,14 @@ public ScalarAggregateOperator( List selectColumns, Expression? havingExpr, TableSchema inputSchema, - ColumnDefinition[] outputSchema) + ColumnDefinition[] outputSchema, + DbFunctionRegistry? functions = null) { _source = source; _selectColumns = selectColumns; _havingExpr = havingExpr; _inputSchema = inputSchema; + _functions = functions ?? DbFunctionRegistry.Empty; OutputSchema = outputSchema; foreach (var col in _selectColumns) @@ -4760,7 +4772,7 @@ public ScalarAggregateOperator( for (int i = 0; i < _aggregateFunctions.Count; i++) { var func = _aggregateFunctions[i]; - var state = new AggregateState(func, _inputSchema); + var state = new AggregateState(func, _inputSchema, _functions); _aggregateStateList[i] = state; _aggregateStates[func] = state; } @@ -4900,7 +4912,7 @@ private void CollectAggregates(Expression expr) { if (!_aggregateStates.ContainsKey(func)) { - _aggregateStates.Add(func, new AggregateState(func, _inputSchema)); + _aggregateStates.Add(func, new AggregateState(func, _inputSchema, _functions)); _aggregateFunctions.Add(func); } } @@ -4929,12 +4941,12 @@ private DbValue EvalWithAggregates(Expression expr, DbValue[]? firstRow) { FunctionCallExpression func => ScalarFunctionEvaluator.IsAggregateFunction(func.FunctionName) ? EvaluateAggregate(func) - : ScalarFunctionEvaluator.Evaluate(func, arg => EvalWithAggregates(arg, firstRow)), + : ScalarFunctionEvaluator.Evaluate(func, arg => EvalWithAggregates(arg, firstRow), _functions), BinaryExpression bin => EvalBinaryWithAgg(bin, firstRow), UnaryExpression un => EvalUnaryWithAgg(un, firstRow), CollateExpression collate => EvalWithAggregates(collate.Operand, firstRow), _ => firstRow != null - ? ExpressionEvaluator.Evaluate(expr, firstRow, _inputSchema) + ? ExpressionEvaluator.Evaluate(expr, firstRow, _inputSchema, _functions) : DbValue.Null, }; } @@ -5009,10 +5021,10 @@ private sealed class AggregateState private bool _hasAny; private DbValue? _best; - public AggregateState(FunctionCallExpression func, TableSchema schema) + public AggregateState(FunctionCallExpression func, TableSchema schema, DbFunctionRegistry functions) { _name = func.FunctionName; - _argumentEvaluator = BuildAggregateArgumentEvaluator(func, schema); + _argumentEvaluator = BuildAggregateArgumentEvaluator(func, schema, functions); _isDistinct = func.IsDistinct; _isStarArg = func.IsStarArg; _directColumnIndex = TryResolveDirectColumnIndex(func, schema); @@ -5139,12 +5151,12 @@ private DbValue EvaluateArgument(ReadOnlySpan row, ref DbValue[]? rowBu return _argumentEvaluator!(row); } - private static SpanExpressionEvaluator? BuildAggregateArgumentEvaluator(FunctionCallExpression func, TableSchema schema) + private static SpanExpressionEvaluator? BuildAggregateArgumentEvaluator(FunctionCallExpression func, TableSchema schema, DbFunctionRegistry functions) { if (func.IsStarArg || func.Arguments.Count == 0) return null; - return ExpressionCompiler.CompileSpan(func.Arguments[0], schema); + return ExpressionCompiler.CompileSpan(func.Arguments[0], schema, functions); } private static int TryResolveDirectColumnIndex(FunctionCallExpression func, TableSchema schema) @@ -5351,11 +5363,11 @@ public DbValue EvaluateSortKey(DbValue[] row) public DbValue[] Current { get; private set; } = Array.Empty(); public int? EstimatedRowCount => _sortedRows?.Count ?? _lateMaterializedRowIds?.Length; - public SortOperator(IOperator source, List orderBy, TableSchema schema) + public SortOperator(IOperator source, List orderBy, TableSchema schema, DbFunctionRegistry? functions = null) { _source = source; _schema = schema; - _compiledOrderBy = CompileOrderBy(orderBy, schema, out _precomputedKeyCount); + _compiledOrderBy = CompileOrderBy(orderBy, schema, out _precomputedKeyCount, functions); if (_compiledOrderBy.Length == 1) { var clause = _compiledOrderBy[0]; @@ -5826,7 +5838,11 @@ private int CompareRowIndices(int aIndex, int bIndex) return 0; } - private static CompiledSortClause[] CompileOrderBy(List orderBy, TableSchema schema, out int precomputedKeyCount) + private static CompiledSortClause[] CompileOrderBy( + List orderBy, + TableSchema schema, + out int precomputedKeyCount, + DbFunctionRegistry? functions) { precomputedKeyCount = 0; var compiled = new CompiledSortClause[orderBy.Count]; @@ -5838,7 +5854,7 @@ private static CompiledSortClause[] CompileOrderBy(List orderBy, int keyIndex = columnIndex >= 0 ? -1 : precomputedKeyCount++; string? collation = CollationSupport.ResolveExpressionCollation(clause.Expression, schema); Func? keyEvaluator = keyIndex >= 0 - ? ExpressionCompiler.Compile(clause.Expression, schema) + ? ExpressionCompiler.Compile(clause.Expression, schema, functions) : null; compiled[i] = new CompiledSortClause(clause.Expression, columnIndex, keyIndex, clause.Descending, collation, keyEvaluator); } @@ -6371,10 +6387,15 @@ public SingleKeyRowIdRankedRow(DbValue key, long rowId) public DbValue[] Current { get; private set; } = Array.Empty(); public int? EstimatedRowCount => _topN; - public TopNSortOperator(IOperator source, List orderBy, TableSchema schema, int topN) + public TopNSortOperator( + IOperator source, + List orderBy, + TableSchema schema, + int topN, + DbFunctionRegistry? functions = null) { _source = source; - _compiledOrderBy = CompileOrderBy(orderBy, schema, out _precomputedKeyCount); + _compiledOrderBy = CompileOrderBy(orderBy, schema, out _precomputedKeyCount, functions); _singleComputedKeyClauseIndex = FindSingleComputedKeyClauseIndex(_compiledOrderBy, _precomputedKeyCount); _singleComputedKeyFastPath = _singleComputedKeyClauseIndex >= 0; _topN = Math.Max(0, topN); @@ -6814,7 +6835,11 @@ private static RankedRow EnsureOwnedRow(RankedRow row, bool sourceReusesCurrentR : new RankedRow((DbValue[])row.Row.Clone(), row.Keys); } - private static CompiledSortClause[] CompileOrderBy(List orderBy, TableSchema schema, out int precomputedKeyCount) + private static CompiledSortClause[] CompileOrderBy( + List orderBy, + TableSchema schema, + out int precomputedKeyCount, + DbFunctionRegistry? functions) { precomputedKeyCount = 0; var compiled = new CompiledSortClause[orderBy.Count]; @@ -6831,7 +6856,7 @@ private static CompiledSortClause[] CompileOrderBy(List orderBy, int keyIndex = columnIndex >= 0 ? -1 : precomputedKeyCount++; string? collation = CollationSupport.ResolveExpressionCollation(clause.Expression, schema); Func? keyEvaluator = keyIndex >= 0 - ? ExpressionCompiler.Compile(clause.Expression, schema) + ? ExpressionCompiler.Compile(clause.Expression, schema, functions) : null; compiled[i] = new CompiledSortClause( clause.Expression, @@ -6970,6 +6995,7 @@ public sealed class HashJoinOperator : IOperator, IBatchOperator, IBatchBackedRo private readonly int[] _rightKeyIndices; private readonly Expression? _residualConditionExpression; private readonly TableSchema _compositeSchema; + private readonly DbFunctionRegistry _functions; private readonly JoinSpanExpressionEvaluator? _residualPredicate; private JoinSpanExpressionEvaluator? _compactedResidualPredicate; private readonly int _leftColCount; @@ -7022,7 +7048,8 @@ public HashJoinOperator( int[] rightKeyIndices, bool buildRightSide = true, int? buildRowCapacityHint = null, - int? estimatedOutputRowCount = null) + int? estimatedOutputRowCount = null, + DbFunctionRegistry? functions = null) { _left = left; _right = right; @@ -7032,8 +7059,9 @@ public HashJoinOperator( _rightKeyIndices = rightKeyIndices; _residualConditionExpression = residualCondition; _compositeSchema = compositeSchema; + _functions = functions ?? DbFunctionRegistry.Empty; _residualPredicate = residualCondition != null - ? ExpressionCompiler.CompileJoinSpan(residualCondition, compositeSchema, leftColCount) + ? ExpressionCompiler.CompileJoinSpan(residualCondition, compositeSchema, leftColCount, _functions) : null; _compactedResidualPredicate = null; _leftColCount = leftColCount; @@ -7119,7 +7147,8 @@ public async ValueTask OpenAsync(CancellationToken ct = default) _compositeSchema, _leftColCount, leftColumnMap: _buildRightSide ? null : _buildColumnToCompactIndexMap, - rightColumnMap: _buildRightSide ? _buildColumnToCompactIndexMap : null); + rightColumnMap: _buildRightSide ? _buildColumnToCompactIndexMap : null, + functions: _functions); } await ConsumeBuildRowsAsync(ct); @@ -8615,7 +8644,8 @@ public IndexNestedLoopJoinOperator( Expression? residualCondition, TableSchema compositeSchema, IRecordSerializer? recordSerializer = null, - int? estimatedOutputRowCount = null) + int? estimatedOutputRowCount = null, + DbFunctionRegistry? functions = null) { _outer = outer; _innerTableTree = innerTableTree; @@ -8628,7 +8658,7 @@ public IndexNestedLoopJoinOperator( _estimatedRowCount = estimatedOutputRowCount > 0 ? estimatedOutputRowCount : null; _residualConditionExpression = residualCondition; _residualPredicate = residualCondition != null - ? ExpressionCompiler.CompileJoinSpan(residualCondition, compositeSchema, leftColCount) + ? ExpressionCompiler.CompileJoinSpan(residualCondition, compositeSchema, leftColCount, functions) : null; _compositeSchema = compositeSchema; _recordSerializer = recordSerializer ?? new DefaultRecordSerializer(); @@ -9478,7 +9508,8 @@ internal HashedIndexNestedLoopJoinOperator( SqlIndexStorageMode storageMode = SqlIndexStorageMode.Hashed, bool usesOrderedTextPayload = false, IRecordSerializer? recordSerializer = null, - int? estimatedOutputRowCount = null) + int? estimatedOutputRowCount = null, + DbFunctionRegistry? functions = null) { _outer = outer; _innerTableTree = innerTableTree; @@ -9494,7 +9525,7 @@ internal HashedIndexNestedLoopJoinOperator( _rightPrimaryKeyColumnIndex = rightPrimaryKeyColumnIndex; _estimatedRowCount = estimatedOutputRowCount > 0 ? estimatedOutputRowCount : null; _residualPredicate = residualCondition != null - ? ExpressionCompiler.CompileJoinSpan(residualCondition, compositeSchema, leftColCount) + ? ExpressionCompiler.CompileJoinSpan(residualCondition, compositeSchema, leftColCount, functions) : null; _recordSerializer = recordSerializer ?? new DefaultRecordSerializer(); OutputSchema = compositeSchema.Columns as ColumnDefinition[] ?? compositeSchema.Columns.ToArray(); @@ -10481,14 +10512,15 @@ public NestedLoopJoinOperator( TableSchema compositeSchema, int leftColCount, int rightColCount, int? estimatedOutputRowCount = null, - int? rightRowCapacityHint = null) + int? rightRowCapacityHint = null, + DbFunctionRegistry? functions = null) { _left = left; _right = right; _joinType = joinType; _conditionExpression = condition; _conditionEvaluator = condition != null - ? ExpressionCompiler.CompileJoinSpan(condition, compositeSchema, leftColCount) + ? ExpressionCompiler.CompileJoinSpan(condition, compositeSchema, leftColCount, functions) : null; _compositeSchema = compositeSchema; _leftColCount = leftColCount; diff --git a/src/CSharpDB.Execution/QueryPlanner.cs b/src/CSharpDB.Execution/QueryPlanner.cs index 69de5b9d..c2c245d5 100644 --- a/src/CSharpDB.Execution/QueryPlanner.cs +++ b/src/CSharpDB.Execution/QueryPlanner.cs @@ -203,6 +203,7 @@ public override int GetHashCode() => private readonly SchemaCatalog _catalog; private readonly IRecordSerializer _recordSerializer; private readonly IRecordSerializer? _collectionReadSerializer; + private readonly DbFunctionRegistry _functions; private readonly Func? _tableRowCountProvider; /// @@ -358,7 +359,8 @@ public QueryPlanner( Func? nextRowIdHintProvider = null, Func? nextRowIdReservationProvider = null, Action? nextRowIdObservationProvider = null, - bool useTransientNextRowIdHints = false) + bool useTransientNextRowIdHints = false, + DbFunctionRegistry? functions = null) { _pager = pager; _catalog = catalog; @@ -366,6 +368,7 @@ public QueryPlanner( _collectionReadSerializer = _recordSerializer is DefaultRecordSerializer ? new CollectionAwareRecordSerializer(_recordSerializer) : null; + _functions = functions ?? DbFunctionRegistry.Empty; _tableRowCountProvider = tableRowCountProvider; _nextRowIdHintProvider = nextRowIdHintProvider; _nextRowIdReservationProvider = nextRowIdReservationProvider; @@ -2814,11 +2817,11 @@ private async ValueTask ExecuteSelectWithCorrelatedSubqueriesAsync( var outputCols = BuildAggregateOutputSchema(stmt.Columns, sourceSchema); if (stmt.GroupBy is { Count: > 0 }) { - op = new HashAggregateOperator(op, stmt.Columns, stmt.GroupBy, stmt.Having, sourceSchema, outputCols); + op = new HashAggregateOperator(op, stmt.Columns, stmt.GroupBy, stmt.Having, sourceSchema, outputCols, _functions); } else { - op = new ScalarAggregateOperator(op, stmt.Columns, stmt.Having, sourceSchema, outputCols); + op = new ScalarAggregateOperator(op, stmt.Columns, stmt.Having, sourceSchema, outputCols, _functions); } var aggregateSchema = new TableSchema @@ -3038,11 +3041,11 @@ private async ValueTask EvaluateExpressionWithSubqueriesAsync( CancellationToken ct) { if (!ContainsSubqueries(expression)) - return ExpressionEvaluator.Evaluate(expression, row, schema); + return ExpressionEvaluator.Evaluate(expression, row, schema, _functions); var correlationScopes = CreateCorrelationScopes(row, schema, outerScopes); var rewritten = await RewriteCorrelatedExpressionAsync(expression, correlationScopes, ct); - return ExpressionEvaluator.Evaluate(rewritten, row, schema); + return ExpressionEvaluator.Evaluate(rewritten, row, schema, _functions); } private async ValueTask TryEvaluateExistsFilterFastAsync( @@ -3069,7 +3072,7 @@ private async ValueTask EvaluateExpressionWithSubqueriesAsync( if (ContainsSubqueries(boundOperand)) return null; - var operandValue = ExpressionEvaluator.Evaluate(boundOperand, row, schema); + var operandValue = ExpressionEvaluator.Evaluate(boundOperand, row, schema, _functions); if (operandValue.IsNull) return false; @@ -3093,7 +3096,7 @@ private async ValueTask EvaluateExpressionWithSubqueriesAsync( while (await source.MoveNextAsync(ct)) { if (residualPredicate == null || - ExpressionEvaluator.Evaluate(residualPredicate, source.Current, sourceSchema).IsTruthy) + ExpressionEvaluator.Evaluate(residualPredicate, source.Current, sourceSchema, _functions).IsTruthy) { return true; } @@ -3165,7 +3168,7 @@ private async ValueTask EvaluateExpressionWithSubqueriesAsync( while (await source.MoveNextAsync(ct)) { if (residualPredicate != null && - !ExpressionEvaluator.Evaluate(residualPredicate, source.Current, sourceSchema).IsTruthy) + !ExpressionEvaluator.Evaluate(residualPredicate, source.Current, sourceSchema, _functions).IsTruthy) { continue; } @@ -3706,12 +3709,12 @@ private QueryResult ExecuteSelectGeneral(SelectStatement stmt) if (hasGroupBy) { op = new HashAggregateOperator( - op, stmt.Columns, stmt.GroupBy, stmt.Having, schema, outputCols); + op, stmt.Columns, stmt.GroupBy, stmt.Having, schema, outputCols, _functions); } else { op = new ScalarAggregateOperator( - op, stmt.Columns, stmt.Having, schema, outputCols); + op, stmt.Columns, stmt.Having, schema, outputCols, _functions); } // After aggregate, we need a synthetic schema for Sort to work with @@ -3894,7 +3897,7 @@ column.Expression is not ColumnRefExpression projectedColumn || return topN >= int.MaxValue ? int.MaxValue : (int)topN; } - private static IOperator ApplyOrdering( + private IOperator ApplyOrdering( IOperator source, List? orderBy, TableSchema schema, @@ -3904,9 +3907,9 @@ private static IOperator ApplyOrdering( return source; if (topN.HasValue) - return new TopNSortOperator(source, orderBy, schema, topN.Value); + return new TopNSortOperator(source, orderBy, schema, topN.Value, _functions); - return new SortOperator(source, orderBy, schema); + return new SortOperator(source, orderBy, schema, _functions); } private static bool TryPushDownColumnProjection( @@ -6119,7 +6122,7 @@ private async ValueTask ExecuteDeleteAsync(DeleteStatement stmt, Ca schema, Array.Empty(), ct) - : ExpressionEvaluator.Evaluate(stmt.Where, scan.Current, schema); + : ExpressionEvaluator.Evaluate(stmt.Where, scan.Current, schema, _functions); if (!result.IsTruthy) continue; } rowsToDelete.Add((scan.CurrentRowId, (DbValue[])scan.Current.Clone())); @@ -6208,7 +6211,7 @@ private async ValueTask ExecuteUpdateAsync(UpdateStatement stmt, Ca schema, Array.Empty(), ct) - : ExpressionEvaluator.Evaluate(stmt.Where, scan.Current, schema); + : ExpressionEvaluator.Evaluate(stmt.Where, scan.Current, schema, _functions); if (!result.IsTruthy) continue; } @@ -6226,7 +6229,7 @@ private async ValueTask ExecuteUpdateAsync(UpdateStatement stmt, Ca schema, Array.Empty(), ct) - : ExpressionEvaluator.Evaluate(set.Value, scan.Current, schema); + : ExpressionEvaluator.Evaluate(set.Value, scan.Current, schema, _functions); } updates.Add((scan.CurrentRowId, oldRow, newRow)); } @@ -6400,12 +6403,12 @@ private async ValueTask ExecuteUpdateAsync(UpdateStatement stmt, Ca if (hasGroupBy) { viewOp = new HashAggregateOperator( - viewOp, viewStmt.Columns, viewStmt.GroupBy, viewStmt.Having, viewSchema, outputCols); + viewOp, viewStmt.Columns, viewStmt.GroupBy, viewStmt.Having, viewSchema, outputCols, _functions); } else { viewOp = new ScalarAggregateOperator( - viewOp, viewStmt.Columns, viewStmt.Having, viewSchema, outputCols); + viewOp, viewStmt.Columns, viewStmt.Having, viewSchema, outputCols, _functions); } viewSchema = new TableSchema @@ -6594,7 +6597,8 @@ private async ValueTask ExecuteUpdateAsync(UpdateStatement stmt, Ca rightSchema.Columns.Count, leftSchema.Columns.Count, swappedEstimatedOutputRowCount, - swappedRightRowCapacityHint); + swappedRightRowCapacityHint, + _functions); } // Swapped execution produces [original right | original left]; @@ -6640,7 +6644,7 @@ private async ValueTask ExecuteUpdateAsync(UpdateStatement stmt, Ca var joinOp = new NestedLoopJoinOperator( leftOp, rightOp, join.JoinType, join.Condition, compositeSchema, leftSchema.Columns.Count, rightSchema.Columns.Count, - estimatedOutputRowCount, rightRowCapacityHint); + estimatedOutputRowCount, rightRowCapacityHint, _functions); return (joinOp, compositeSchema); } @@ -7927,7 +7931,8 @@ private bool TryBuildIndexNestedLoopJoinOperator( residualCondition, compositeSchema, GetReadSerializer(rightSchema), - estimatedOutputRowCount); + estimatedOutputRowCount, + _functions); } else { @@ -7947,7 +7952,8 @@ private bool TryBuildIndexNestedLoopJoinOperator( storageMode: lookupStorageMode, usesOrderedTextPayload: usesOrderedTextLookupPayload, recordSerializer: GetReadSerializer(rightSchema), - estimatedOutputRowCount: estimatedOutputRowCount); + estimatedOutputRowCount: estimatedOutputRowCount, + functions: _functions); } return true; @@ -8208,7 +8214,8 @@ private bool TryBuildHashJoinOperator( rightKeyIndices, buildRightSide, buildRowCapacityHint, - estimatedOutputRowCount); + estimatedOutputRowCount, + _functions); return true; } @@ -12779,7 +12786,7 @@ private Func GetOrCompileExpression(Expression expression, T _requiresQualifiedMappingCache.Clear(); } - evaluator = ExpressionCompiler.Compile(expression, schema); + evaluator = ExpressionCompiler.Compile(expression, schema, _functions); _compiledExpressionCache[key] = evaluator; return evaluator; } @@ -12811,7 +12818,7 @@ private SpanExpressionEvaluator GetOrCompileSpanExpression(Expression expression _requiresQualifiedMappingCache.Clear(); } - evaluator = ExpressionCompiler.CompileSpan(expression, schema); + evaluator = ExpressionCompiler.CompileSpan(expression, schema, _functions); _compiledSpanExpressionCache[key] = evaluator; return evaluator; } @@ -14184,12 +14191,12 @@ private static void ValidateInsertValueCount(int expectedCount, int actualCount) } } - private static DbValue ResolveInsertValue(Expression valueExpression, TableSchema schema) + private DbValue ResolveInsertValue(Expression valueExpression, TableSchema schema) { if (valueExpression is LiteralExpression literal) return ResolveInsertLiteral(literal); - return ExpressionEvaluator.Evaluate(valueExpression, Array.Empty(), schema); + return ExpressionEvaluator.Evaluate(valueExpression, Array.Empty(), schema, _functions); } private static DbValue ResolveInsertLiteral(LiteralExpression literal) diff --git a/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs b/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs index df78f3f7..41d7a535 100644 --- a/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs +++ b/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs @@ -10,15 +10,38 @@ public static bool IsAggregateFunction(string functionName) => functionName.ToUpperInvariant() is "COUNT" or "SUM" or "AVG" or "MIN" or "MAX"; public static DbValue Evaluate(FunctionCallExpression func, Func evaluateArgument) + => Evaluate(func, evaluateArgument, DbFunctionRegistry.Empty); + + public static DbValue Evaluate( + FunctionCallExpression func, + Func evaluateArgument, + DbFunctionRegistry? functions) { string functionName = func.FunctionName.ToUpperInvariant(); return functionName switch { "TEXT" => EvaluateText(func, evaluateArgument), - _ => throw new CSharpDbException(ErrorCode.Unknown, $"Unknown scalar function: {func.FunctionName}"), + _ => EvaluateUserFunction(func, evaluateArgument, functions ?? DbFunctionRegistry.Empty), }; } + public static DbValue Evaluate( + FunctionCallExpression func, + DbValue[] arguments, + DbFunctionRegistry? functions) + { + string functionName = func.FunctionName.ToUpperInvariant(); + if (functionName == "TEXT") + { + if (func.IsStarArg || func.IsDistinct || arguments.Length != 1) + throw new CSharpDbException(ErrorCode.SyntaxError, "TEXT() requires exactly one argument."); + + return EvaluateTextValue(arguments[0]); + } + + return EvaluateUserFunction(func, arguments, functions ?? DbFunctionRegistry.Empty); + } + private static DbValue EvaluateText(FunctionCallExpression func, Func evaluateArgument) { if (func.IsStarArg || func.IsDistinct || func.Arguments.Count != 1) @@ -31,6 +54,72 @@ private static DbValue EvaluateText(FunctionCallExpression func, Func DbValue.FromText(ToDisplayText(value)); + private static DbValue EvaluateUserFunction( + FunctionCallExpression func, + Func evaluateArgument, + DbFunctionRegistry functions) + { + if (func.IsStarArg || func.IsDistinct) + throw new CSharpDbException(ErrorCode.SyntaxError, $"Scalar function '{func.FunctionName}' does not support DISTINCT or * arguments."); + + if (!functions.TryGetScalar(func.FunctionName, func.Arguments.Count, out var definition)) + { + throw new CSharpDbException( + ErrorCode.Unknown, + $"Unknown scalar function: {func.FunctionName}"); + } + + var arguments = new DbValue[func.Arguments.Count]; + for (int i = 0; i < arguments.Length; i++) + arguments[i] = evaluateArgument(func.Arguments[i]); + + if (definition.Options.NullPropagating && arguments.Any(static value => value.IsNull)) + return DbValue.Null; + + try + { + return definition.Invoke(arguments); + } + catch (Exception ex) + { + throw new CSharpDbException( + ErrorCode.Unknown, + $"Scalar function '{definition.Name}' failed: {ex.Message}", + ex); + } + } + + private static DbValue EvaluateUserFunction( + FunctionCallExpression func, + DbValue[] arguments, + DbFunctionRegistry functions) + { + if (func.IsStarArg || func.IsDistinct) + throw new CSharpDbException(ErrorCode.SyntaxError, $"Scalar function '{func.FunctionName}' does not support DISTINCT or * arguments."); + + if (!functions.TryGetScalar(func.FunctionName, arguments.Length, out var definition)) + { + throw new CSharpDbException( + ErrorCode.Unknown, + $"Unknown scalar function: {func.FunctionName}"); + } + + if (definition.Options.NullPropagating && arguments.Any(static value => value.IsNull)) + return DbValue.Null; + + try + { + return definition.Invoke(arguments); + } + catch (Exception ex) + { + throw new CSharpDbException( + ErrorCode.Unknown, + $"Scalar function '{definition.Name}' failed: {ex.Message}", + ex); + } + } + private static string ToDisplayText(DbValue value) => value.Type switch { DbType.Null => "NULL", diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs index 1bcb6456..5018d46d 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs @@ -1,9 +1,17 @@ using CSharpDB.Pipelines.Models; +using CSharpDB.Primitives; namespace CSharpDB.Pipelines.Runtime.BuiltIns; public sealed class DefaultPipelineComponentFactory : IPipelineComponentFactory { + private readonly DbFunctionRegistry _functions; + + public DefaultPipelineComponentFactory(DbFunctionRegistry? functions = null) + { + _functions = functions ?? DbFunctionRegistry.Empty; + } + public IPipelineSource CreateSource(PipelineSourceDefinition definition) => definition.Kind switch { PipelineSourceKind.CsvFile => new CsvPipelineSource(definition), @@ -27,13 +35,13 @@ public IReadOnlyList CreateTransforms(IReadOnlyList throw new ArgumentOutOfRangeException(nameof(definition)), }; - private static IPipelineTransform CreateTransform(PipelineTransformDefinition definition) => definition.Kind switch + private IPipelineTransform CreateTransform(PipelineTransformDefinition definition) => definition.Kind switch { PipelineTransformKind.Select => new SelectPipelineTransform(definition), PipelineTransformKind.Rename => new RenamePipelineTransform(definition), PipelineTransformKind.Cast => new CastPipelineTransform(definition), - PipelineTransformKind.Filter => new FilterPipelineTransform(definition), - PipelineTransformKind.Derive => new DerivePipelineTransform(definition), + PipelineTransformKind.Filter => new FilterPipelineTransform(definition, _functions), + PipelineTransformKind.Derive => new DerivePipelineTransform(definition, _functions), PipelineTransformKind.Deduplicate => new DeduplicatePipelineTransform(definition), _ => throw new ArgumentOutOfRangeException(nameof(definition)), }; diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs index 00e8ae74..79239ce6 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs @@ -50,7 +50,10 @@ internal static class TransformSupport }; } - public static bool EvaluateFilter(string expression, IReadOnlyDictionary row) + public static bool EvaluateFilter( + string expression, + IReadOnlyDictionary row, + DbFunctionRegistry? functions = null) { string[] operators = ["==", "!=", ">=", "<=", ">", "<"]; foreach (string op in operators) @@ -63,8 +66,8 @@ public static bool EvaluateFilter(string expression, IReadOnlyDictionary row) + public static object? EvaluateDerivedExpression( + string expression, + IReadOnlyDictionary row, + DbFunctionRegistry? functions = null) { string trimmed = expression.Trim(); if (row.TryGetValue(trimmed, out var columnValue)) @@ -90,11 +96,42 @@ public static bool EvaluateFilter(string expression, IReadOnlyDictionary row) + private static object? EvaluateValue( + string token, + IReadOnlyDictionary row, + DbFunctionRegistry? functions) { + if (row.TryGetValue(token, out var columnValue)) + return columnValue; + + return ParseLiteral(token, row, functions); + } + + private static object? EvaluateFilterLeft( + string token, + IReadOnlyDictionary row, + DbFunctionRegistry? functions) + { + if (row.TryGetValue(token, out var columnValue)) + return columnValue; + + if (TryEvaluateFunctionCall(token, row, functions, out object? functionValue)) + return functionValue; + + return null; + } + + private static object? ParseLiteral( + string token, + IReadOnlyDictionary row, + DbFunctionRegistry? functions) + { + if (TryEvaluateFunctionCall(token, row, functions, out object? functionValue)) + return functionValue; + if (token.StartsWith('\'') && token.EndsWith('\'') && token.Length >= 2) { return token[1..^1]; @@ -133,6 +170,137 @@ public static bool EvaluateFilter(string expression, IReadOnlyDictionary row, + DbFunctionRegistry? functions, + out object? value) + { + value = null; + int openParen = expression.IndexOf('('); + if (openParen <= 0 || !expression.EndsWith(')')) + return false; + + string name = expression[..openParen].Trim(); + if (!IsIdentifier(name)) + return false; + + string argumentsText = expression[(openParen + 1)..^1]; + string[] argumentTokens = SplitArguments(argumentsText); + + DbFunctionRegistry registry = functions ?? DbFunctionRegistry.Empty; + if (!registry.TryGetScalar(name, argumentTokens.Length, out var definition)) + throw new InvalidOperationException($"Unknown scalar function '{name}'."); + + var arguments = new DbValue[argumentTokens.Length]; + for (int i = 0; i < argumentTokens.Length; i++) + arguments[i] = ToDbValue(ParseLiteral(argumentTokens[i].Trim(), row, functions)); + + if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + { + value = null; + return true; + } + + try + { + value = FromDbValue(definition.Invoke(arguments)); + return true; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Scalar function '{definition.Name}' failed: {ex.Message}", ex); + } + } + + private static string[] SplitArguments(string argumentsText) + { + if (string.IsNullOrWhiteSpace(argumentsText)) + return []; + + var arguments = new List(); + int start = 0; + int depth = 0; + bool inString = false; + for (int i = 0; i < argumentsText.Length; i++) + { + char ch = argumentsText[i]; + if (ch == '\'') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + if (depth < 0) + throw new InvalidOperationException($"Malformed function expression '{argumentsText}'."); + continue; + } + + if (ch == ',' && depth == 0) + { + arguments.Add(argumentsText[start..i].Trim()); + start = i + 1; + } + } + + if (inString || depth != 0) + throw new InvalidOperationException($"Malformed function expression '{argumentsText}'."); + + arguments.Add(argumentsText[start..].Trim()); + if (arguments.Any(static argument => argument.Length == 0)) + throw new InvalidOperationException($"Malformed function expression '{argumentsText}'."); + + return arguments.ToArray(); + } + + private static DbValue ToDbValue(object? value) => value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolValue => DbValue.FromInteger(boolValue ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + byte[] bytes => DbValue.FromBlob(bytes), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + + private static object? FromDbValue(DbValue value) => value.Type switch + { + DbType.Null => null, + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + DbType.Text => value.AsText, + DbType.Blob => value.AsBlob, + _ => null, + }; + + private static bool IsIdentifier(string value) + { + if (value.Length == 0 || (!char.IsLetter(value[0]) && value[0] != '_')) + return false; + + for (int i = 1; i < value.Length; i++) + { + if (!char.IsLetterOrDigit(value[i]) && value[i] != '_') + return false; + } + + return true; + } + private static int Compare(object? left, object? right) { if (left is null && right is null) diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs index c9a3c435..7dea0108 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs @@ -1,4 +1,5 @@ using CSharpDB.Pipelines.Models; +using CSharpDB.Primitives; namespace CSharpDB.Pipelines.Runtime.BuiltIns; @@ -104,11 +105,13 @@ public ValueTask TransformAsync(PipelineRowBatch batch, Pipeli public sealed class FilterPipelineTransform : IPipelineTransform { private readonly string _expression; + private readonly DbFunctionRegistry _functions; - public FilterPipelineTransform(PipelineTransformDefinition definition) + public FilterPipelineTransform(PipelineTransformDefinition definition, DbFunctionRegistry? functions = null) { _expression = definition.FilterExpression ?? throw new InvalidOperationException("Filter transform requires an expression."); + _functions = functions ?? DbFunctionRegistry.Empty; } public string Name => "filter"; @@ -116,7 +119,7 @@ public FilterPipelineTransform(PipelineTransformDefinition definition) public ValueTask TransformAsync(PipelineRowBatch batch, PipelineExecutionContext context, CancellationToken ct = default) { var rows = batch.Rows - .Where(row => TransformSupport.EvaluateFilter(_expression, row)) + .Where(row => TransformSupport.EvaluateFilter(_expression, row, _functions)) .Select(row => new Dictionary(row, StringComparer.OrdinalIgnoreCase)) .ToArray(); @@ -127,11 +130,13 @@ public ValueTask TransformAsync(PipelineRowBatch batch, Pipeli public sealed class DerivePipelineTransform : IPipelineTransform { private readonly IReadOnlyList _columns; + private readonly DbFunctionRegistry _functions; - public DerivePipelineTransform(PipelineTransformDefinition definition) + public DerivePipelineTransform(PipelineTransformDefinition definition, DbFunctionRegistry? functions = null) { _columns = definition.DerivedColumns ?? throw new InvalidOperationException("Derive transform requires derived columns."); + _functions = functions ?? DbFunctionRegistry.Empty; } public string Name => "derive"; @@ -143,7 +148,7 @@ public ValueTask TransformAsync(PipelineRowBatch batch, Pipeli var output = new Dictionary(row, StringComparer.OrdinalIgnoreCase); foreach (var column in _columns) { - output[column.Name] = TransformSupport.EvaluateDerivedExpression(column.Expression, output); + output[column.Name] = TransformSupport.EvaluateDerivedExpression(column.Expression, output, _functions); } return output; diff --git a/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs b/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs index d127672d..7d7df869 100644 --- a/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs +++ b/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs @@ -179,6 +179,10 @@ private static void ValidateTransforms(IReadOnlyList errors) + { + bool inString = false; + for (int i = 0; i < expression.Length; i++) + { + if (expression[i] == '\'') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (!IsIdentifierStart(expression[i])) + continue; + + int nameStart = i; + i++; + while (i < expression.Length && IsIdentifierPart(expression[i])) + i++; + + int cursor = i; + while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor])) + cursor++; + + if (cursor >= expression.Length || expression[cursor] != '(') + continue; + + if (!TryValidateFunctionArguments(expression, cursor, out int closeParen)) + { + errors.Add(Error( + "pipeline.expression.function.syntax", + path, + $"Function call '{expression[nameStart..Math.Min(expression.Length, cursor + 1)]}...' is malformed.")); + return; + } + + i = closeParen; + } + } + + private static bool TryValidateFunctionArguments(string expression, int openParen, out int closeParen) + { + closeParen = -1; + int depth = 0; + bool inString = false; + bool expectingArgument = true; + bool sawArgument = false; + + for (int i = openParen; i < expression.Length; i++) + { + char ch = expression[i]; + if (ch == '\'') + { + inString = !inString; + expectingArgument = false; + sawArgument = true; + continue; + } + + if (inString) + continue; + + if (ch == '(') + { + depth++; + if (depth > 1) + { + expectingArgument = false; + sawArgument = true; + } + continue; + } + + if (ch == ')') + { + depth--; + if (depth < 0) + return false; + + if (depth == 0) + { + closeParen = i; + return !inString && (!expectingArgument || !sawArgument); + } + + continue; + } + + if (ch == ',' && depth == 1) + { + if (expectingArgument) + return false; + + expectingArgument = true; + continue; + } + + if (!char.IsWhiteSpace(ch)) + { + expectingArgument = false; + sawArgument = true; + } + } + + return false; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + + private static bool IsIdentifierPart(char value) + => char.IsLetterOrDigit(value) || value == '_'; + private static PipelineValidationIssue Error(string code, string path, string message) => new() { Code = code, diff --git a/src/CSharpDB.Primitives/DbFunctions.cs b/src/CSharpDB.Primitives/DbFunctions.cs new file mode 100644 index 00000000..f937932d --- /dev/null +++ b/src/CSharpDB.Primitives/DbFunctions.cs @@ -0,0 +1,180 @@ +namespace CSharpDB.Primitives; + +public delegate DbValue DbScalarFunctionDelegate( + DbScalarFunctionContext context, + ReadOnlySpan arguments); + +public sealed record DbScalarFunctionContext(string FunctionName); + +public sealed record DbScalarFunctionOptions( + DbType? ReturnType = null, + bool IsDeterministic = false, + bool NullPropagating = false); + +public sealed class DbScalarFunctionDefinition +{ + private readonly DbScalarFunctionDelegate _invoke; + + internal DbScalarFunctionDefinition( + string name, + int arity, + DbScalarFunctionOptions options, + DbScalarFunctionDelegate invoke) + { + Name = name; + Arity = arity; + Options = options; + _invoke = invoke; + } + + public string Name { get; } + + public int Arity { get; } + + public DbScalarFunctionOptions Options { get; } + + public DbValue Invoke(ReadOnlySpan arguments) + => _invoke(new DbScalarFunctionContext(Name), arguments); +} + +public sealed class DbFunctionRegistry +{ + private readonly Dictionary> _scalarFunctions; + private readonly DbScalarFunctionDefinition[] _scalarFunctionList; + + public static DbFunctionRegistry Empty { get; } = new(); + + private DbFunctionRegistry() + { + _scalarFunctions = new Dictionary>(StringComparer.OrdinalIgnoreCase); + _scalarFunctionList = []; + } + + internal DbFunctionRegistry(Dictionary> scalarFunctions) + { + _scalarFunctions = scalarFunctions; + _scalarFunctionList = scalarFunctions.Values + .SelectMany(static byArity => byArity.Values) + .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static definition => definition.Arity) + .ToArray(); + } + + public IReadOnlyCollection ScalarFunctions => _scalarFunctionList; + + public static DbFunctionRegistry Create(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var builder = new DbFunctionRegistryBuilder(); + configure(builder); + return builder.Build(); + } + + public bool TryGetScalar(string name, int arity, out DbScalarFunctionDefinition definition) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + if (_scalarFunctions.TryGetValue(name, out var byArity) && + byArity.TryGetValue(arity, out definition!)) + { + return true; + } + + definition = null!; + return false; + } + + public bool ContainsScalarName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _scalarFunctions.ContainsKey(name); + } +} + +public sealed class DbFunctionRegistryBuilder +{ + private static readonly HashSet s_reservedFunctionNames = new(StringComparer.OrdinalIgnoreCase) + { + "TEXT", + "COUNT", + "SUM", + "AVG", + "MIN", + "MAX", + }; + + private readonly Dictionary> _scalarFunctions = + new(StringComparer.OrdinalIgnoreCase); + + public DbFunctionRegistryBuilder AddScalar( + string name, + int arity, + DbScalarFunctionOptions? options, + DbScalarFunctionDelegate invoke) + { + string normalizedName = ValidateFunctionName(name); + ArgumentOutOfRangeException.ThrowIfNegative(arity); + ArgumentNullException.ThrowIfNull(invoke); + + if (s_reservedFunctionNames.Contains(normalizedName)) + throw new ArgumentException($"Function name '{name}' is reserved and cannot be overridden.", nameof(name)); + + if (_scalarFunctions.ContainsKey(normalizedName)) + throw new ArgumentException($"Scalar function '{name}' is already registered.", nameof(name)); + + var byArity = new Dictionary(); + _scalarFunctions.Add(normalizedName, byArity); + + byArity.Add( + arity, + new DbScalarFunctionDefinition( + normalizedName, + arity, + options ?? new DbScalarFunctionOptions(), + invoke)); + return this; + } + + public DbFunctionRegistryBuilder AddScalar( + string name, + int arity, + DbScalarFunctionDelegate invoke) + => AddScalar(name, arity, options: null, invoke); + + public DbFunctionRegistry Build() + { + if (_scalarFunctions.Count == 0) + return DbFunctionRegistry.Empty; + + var copy = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach ((string name, Dictionary byArity) in _scalarFunctions) + copy[name] = new Dictionary(byArity); + + return new DbFunctionRegistry(copy); + } + + internal static bool IsReservedFunctionName(string name) + => s_reservedFunctionNames.Contains(name); + + private static string ValidateFunctionName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + string trimmed = name.Trim(); + if (!IsIdentifierStart(trimmed[0])) + throw new ArgumentException($"Function name '{name}' is not a valid SQL identifier.", nameof(name)); + + for (int i = 1; i < trimmed.Length; i++) + { + char ch = trimmed[i]; + if (!char.IsLetterOrDigit(ch) && ch != '_') + throw new ArgumentException($"Function name '{name}' is not a valid SQL identifier.", nameof(name)); + } + + return trimmed; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Evaluation/FormulaEvaluatorTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Evaluation/FormulaEvaluatorTests.cs index 0f5fcdc3..5a75929f 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Evaluation/FormulaEvaluatorTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Evaluation/FormulaEvaluatorTests.cs @@ -1,4 +1,5 @@ using CSharpDB.Admin.Forms.Evaluation; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Tests.Evaluation; @@ -78,6 +79,25 @@ public void FieldReference() Assert.Equal(50.0, result); } + [Fact] + public void RegisteredScalarFunction() + { + var registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "Markup", + 2, + new DbScalarFunctionOptions(DbType.Real, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromReal(args[0].AsReal * args[1].AsReal))); + + var result = FormulaEvaluator.Evaluate("=Markup(Price, 1.25)", field => field switch + { + "Price" => 10.0, + _ => null, + }, registry); + + Assert.Equal(12.5, result); + } + [Fact] public void ComplexExpression_WithFields() { diff --git a/tests/CSharpDB.Admin.Reports.Tests/Services/ReportFormulaEvaluatorFunctionTests.cs b/tests/CSharpDB.Admin.Reports.Tests/Services/ReportFormulaEvaluatorFunctionTests.cs new file mode 100644 index 00000000..dfd73870 --- /dev/null +++ b/tests/CSharpDB.Admin.Reports.Tests/Services/ReportFormulaEvaluatorFunctionTests.cs @@ -0,0 +1,46 @@ +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Reports.Tests.Services; + +public sealed class ReportFormulaEvaluatorFunctionTests +{ + [Fact] + public void EvaluateNumeric_CallsRegisteredFunction() + { + var registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "Discount", + 2, + new DbScalarFunctionOptions(DbType.Real, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromReal(args[0].AsReal - args[1].AsReal))); + + double? value = ReportFormulaEvaluator.EvaluateNumeric("=Discount(Total, 2)", field => field switch + { + "Total" => 10.0, + _ => null, + }, registry); + + Assert.Equal(8.0, value); + } + + [Fact] + public void TryEvaluateScalar_ReturnsTextFunctionValue() + { + var registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "Labelize", + 1, + new DbScalarFunctionOptions(DbType.Text, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromText($"Item: {args[0].AsText}"))); + + bool evaluated = ReportFormulaEvaluator.TryEvaluateScalar( + "=Labelize(Name)", + field => field == "Name" ? "Widget" : null, + registry, + out object? value); + + Assert.True(evaluated); + Assert.Equal("Item: Widget", value); + } +} diff --git a/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs index 4bd82f8b..14916814 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs @@ -156,6 +156,39 @@ public void Validate_ReturnsError_WhenIncrementalWatermarkIsMissing() Assert.Contains(result.Errors, e => e.Code == "pipeline.incremental.watermark.required"); } + [Fact] + public void Validate_ReturnsError_WhenFunctionSyntaxIsMalformed() + { + var validPackage = CreateValidPackage(); + var package = new PipelinePackageDefinition + { + Name = validPackage.Name, + Version = validPackage.Version, + Source = validPackage.Source, + Destination = validPackage.Destination, + Options = validPackage.Options, + Transforms = + [ + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn + { + Name = "slug", + Expression = "Slugify(name", + }, + ], + }, + ], + }; + + PipelineValidationResult result = PipelinePackageValidator.Validate(package); + + Assert.Contains(result.Errors, e => e.Code == "pipeline.expression.function.syntax"); + } + [Fact] public void Validate_ReturnsMultipleErrors_ForCompoundInvalidPackage() { diff --git a/tests/CSharpDB.Pipelines.Tests/TrustedScalarFunctionPipelineTests.cs b/tests/CSharpDB.Pipelines.Tests/TrustedScalarFunctionPipelineTests.cs new file mode 100644 index 00000000..102d5add --- /dev/null +++ b/tests/CSharpDB.Pipelines.Tests/TrustedScalarFunctionPipelineTests.cs @@ -0,0 +1,109 @@ +using CSharpDB.Pipelines.Models; +using CSharpDB.Pipelines.Runtime.BuiltIns; +using CSharpDB.Primitives; + +namespace CSharpDB.Pipelines.Tests; + +public sealed class TrustedScalarFunctionPipelineTests +{ + [Fact] + public async Task FilterAndDeriveTransforms_InvokeRegisteredFunctions() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var registry = DbFunctionRegistry.Create(functions => + { + functions.AddScalar( + "Slugify", + 1, + new DbScalarFunctionOptions(DbType.Text, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromText(args[0].AsText.ToLowerInvariant().Replace(' ', '-'))); + functions.AddScalar( + "StartsWithA", + 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsText.StartsWith("A", StringComparison.OrdinalIgnoreCase) ? 1 : 0)); + }); + + var filter = new FilterPipelineTransform(new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Filter, + FilterExpression = "StartsWithA(name) == 1", + }, registry); + var derive = new DerivePipelineTransform(new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn { Name = "slug", Expression = "Slugify(name)" }, + ], + }, registry); + var context = CreateContext(); + var batch = new PipelineRowBatch + { + BatchNumber = 1, + StartingRowNumber = 1, + Rows = + [ + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["name"] = "Alice Smith", + }, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["name"] = "Bob Smith", + }, + ], + }; + + PipelineRowBatch filtered = await filter.TransformAsync(batch, context, ct); + PipelineRowBatch derived = await derive.TransformAsync(filtered, context, ct); + + Assert.Single(derived.Rows); + Assert.Equal("alice-smith", derived.Rows[0]["slug"]); + } + + [Fact] + public async Task MissingRegisteredFunction_FailsAtRuntime() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var transform = new DerivePipelineTransform(new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn { Name = "slug", Expression = "MissingFunction(name)" }, + ], + }); + var batch = new PipelineRowBatch + { + BatchNumber = 1, + StartingRowNumber = 1, + Rows = + [ + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["name"] = "Alice", + }, + ], + }; + + var ex = await Assert.ThrowsAsync(async () => + await transform.TransformAsync(batch, CreateContext(), ct)); + + Assert.Contains("Unknown scalar function", ex.Message); + } + + private static PipelineExecutionContext CreateContext() => new() + { + RunId = "functions-test", + Mode = PipelineExecutionMode.DryRun, + Package = new PipelinePackageDefinition + { + Name = "functions", + Version = "1.0", + Source = new PipelineSourceDefinition { Kind = PipelineSourceKind.JsonFile, Path = "input.json" }, + Destination = new PipelineDestinationDefinition { Kind = PipelineDestinationKind.JsonFile, Path = "output.json" }, + Options = new PipelineExecutionOptions(), + }, + }; +} diff --git a/tests/CSharpDB.Tests/ClientDirectDatabaseOptionsTests.cs b/tests/CSharpDB.Tests/ClientDirectDatabaseOptionsTests.cs index 534bfe3d..14db89f6 100644 --- a/tests/CSharpDB.Tests/ClientDirectDatabaseOptionsTests.cs +++ b/tests/CSharpDB.Tests/ClientDirectDatabaseOptionsTests.cs @@ -3,6 +3,9 @@ using CSharpDB.Client.Models; using CSharpDB.Engine; using CSharpDB.Storage.Paging; +using PrimitiveDbType = CSharpDB.Primitives.DbType; +using PrimitiveDbValue = CSharpDB.Primitives.DbValue; +using PrimitiveScalarFunctionOptions = CSharpDB.Primitives.DbScalarFunctionOptions; namespace CSharpDB.Tests; @@ -54,6 +57,51 @@ public async Task DirectDatabaseOptions_AreUsedOnFirstOpen() } } + [Fact] + public async Task DirectDatabaseOptions_FunctionsAreUsedByDirectClient() + { + var ct = TestContext.Current.CancellationToken; + string dbPath = NewTempDbPath(); + + try + { + await using var client = Assert.IsType(CSharpDbClient.Create(new CSharpDbClientOptions + { + DataSource = dbPath, + DirectDatabaseOptions = new DatabaseOptions().ConfigureFunctions(functions => + functions.AddScalar( + "AddOne", + 1, + new PrimitiveScalarFunctionOptions(PrimitiveDbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => PrimitiveDbValue.FromInteger(args[0].AsInteger + 1))), + })); + + Assert.Null((await client.ExecuteSqlAsync("CREATE TABLE numbers (value INTEGER);", ct)).Error); + Assert.Null((await client.ExecuteSqlAsync("INSERT INTO numbers VALUES (41);", ct)).Error); + + var result = await client.ExecuteSqlAsync("SELECT AddOne(value) FROM numbers;", ct); + + Assert.Null(result.Error); + Assert.NotNull(result.Rows); + Assert.Equal(42L, result.Rows![0][0]); + + await client.CreateProcedureAsync(new ProcedureDefinition + { + Name = "select_add_one", + BodySql = "SELECT AddOne(value) FROM numbers;", + }, ct); + + var procedureResult = await client.ExecuteProcedureAsync("select_add_one", new Dictionary(), ct); + Assert.True(procedureResult.Succeeded, procedureResult.Error); + Assert.Equal(42L, procedureResult.Statements[0].Rows![0][0]); + } + finally + { + DeleteIfExists(dbPath); + DeleteIfExists(dbPath + ".wal"); + } + } + [Fact] public async Task DirectDatabaseOptions_AreUsedAfterReleaseCachedDatabaseAsync() { diff --git a/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs b/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs new file mode 100644 index 00000000..2fe555d5 --- /dev/null +++ b/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs @@ -0,0 +1,142 @@ +using CSharpDB.Engine; +using CSharpDB.Execution; +using CSharpDB.Primitives; +using CSharpDB.Sql; + +namespace CSharpDB.Tests; + +public sealed class TrustedScalarFunctionTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public void Registry_ValidatesNamesCollisionsAndMetadata() + { + var registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "Bump", + 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1))); + + Assert.True(registry.TryGetScalar("bump", 1, out var definition)); + Assert.Equal(DbType.Integer, definition.Options.ReturnType); + Assert.True(definition.Options.IsDeterministic); + Assert.True(definition.Options.NullPropagating); + + Assert.Throws(() => DbFunctionRegistry.Create(functions => + { + functions.AddScalar("Dup", 1, static (_, _) => DbValue.Null); + functions.AddScalar("dup", 2, static (_, _) => DbValue.Null); + })); + + Assert.Throws(() => DbFunctionRegistry.Create(functions => + functions.AddScalar("TEXT", 1, static (_, _) => DbValue.Null))); + } + + [Fact] + public void ExpressionCompiler_InvokesRegisteredFunctionAndPropagatesNull() + { + var registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "DoubleIt", + 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsInteger * 2))); + + var schema = new TableSchema + { + TableName = "numbers", + Columns = + [ + new ColumnDefinition { Name = "value", Type = DbType.Integer, Nullable = true }, + ], + }; + + var expression = new FunctionCallExpression + { + FunctionName = "DoubleIt", + Arguments = [new ColumnRefExpression { ColumnName = "value" }], + }; + + var evaluator = ExpressionCompiler.CompileSpan(expression, schema, registry); + + Assert.Equal(DbValue.FromInteger(14), evaluator([DbValue.FromInteger(7)])); + Assert.Equal(DbValue.Null, evaluator([DbValue.Null])); + } + + [Fact] + public async Task Sql_UsesRegisteredFunctionsAcrossReadWriteAndTriggerPaths() + { + var options = CreateOptions(); + await using var db = await Database.OpenInMemoryAsync(options, Ct); + + await db.ExecuteAsync("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, slug TEXT)", Ct); + await db.ExecuteAsync("CREATE TABLE audit (slug TEXT)", Ct); + await db.ExecuteAsync(""" + CREATE TRIGGER items_ai AFTER INSERT ON items + BEGIN + INSERT INTO audit VALUES (Slugify(NEW.name)); + END + """, Ct); + + await db.ExecuteAsync("INSERT INTO items VALUES (1, 'Hello World', Slugify('Hello World'))", Ct); + await db.ExecuteAsync("INSERT INTO items VALUES (2, 'Odd Name', Slugify('Odd Name'))", Ct); + await db.ExecuteAsync("UPDATE items SET slug = Slugify(name) WHERE IsEven(id) = 1", Ct); + + await using (var result = await db.ExecuteAsync( + "SELECT Slugify(name) FROM items WHERE IsEven(id) = 1 ORDER BY Slugify(name) DESC", + Ct)) + { + var rows = await result.ToListAsync(Ct); + Assert.Single(rows); + Assert.Equal("odd-name", rows[0][0].AsText); + } + + await using (var triggerResult = await db.ExecuteAsync("SELECT slug FROM audit ORDER BY slug", Ct)) + { + var rows = await triggerResult.ToListAsync(Ct); + Assert.Equal(["hello-world", "odd-name"], rows.Select(row => row[0].AsText).ToArray()); + } + } + + [Fact] + public async Task Sql_MissingAndThrowingFunctionsFailAndStatementRollsBack() + { + var options = CreateOptions(); + await using var db = await Database.OpenInMemoryAsync(options, Ct); + + await db.ExecuteAsync("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)", Ct); + + var missing = await Assert.ThrowsAsync(async () => + await db.ExecuteAsync("INSERT INTO items VALUES (MissingFunc(1), 'bad')", Ct)); + Assert.Contains("Unknown scalar function", missing.Message); + + var thrown = await Assert.ThrowsAsync(async () => + await db.ExecuteAsync("INSERT INTO items VALUES (Boom(1), 'bad')", Ct)); + Assert.Contains("Scalar function 'Boom' failed", thrown.Message); + + await using var result = await db.ExecuteAsync("SELECT COUNT(*) FROM items", Ct); + var rows = await result.ToListAsync(Ct); + Assert.Equal(0, rows[0][0].AsInteger); + } + + private static DatabaseOptions CreateOptions() + => new DatabaseOptions().ConfigureFunctions(functions => + { + functions.AddScalar( + "Slugify", + 1, + new DbScalarFunctionOptions(DbType.Text, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromText(args[0].AsText.ToLowerInvariant().Replace(' ', '-'))); + functions.AddScalar( + "IsEven", + 1, + new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + static (_, args) => DbValue.FromInteger(args[0].AsInteger % 2 == 0 ? 1 : 0)); + functions.AddScalar( + "Boom", + 1, + static (_, _) => throw new InvalidOperationException("boom")); + }); +} From af61ecbf49794164439fa8dfad35d45dff15cb2d Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Mon, 27 Apr 2026 23:03:36 -0700 Subject: [PATCH 02/39] Prepare v3.6.0 release candidate --- RELEASE_NOTES.md | 105 +++++++++++++++++++++++++++++++++++++++++++++++ docs/roadmap.md | 4 +- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index dd327245..e0796599 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,110 @@ # What's New +## v3.6.0 + +v3.6.0 adds trusted, in-process C# scalar functions across CSharpDB's +user-facing expression surfaces. Host applications can now register C# +delegates when opening or hosting a database, then call those functions from +SQL, SQL-backed triggers and procedures, Admin Forms formulas, Admin Reports +calculated text, and pipeline filter/derive expressions. + +### Trusted C# Scalar Functions + +- Added the shared `DbFunctionRegistry`, `DbFunctionRegistryBuilder`, + `DbScalarFunctionDelegate`, and `DbScalarFunctionOptions` public model in + `CSharpDB.Primitives`. +- Added `DatabaseOptions.Functions` plus `ConfigureFunctions(...)` so embedded + hosts can register scalar functions when opening file-backed, in-memory, or + hybrid databases. +- SQL expression evaluation now resolves registered scalar functions in + projections, filters, ordering expressions, `INSERT`/`UPDATE` expressions, + trigger bodies, and stored SQL procedure bodies. +- Direct clients can pass trusted functions through `DirectDatabaseOptions`; + HTTP and gRPC clients still do not serialize delegates and can only call + functions registered inside the remote host process. +- Admin Forms formulas and Admin Reports calculated expressions can use the + same registry while preserving existing arithmetic and aggregate behavior. +- Pipeline filter and derived-column expressions can call registered functions; + package definitions continue to store function names and expressions only. +- Added the usage guide at `docs/trusted-csharp-functions/README.md`. + +### Behavior And Safety + +- Function names are case-insensitive SQL identifiers, and registration rejects + duplicate user names or collisions with reserved built-ins such as `TEXT`, + `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`. +- Arity is validated before invocation, missing SQL functions fail with the + existing unknown scalar function path, and thrown delegate exceptions are + wrapped with the function name before normal statement/transaction rollback. +- `NullPropagating = true` returns `NULL` without invoking the delegate when + any argument is `NULL`; otherwise `DbValue.Null` is passed explicitly. +- V1 remains scalar-only, synchronous, trusted, and in-process. It does not + persist C# source, sandbox code, load database-owned plugin assemblies, + marshal delegates over HTTP/gRPC, or add aggregate/table-valued/procedure + UDFs. +- Query planning keeps custom functions on the residual expression path in V1: + no index pushdown, generated columns, constant folding, or cost assumptions + are inferred from user functions. + +### Tests And Benchmarks + +- Added registry and SQL coverage for case-insensitive lookup, duplicate and + built-in collision rejection, null propagation, deterministic metadata, + missing functions, thrown functions, rollback behavior, triggers, and stored + SQL procedures. +- Added direct-client, Admin Forms, Admin Reports, pipeline validation, and + pipeline runtime tests for registered scalar functions. +- Same-machine affected benchmark comparison against the pre-feature HEAD + baseline showed no material regression in the main write/query guardrails: + +| Suite | Worst current change | Best current change | +|-------|---------------------:|--------------------:| +| Insert | `+3.76%` | `-3.38%` | +| Join | `+6.65%` | `-6.93%` | +| PointLookup | `+5.15%` | `-9.25%` | +| QueryPlanCache | `+1.62%` | `-4.45%` | +| ScanProjection | `+0.20%` | `-18.12%` | +| TriggerDispatch | `+0.77%` | `-4.52%` | +| BatchEvaluation | `+10.53%` | `-10.36%` | + +The one notable row was the synthetic BatchEvaluation delegate +filter/projection case at `+10.53%`; its paired specialized path improved by +`-10.36%`, allocations were unchanged, and the affected guardrail suites were +otherwise neutral to improved. + +### Validation + +- `git status --short --branch` +- `dotnet restore CSharpDB.slnx` +- `.\scripts\Test-NoLegacyCoreReferences.ps1` + - Passed through the script's PowerShell fallback after the local packaged + `rg.exe` could not be launched normally in this desktop environment. +- `dotnet build CSharpDB.slnx -c Release --no-restore` + - Passed with `0` warnings and `0` errors. +- `dotnet test CSharpDB.slnx -c Release --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Non-parallel unit test run passed with `1,663` tests. +- `dotnet pack` smoke for the release workflow packages with + `-p:Version=3.6.0` + - Produced `11` local packages: + `CSharpDB`, `CSharpDB.Client`, `CSharpDB.Data`, `CSharpDB.Engine`, + `CSharpDB.EntityFrameworkCore`, `CSharpDB.Execution`, + `CSharpDB.Pipelines`, `CSharpDB.Primitives`, `CSharpDB.Sql`, + `CSharpDB.Storage`, and `CSharpDB.Storage.Diagnostics`. +- `.\scripts\Publish-CSharpDbDaemonRelease.ps1 -Version 3.6.0 -Runtime win-x64 -OutputRoot artifacts\daemon-release-local` + - Produced `csharpdb-daemon-v3.6.0-win-x64.zip` and `SHA256SUMS.txt`. + +### Review Notes + +- The highest-risk runtime changes are in expression evaluation and planner + plumbing: custom functions are intentionally kept off the index-pushdown and + batch-fast-path planning assumptions in V1. +- Remote hosts must register functions in the daemon/API host process; direct + clients can register functions locally through `DirectDatabaseOptions`, but + callback delegates are never serialized over HTTP or gRPC. +- Admin Forms and Reports use the shared registry, but their formula surfaces + remain narrower than SQL: numeric formulas expect numeric returns, while + report calculated text supports scalar function expressions as rendered text. + ## v3.5.0 v3.5.0 focuses on the collection binary payload fast path, generated diff --git a/docs/roadmap.md b/docs/roadmap.md index 084b48a2..1a85333c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions** | Broader built-in scalar function registry (UPPER, ABS, COALESCE, etc.), user-registered C# functions, native plugin extensions | Planned | +| **User-defined functions** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -96,7 +96,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions** | Very limited scalar function surface today: built-in `TEXT(expr)` plus aggregate functions; no broader built-in function library or user-defined functions yet | +| **Functions** | Trusted in-process C# scalar functions are supported when registered by the host; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | From 307a349fe7f37afab961a9fa511d0b749959cfc7 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 06:42:20 -0700 Subject: [PATCH 03/39] Add trusted C# commands and form events --- RELEASE_NOTES.md | 22 ++ docs/roadmap.md | 4 +- docs/trusted-csharp-functions/README.md | 66 +++++- .../Contracts/FormEventDispatchResult.cs | 12 ++ .../Contracts/IFormEventDispatcher.cs | 12 ++ .../Models/FormDefinition.cs | 3 +- .../Models/FormEventBinding.cs | 19 ++ .../Pages/DataEntry.razor | 37 ++++ .../AdminFormsServiceCollectionExtensions.cs | 14 ++ .../Services/DefaultFormEventDispatcher.cs | 98 +++++++++ .../Services/NullFormEventDispatcher.cs | 20 ++ src/CSharpDB.Primitives/DbCommands.cs | 197 ++++++++++++++++++ .../Pages/DataEntryTests.cs | 154 +++++++++++++- .../Serialization/JsonRoundtripTests.cs | 21 +- .../DefaultFormEventDispatcherTests.cs | 130 ++++++++++++ .../TrustedCommandRegistryTests.cs | 74 +++++++ 16 files changed, 877 insertions(+), 6 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs create mode 100644 src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs create mode 100644 src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs create mode 100644 src/CSharpDB.Primitives/DbCommands.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs create mode 100644 tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e0796599..acf55698 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -28,6 +28,25 @@ calculated text, and pipeline filter/derive expressions. package definitions continue to store function names and expressions only. - Added the usage guide at `docs/trusted-csharp-functions/README.md`. +### Trusted Commands And Form Events + +- Added the shared `DbCommandRegistry`, `DbCommandRegistryBuilder`, + `DbCommandDelegate`, `DbCommandContext`, `DbCommandResult`, and + `DbCommandOptions` public model in `CSharpDB.Primitives`. +- Admin Forms can now store form-level event bindings that reference trusted + command names instead of storing C# source. +- The Forms data-entry runtime dispatches `OnOpen`, `OnLoad`, `BeforeInsert`, + `AfterInsert`, `BeforeUpdate`, `AfterUpdate`, `BeforeDelete`, and + `AfterDelete`. +- `BeforeInsert`, `BeforeUpdate`, and `BeforeDelete` can cancel the requested + write by returning `DbCommandResult.Failure(...)`; after-events report errors + without attempting to roll back a completed write. +- Command context arguments include current record fields converted to + `DbValue`; metadata includes the Forms surface, form id/name, table name, and + event name. +- `AddCSharpDbAdminForms(...)` now has a command-registration overload for + trusted host applications. + ### Behavior And Safety - Function names are case-insensitive SQL identifiers, and registration rejects @@ -54,6 +73,9 @@ calculated text, and pipeline filter/derive expressions. SQL procedures. - Added direct-client, Admin Forms, Admin Reports, pipeline validation, and pipeline runtime tests for registered scalar functions. +- Added command-registry, form-event dispatcher, event JSON round-trip, and + Forms data-entry tests for create/update/delete event dispatch and + before-event cancellation. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a85333c..10c90f21 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, and sandboxed UDFs remain future work | Partial | +| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back the first Admin Forms event bindings; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro actions, control events, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -96,7 +96,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions** | Trusted in-process C# scalar functions are supported when registered by the host; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, and sandboxed UDFs are not implemented | +| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host, and Admin Forms can invoke trusted host commands from initial form-level events; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, control-level form events, macro actions, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 45c8102f..b6d16bba 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -6,6 +6,39 @@ This feature is intentionally trusted and in-process. It does not store C# sourc --- +## Trusted Commands + +CSharpDB also supports trusted host-registered commands for application automation surfaces. Commands are different from scalar functions: + +- Scalar functions return a `DbValue` and can be used inside SQL or formulas. +- Commands return a `DbCommandResult` and are invoked by host-driven events such as Admin Forms lifecycle events. + +Commands are intended for Access-style application automation such as auditing, calling application services, sending notifications, refreshing derived state, or coordinating UI workflows. They are trusted in-process callbacks registered by the host application. + +```csharp +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminForms(commands => +{ + commands.AddCommand( + "AuditCustomerChange", + new DbCommandOptions("Writes an application audit entry."), + static async (context, ct) => + { + long customerId = context.Arguments["Id"].AsInteger; + string eventName = context.Metadata["event"]; + + await WriteAuditAsync(customerId, eventName, ct); + return DbCommandResult.Success(); + }); +}); +``` + +Command names are case-insensitive identifiers. Duplicate command names are rejected during registration. + +--- + ## What You Can Register V1 supports synchronous scalar functions: @@ -268,6 +301,35 @@ double? tax = FormulaEvaluator.Evaluate( Forms formulas are numeric formulas. A custom function used from `FormulaEvaluator.Evaluate` should return `INTEGER` or `REAL`; other return types evaluate to `null` in that surface. Existing aggregate formulas such as `=SUM(OrderItems.LineTotal)` remain built-in form behavior and are not replaced by custom scalar functions. +Admin Forms can also bind lifecycle events to trusted commands. Form definitions store event names and command names only; the C# command bodies stay registered in the host process. + +```csharp +var form = existingForm with +{ + EventBindings = + [ + new FormEventBinding(FormEventKind.OnOpen, "AuditFormOpen"), + new FormEventBinding(FormEventKind.BeforeInsert, "ValidateCustomerCreate"), + new FormEventBinding(FormEventKind.AfterUpdate, "AuditCustomerChange"), + ], +}; +``` + +Supported form-level events in this slice are: + +| Event | When it runs | +| --- | --- | +| `OnOpen` | After the form definition and source table are resolved, before records load. | +| `OnLoad` | After the initial record page loads. | +| `BeforeInsert` | Before a new record is inserted. Returning `DbCommandResult.Failure(...)` cancels the insert. | +| `AfterInsert` | After a new record is inserted. | +| `BeforeUpdate` | Before an existing record is updated. Returning failure cancels the update. | +| `AfterUpdate` | After an existing record is updated. | +| `BeforeDelete` | Before the current record is deleted. Returning failure cancels the delete. | +| `AfterDelete` | After the current record is deleted. | + +Command context arguments include the current record fields converted to `DbValue`. Static arguments configured on the event binding override same-named record fields. Metadata includes `surface`, `formId`, `formName`, `tableName`, and `event`. + --- ## Admin Reports @@ -411,7 +473,9 @@ V1 does not support: - Table-valued UDFs. - Stored C# source code or database-owned compiled modules. - Sandboxed execution. -- Async delegates. +- Async scalar delegates. - Passing a database handle into the function context. - Sending delegates over HTTP, gRPC, or pipeline package files. - Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. +- Control-level form events such as button `OnClick`. +- Stored macro/action scripts. diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs b/src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs new file mode 100644 index 00000000..2279994d --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormEventDispatchResult.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormEventDispatchResult(bool Succeeded, string? Message = null) +{ + public static FormEventDispatchResult Success(string? message = null) => new(true, message); + + public static FormEventDispatchResult Failure(string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return new(false, message); + } +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs new file mode 100644 index 00000000..7a03829a --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormEventDispatcher.cs @@ -0,0 +1,12 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Contracts; + +public interface IFormEventDispatcher +{ + Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record = null, + CancellationToken ct = default); +} diff --git a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs index c823c971..7c2c0305 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs @@ -8,4 +8,5 @@ public sealed record FormDefinition( string SourceSchemaSignature, LayoutDefinition Layout, IReadOnlyList Controls, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null); diff --git a/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs b/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs new file mode 100644 index 00000000..7e26f6fe --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs @@ -0,0 +1,19 @@ +namespace CSharpDB.Admin.Forms.Models; + +public enum FormEventKind +{ + OnOpen, + OnLoad, + BeforeInsert, + AfterInsert, + BeforeUpdate, + AfterUpdate, + BeforeDelete, + AfterDelete, +} + +public sealed record FormEventBinding( + FormEventKind Event, + string CommandName, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true); diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index a3b12b07..85aa2f7f 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -197,6 +197,7 @@ [Parameter] public bool ShowDesignerButton { get; set; } = true; [Parameter] public string? BackHref { get; set; } [Parameter] public string BackLabel { get; set; } = "Forms"; + [Inject] public IFormEventDispatcher FormEvents { get; set; } = NullFormEventDispatcher.Instance; private FormDefinition? _form; private FormTableDefinition? _table; @@ -322,9 +323,14 @@ await LoadChildTableDefinitionsAsync(_form); CacheComputedControls(_form); + if (!await DispatchFormEventAsync(FormEventKind.OnOpen)) + return; + InitializeSearchState(); _page = 1; await LoadRecordPageAsync(_page); + if (_error is null) + await DispatchFormEventAsync(FormEventKind.OnLoad, _currentRecord); } catch (Exception ex) { @@ -451,6 +457,9 @@ if (_isNew) { + if (!await DispatchFormEventAsync(FormEventKind.BeforeInsert, recordToSave)) + return; + Dictionary created = await RecordService.CreateRecordAsync(_table, recordToSave); _currentRecord = CloneRecord(created); _isNew = false; @@ -463,11 +472,17 @@ { await LoadRecordPageAsync(_page); } + + await DispatchFormEventAsync(FormEventKind.AfterInsert, created); } else if (TryGetPrimaryKeyValue(_currentRecord, out object? pkValue)) { + if (!await DispatchFormEventAsync(FormEventKind.BeforeUpdate, _currentRecord)) + return; + Dictionary updated = await RecordService.UpdateRecordAsync(_table, pkValue!, recordToSave); UpdateVisibleCurrentRecord(updated); + await DispatchFormEventAsync(FormEventKind.AfterUpdate, updated); } _dirty = false; @@ -501,9 +516,14 @@ try { + var deletedRecord = CloneRecord(_currentRecord); + if (!await DispatchFormEventAsync(FormEventKind.BeforeDelete, deletedRecord)) + return; + int preferredPageIndex = _recordPageIndex; await RecordService.DeleteRecordAsync(_table, pkValue!); await LoadRecordPageAsync(_page, preferredPageIndex: preferredPageIndex); + await DispatchFormEventAsync(FormEventKind.AfterDelete, deletedRecord); } catch (Exception ex) { @@ -605,6 +625,23 @@ private Task PrintRecord() => JS.InvokeVoidAsync("window.print").AsTask(); + private async Task DispatchFormEventAsync( + FormEventKind eventKind, + IReadOnlyDictionary? record = null) + { + if (_form is null) + return true; + + FormEventDispatchResult result = await FormEvents.DispatchAsync(_form, eventKind, record); + if (result.Succeeded) + return true; + + _error = string.IsNullOrWhiteSpace(result.Message) + ? $"Form event '{eventKind}' failed." + : result.Message; + return false; + } + private void OnGoToRecordInput(ChangeEventArgs e) => _goToRecordValue = e.Value?.ToString() ?? string.Empty; private void OnSearchColumnChanged(ChangeEventArgs e) => _searchColumnName = e.Value?.ToString() ?? string.Empty; diff --git a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs index 29a46c75..69b9b5e0 100644 --- a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Primitives; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CSharpDB.Admin.Forms.Services; @@ -7,11 +9,23 @@ public static class AdminFormsServiceCollectionExtensions { public static IServiceCollection AddCSharpDbAdminForms(this IServiceCollection services) { + services.TryAddSingleton(DbCommandRegistry.Empty); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); return services; } + + public static IServiceCollection AddCSharpDbAdminForms( + this IServiceCollection services, + Action configureCommands) + { + ArgumentNullException.ThrowIfNull(configureCommands); + + services.AddSingleton(DbCommandRegistry.Create(configureCommands)); + return services.AddCSharpDbAdminForms(); + } } diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs new file mode 100644 index 00000000..e5758620 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class DefaultFormEventDispatcher(DbCommandRegistry commands) : IFormEventDispatcher +{ + public async Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(form); + + IReadOnlyList bindings = form.EventBindings ?? []; + foreach (FormEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) + { + if (string.IsNullOrWhiteSpace(binding.CommandName)) + return FormEventDispatchResult.Failure($"Form event '{eventKind}' has an empty command name."); + + if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + return FormEventDispatchResult.Failure($"Unknown form command '{binding.CommandName}' for event '{eventKind}'."); + + Dictionary arguments = BuildArguments(record, binding.Arguments); + Dictionary metadata = new(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["formId"] = form.FormId, + ["formName"] = form.Name, + ["tableName"] = form.TableName, + ["event"] = eventKind.ToString(), + }; + + DbCommandResult result; + try + { + result = await definition.InvokeAsync(arguments, metadata, ct); + } + catch (Exception ex) + { + return FormEventDispatchResult.Failure( + $"Form event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"); + } + + if (!result.Succeeded && binding.StopOnFailure) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Form event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + return FormEventDispatchResult.Failure(message); + } + } + + return FormEventDispatchResult.Success(); + } + + private static Dictionary BuildArguments( + IReadOnlyDictionary? record, + IReadOnlyDictionary? configuredArguments) + { + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (record is not null) + { + foreach ((string key, object? value) in record) + arguments[key] = ToDbValue(value); + } + + if (configuredArguments is not null) + { + foreach ((string key, object? value) in configuredArguments) + { + if (!string.IsNullOrWhiteSpace(key)) + arguments[key] = ToDbValue(value); + } + } + + return arguments; + } + + private static DbValue ToDbValue(object? value) => value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolValue => DbValue.FromInteger(boolValue ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + Guid guid => DbValue.FromText(guid.ToString("D")), + DateOnly date => DbValue.FromText(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + DateTime dateTime => DbValue.FromText(dateTime.ToString("O", CultureInfo.InvariantCulture)), + byte[] bytes => DbValue.FromBlob(bytes), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs new file mode 100644 index 00000000..6c413c2a --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/NullFormEventDispatcher.cs @@ -0,0 +1,20 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class NullFormEventDispatcher : IFormEventDispatcher +{ + public static NullFormEventDispatcher Instance { get; } = new(); + + private NullFormEventDispatcher() + { + } + + public Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record = null, + CancellationToken ct = default) + => Task.FromResult(FormEventDispatchResult.Success()); +} diff --git a/src/CSharpDB.Primitives/DbCommands.cs b/src/CSharpDB.Primitives/DbCommands.cs new file mode 100644 index 00000000..ffa4af1f --- /dev/null +++ b/src/CSharpDB.Primitives/DbCommands.cs @@ -0,0 +1,197 @@ +namespace CSharpDB.Primitives; + +public delegate ValueTask DbCommandDelegate( + DbCommandContext context, + CancellationToken ct); + +public sealed record DbCommandContext( + string CommandName, + IReadOnlyDictionary Arguments, + IReadOnlyDictionary Metadata); + +public sealed record DbCommandOptions(string? Description = null); + +public sealed record DbCommandResult( + bool Succeeded, + string? Message = null, + DbValue Value = default) +{ + public static DbCommandResult Success(string? message = null, DbValue value = default) + => new(true, message, value); + + public static DbCommandResult Failure(string message, DbValue value = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return new(false, message, value); + } +} + +public sealed class DbCommandDefinition +{ + private readonly DbCommandDelegate _invoke; + + internal DbCommandDefinition( + string name, + DbCommandOptions options, + DbCommandDelegate invoke) + { + Name = name; + Options = options; + _invoke = invoke; + } + + public string Name { get; } + + public DbCommandOptions Options { get; } + + public ValueTask InvokeAsync( + IReadOnlyDictionary? arguments = null, + IReadOnlyDictionary? metadata = null, + CancellationToken ct = default) + { + var context = new DbCommandContext( + Name, + arguments ?? EmptyDbValueDictionary.Instance, + metadata ?? EmptyStringDictionary.Instance); + return _invoke(context, ct); + } + + private static class EmptyDbValueDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static class EmptyStringDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} + +public sealed class DbCommandRegistry +{ + private readonly Dictionary _commands; + private readonly DbCommandDefinition[] _commandList; + + public static DbCommandRegistry Empty { get; } = new(); + + private DbCommandRegistry() + { + _commands = new Dictionary(StringComparer.OrdinalIgnoreCase); + _commandList = []; + } + + internal DbCommandRegistry(Dictionary commands) + { + _commands = commands; + _commandList = commands.Values + .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public IReadOnlyCollection Commands => _commandList; + + public static DbCommandRegistry Create(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var builder = new DbCommandRegistryBuilder(); + configure(builder); + return builder.Build(); + } + + public bool TryGetCommand(string name, out DbCommandDefinition definition) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + if (_commands.TryGetValue(name, out definition!)) + return true; + + definition = null!; + return false; + } + + public bool ContainsCommandName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _commands.ContainsKey(name); + } +} + +public sealed class DbCommandRegistryBuilder +{ + private readonly Dictionary _commands = + new(StringComparer.OrdinalIgnoreCase); + + public DbCommandRegistryBuilder AddCommand( + string name, + DbCommandOptions? options, + DbCommandDelegate invoke) + { + string normalizedName = ValidateCommandName(name); + ArgumentNullException.ThrowIfNull(invoke); + + if (_commands.ContainsKey(normalizedName)) + throw new ArgumentException($"Command '{name}' is already registered.", nameof(name)); + + _commands.Add( + normalizedName, + new DbCommandDefinition( + normalizedName, + options ?? new DbCommandOptions(), + invoke)); + return this; + } + + public DbCommandRegistryBuilder AddCommand( + string name, + DbCommandDelegate invoke) + => AddCommand(name, options: null, invoke); + + public DbCommandRegistryBuilder AddCommand( + string name, + DbCommandOptions? options, + Func invoke) + { + ArgumentNullException.ThrowIfNull(invoke); + return AddCommand( + name, + options, + (context, _) => ValueTask.FromResult(invoke(context))); + } + + public DbCommandRegistryBuilder AddCommand( + string name, + Func invoke) + => AddCommand(name, options: null, invoke); + + public DbCommandRegistry Build() + { + if (_commands.Count == 0) + return DbCommandRegistry.Empty; + + return new DbCommandRegistry(new Dictionary(_commands, StringComparer.OrdinalIgnoreCase)); + } + + private static string ValidateCommandName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + string trimmed = name.Trim(); + if (!IsIdentifierStart(trimmed[0])) + throw new ArgumentException($"Command name '{name}' is not a valid identifier.", nameof(name)); + + for (int i = 1; i < trimmed.Length; i++) + { + char ch = trimmed[i]; + if (!char.IsLetterOrDigit(ch) && ch != '_') + throw new ArgumentException($"Command name '{name}' is not a valid identifier.", nameof(name)); + } + + return trimmed; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs index 9dc3c609..1c745241 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs @@ -3,6 +3,7 @@ using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Pages; using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; using Microsoft.JSInterop; namespace CSharpDB.Admin.Forms.Tests.Pages; @@ -114,6 +115,155 @@ Name TEXT NOT NULL Assert.Equal(1, recordService.GetRecordWindowCalls); } + [Fact] + public async Task SaveRecord_CreateDispatchesFormEvents() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + """); + + var calls = new List(); + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("CaptureRecord", context => + { + calls.Add($"{context.Metadata["event"]}:{context.Arguments["Name"].AsText}"); + Assert.Equal("products-form", context.Metadata["formId"]); + Assert.Equal("Products", context.Metadata["tableName"]); + return DbCommandResult.Success(); + }); + }); + + FormDefinition form = CreateForm("products-form", "Products") with + { + EventBindings = + [ + new FormEventBinding(FormEventKind.BeforeInsert, "CaptureRecord"), + new FormEventBinding(FormEventKind.AfterInsert, "CaptureRecord"), + ], + }; + + DataEntry component = await CreateComponentAsync( + form, + new DbSchemaProvider(db.Client), + new DbFormRecordService(db.Client), + new DefaultFormEventDispatcher(commands)); + + InvokeNonPublic(component, "NewRecord"); + Dictionary current = ReadCurrentRecord(component); + current["Id"] = 1001L; + current["Name"] = "Created"; + SetField(component, "_dirty", true); + + await InvokeNonPublicAsync(component, "SaveRecord"); + + Assert.Equal(["BeforeInsert:Created", "AfterInsert:Created"], calls); + Assert.Null(GetField(component, "_error")); + Assert.Equal("Created", ReadCurrentRecord(component)["Name"]); + } + + [Fact] + public async Task SaveRecord_BeforeInsertFailureCancelsCreate() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + """); + + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + builder.AddCommand("RejectCreate", static _ => DbCommandResult.Failure("Create blocked by command."))); + + FormDefinition form = CreateForm("products-form", "Products") with + { + EventBindings = [new FormEventBinding(FormEventKind.BeforeInsert, "RejectCreate")], + }; + + DataEntry component = await CreateComponentAsync( + form, + new DbSchemaProvider(db.Client), + new DbFormRecordService(db.Client), + new DefaultFormEventDispatcher(commands)); + + InvokeNonPublic(component, "NewRecord"); + Dictionary current = ReadCurrentRecord(component); + current["Id"] = 1001L; + current["Name"] = "Created"; + SetField(component, "_dirty", true); + + await InvokeNonPublicAsync(component, "SaveRecord"); + + Assert.Equal("Create blocked by command.", GetField(component, "_error")); + Assert.Empty(await db.QueryRowsAsync("SELECT * FROM Products")); + Assert.True(GetField(component, "_isNew")); + } + + [Fact] + public async Task SaveAndDeleteRecord_DispatchUpdateAndDeleteEvents() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + INSERT INTO Products VALUES (1, 'Widget'); + """); + + var calls = new List(); + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("CaptureRecord", context => + { + calls.Add($"{context.Metadata["event"]}:{context.Arguments["Name"].AsText}"); + return DbCommandResult.Success(); + }); + }); + + FormDefinition form = CreateForm("products-form", "Products") with + { + EventBindings = + [ + new FormEventBinding(FormEventKind.BeforeUpdate, "CaptureRecord"), + new FormEventBinding(FormEventKind.AfterUpdate, "CaptureRecord"), + new FormEventBinding(FormEventKind.BeforeDelete, "CaptureRecord"), + new FormEventBinding(FormEventKind.AfterDelete, "CaptureRecord"), + ], + }; + + DataEntry component = await CreateComponentAsync( + form, + new DbSchemaProvider(db.Client), + new DbFormRecordService(db.Client), + new DefaultFormEventDispatcher(commands)); + + Dictionary current = ReadCurrentRecord(component); + current["Name"] = "Widget Pro"; + SetField(component, "_dirty", true); + + await InvokeNonPublicAsync(component, "SaveRecord"); + await InvokeNonPublicAsync(component, "DeleteRecord"); + + Assert.Equal( + [ + "BeforeUpdate:Widget Pro", + "AfterUpdate:Widget Pro", + "BeforeDelete:Widget Pro", + "AfterDelete:Widget Pro", + ], + calls); + Assert.Empty(await db.QueryRowsAsync("SELECT * FROM Products")); + } + [Fact] public async Task FocusedNavigation_NextRecordMovesWithinWindowAndAcrossWindowEdge() { @@ -193,13 +343,15 @@ SELECT Name private static async Task CreateComponentAsync( FormDefinition form, ISchemaProvider schemaProvider, - IFormRecordService recordService) + IFormRecordService recordService, + IFormEventDispatcher? formEvents = null) { var component = new DataEntry(); SetProperty(component, "FormRepository", new StaticFormRepository(form)); SetProperty(component, "RecordService", recordService); SetProperty(component, "SchemaProvider", schemaProvider); SetProperty(component, "ValidationService", new PassThroughValidationService()); + SetProperty(component, "FormEvents", formEvents ?? NullFormEventDispatcher.Instance); SetProperty(component, "JS", new StubJsRuntime()); SetProperty(component, nameof(DataEntry.FormId), form.FormId); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index 0d245bf0..f3231c5f 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -38,6 +38,11 @@ public void FormDefinition_RoundTrips() Assert.Equal(form.Layout.LayoutMode, deserialized.Layout.LayoutMode); Assert.Equal(form.Layout.GridSize, deserialized.Layout.GridSize); Assert.Equal(form.Controls.Count, deserialized.Controls.Count); + Assert.NotNull(deserialized.EventBindings); + Assert.Single(deserialized.EventBindings); + Assert.Equal(FormEventKind.AfterUpdate, deserialized.EventBindings[0].Event); + Assert.Equal("AuditChange", deserialized.EventBindings[0].CommandName); + Assert.Equal("manual", deserialized.EventBindings[0].Arguments!["reason"]); } [Fact] @@ -284,6 +289,20 @@ private static FormDefinition CreateSampleForm() new PropertyBag(new Dictionary { ["placeholder"] = "Enter first name" }), null) }; - return new FormDefinition("f1", "Customer Form", "Customers", 1, "customers:v1", layout, controls); + return new FormDefinition( + "f1", + "Customer Form", + "Customers", + 1, + "customers:v1", + layout, + controls, + EventBindings: + [ + new FormEventBinding( + FormEventKind.AfterUpdate, + "AuditChange", + new Dictionary { ["reason"] = "manual" }), + ]); } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs new file mode 100644 index 00000000..c1313ea6 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs @@ -0,0 +1,130 @@ +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Tests.Services; + +public sealed class DefaultFormEventDispatcherTests +{ + [Fact] + public async Task DispatchAsync_InvokesMatchingCommandsWithRecordArgumentsAndMetadata() + { + DbCommandContext? captured = null; + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditChange", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm([ + new FormEventBinding( + FormEventKind.AfterUpdate, + "AuditChange", + new Dictionary { ["Reason"] = "manual" }), + ]); + + var result = await dispatcher.DispatchAsync( + form, + FormEventKind.AfterUpdate, + new Dictionary { ["Id"] = 7L, ["Name"] = "Alice" }, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal("AfterUpdate", captured!.Metadata["event"]); + Assert.Equal("AdminForms", captured.Metadata["surface"]); + Assert.Equal("customers-form", captured.Metadata["formId"]); + Assert.Equal("Customers", captured.Metadata["tableName"]); + Assert.Equal(7, captured.Arguments["Id"].AsInteger); + Assert.Equal("Alice", captured.Arguments["Name"].AsText); + Assert.Equal("manual", captured.Arguments["Reason"].AsText); + } + + [Fact] + public async Task DispatchAsync_FailsForMissingCommand() + { + var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty); + var form = CreateForm([new FormEventBinding(FormEventKind.BeforeDelete, "MissingCommand")]); + + var result = await dispatcher.DispatchAsync(form, FormEventKind.BeforeDelete, ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("Unknown form command 'MissingCommand'", result.Message); + } + + [Fact] + public async Task DispatchAsync_StopsOnCommandFailureByDefault() + { + var calls = new List(); + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("Reject", _ => + { + calls.Add("reject"); + return DbCommandResult.Failure("Rejected."); + }); + builder.AddCommand("After", _ => + { + calls.Add("after"); + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm([ + new FormEventBinding(FormEventKind.BeforeUpdate, "Reject"), + new FormEventBinding(FormEventKind.BeforeUpdate, "After"), + ]); + + var result = await dispatcher.DispatchAsync(form, FormEventKind.BeforeUpdate, ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Equal("Rejected.", result.Message); + Assert.Equal(["reject"], calls); + } + + [Fact] + public async Task DispatchAsync_ContinuesWhenStopOnFailureIsFalse() + { + var calls = new List(); + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("Reject", _ => + { + calls.Add("reject"); + return DbCommandResult.Failure("Rejected."); + }); + builder.AddCommand("After", _ => + { + calls.Add("after"); + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm([ + new FormEventBinding(FormEventKind.BeforeUpdate, "Reject", StopOnFailure: false), + new FormEventBinding(FormEventKind.BeforeUpdate, "After"), + ]); + + var result = await dispatcher.DispatchAsync(form, FormEventKind.BeforeUpdate, ct: TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(["reject", "after"], calls); + } + + private static FormDefinition CreateForm(IReadOnlyList eventBindings) + => new( + "customers-form", + "Customers Form", + "Customers", + DefinitionVersion: 1, + SourceSchemaSignature: "customers:v1", + Layout: new LayoutDefinition("absolute", 8, SnapToGrid: false, []), + Controls: [], + EventBindings: eventBindings); +} diff --git a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs new file mode 100644 index 00000000..577c2d4c --- /dev/null +++ b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs @@ -0,0 +1,74 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class TrustedCommandRegistryTests +{ + [Fact] + public async Task Registry_ValidatesNamesCollisionsAndMetadata() + { + var registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "RecalculateInventory", + new DbCommandOptions("Rebuilds inventory summaries."), + static context => + { + Assert.Equal("RecalculateInventory", context.CommandName); + Assert.Equal("AdminForms", context.Metadata["surface"]); + Assert.Equal(42, context.Arguments["ProductId"].AsInteger); + return DbCommandResult.Success("done", DbValue.FromText("ok")); + })); + + Assert.True(registry.TryGetCommand("recalculateinventory", out DbCommandDefinition definition)); + Assert.Equal("Rebuilds inventory summaries.", definition.Options.Description); + + DbCommandResult result = await definition.InvokeAsync( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["ProductId"] = DbValue.FromInteger(42), + }, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + }, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal("done", result.Message); + Assert.Equal("ok", result.Value.AsText); + + Assert.Throws(() => DbCommandRegistry.Create(commands => + { + commands.AddCommand("Dup", static _ => DbCommandResult.Success()); + commands.AddCommand("dup", static _ => DbCommandResult.Success()); + })); + + Assert.Throws(() => DbCommandRegistry.Create(commands => + commands.AddCommand("bad-name", static _ => DbCommandResult.Success()))); + } + + [Fact] + public async Task Registry_InvokesAsyncCommands() + { + var registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "AsyncCommand", + static async (context, ct) => + { + await Task.Delay(1, ct); + return DbCommandResult.Success(context.Metadata["event"]); + })); + + Assert.True(registry.TryGetCommand("AsyncCommand", out DbCommandDefinition definition)); + + DbCommandResult result = await definition.InvokeAsync( + metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["event"] = "AfterUpdate", + }, + ct: TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal("AfterUpdate", result.Message); + } +} From eb179b78830561e9e2ae2a355a221c3dc0886079 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 07:13:23 -0700 Subject: [PATCH 04/39] Add Admin Forms command automation UX --- RELEASE_NOTES.md | 7 + docs/admin-forms-access-parity/README.md | 7 +- docs/roadmap.md | 8 +- docs/trusted-csharp-functions/README.md | 26 ++- .../Components/Designer/DesignCanvas.razor | 16 +- .../Components/Designer/DesignerState.cs | 13 +- .../Designer/FormEventBindingsEditor.razor | 194 ++++++++++++++++++ .../Components/Designer/FormRenderer.razor | 64 ++++++ .../Designer/PropertyInspector.razor | 131 +++++++++++- .../Components/Designer/Toolbox.razor | 10 + .../Pages/DataEntry.razor | 6 + src/CSharpDB.Admin.Forms/README.md | 15 +- .../Services/DefaultFormEventDispatcher.cs | 52 +---- .../Services/FormCommandInvocation.cs | 97 +++++++++ .../wwwroot/css/designer.css | 160 ++++++++++++++- .../Components/Designer/DesignerStateTests.cs | 62 ++++++ .../FormRendererCommandButtonTests.cs | 113 ++++++++++ 17 files changed, 913 insertions(+), 68 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor create mode 100644 src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index acf55698..b0edbcf9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -46,6 +46,11 @@ calculated text, and pipeline filter/derive expressions. event name. - `AddCSharpDbAdminForms(...)` now has a command-registration overload for trusted host applications. +- The Admin Forms designer preserves and edits form-level event bindings + instead of dropping automation metadata during save. +- Added a command button control that invokes a trusted host command on click, + passing current record fields, optional configured arguments, and form + metadata to the command callback. ### Behavior And Safety @@ -76,6 +81,8 @@ calculated text, and pipeline filter/derive expressions. - Added command-registry, form-event dispatcher, event JSON round-trip, and Forms data-entry tests for create/update/delete event dispatch and before-event cancellation. +- Added designer-state and command-button tests covering event binding + preservation and registered command invocation from rendered forms. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index 64070b14..fc98beb8 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -22,6 +22,9 @@ The current forms surface already includes: - schema-change warnings - designer undo/redo, copy/paste, duplicate, layers, alignment, tab order, and mobile/tablet/desktop breakpoint editing +- trusted host command registration for form lifecycle events +- designer editing for form-level event bindings +- command button controls that invoke trusted host commands ## Added Review Findings @@ -96,9 +99,9 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | -| Command button control | Planned | Add buttons that can run form actions. | +| Command button control | Partial | Trusted command buttons can invoke host-registered C# commands; built-in form actions remain future work. | | Action model | Planned | Support actions such as open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, and show message. | -| Event hooks | Planned | Add form/control events such as on load, before save, after save, before field change, after field change, and button click. | +| Event hooks | Partial | Form lifecycle events and command-button clicks can call trusted commands; field/control event coverage remains future work. | | Conditional UI rules | Planned | Add visible/enabled/read-only expressions for controls. | ### Phase 5: Broader Control and Property Coverage diff --git a/docs/roadmap.md b/docs/roadmap.md index 10c90f21..6e9e5ad9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back the first Admin Forms event bindings; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro actions, control events, and sandboxed UDFs remain future work | Partial | +| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms event bindings and command-button clicks; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro action scripts, broader control events, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -56,7 +56,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | **NuGet package** | Publish and maintain `CSharpDB.Engine`, `CSharpDB.Data`, `CSharpDB.Client`, and `CSharpDB.Primitives` as the primary NuGet packages | Done | | **Connection pooling** | Pool underlying direct embedded sessions behind `CSharpDbConnection` to amortize open/close cost | Done | | **Admin dashboard improvements** | Richer SQL editor UX, query history, deeper diagnostics, and integrated Forms/Reports tooling beyond the core schema/procedure/storage surface | Done | -| **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, command/action events, and broader control coverage | Planned | +| **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, broader action/event coverage, and broader control coverage; trusted command-backed form events and command buttons are now started | Partial | | **Admin Reports Access parity** | Close the highest-impact Access-style report gaps: bounded saved-query previews, full report rendering/export, parameter/filter prompts, richer grouping/totals options, Layout View, conditional formatting, subreports, and broader report controls | Planned | | **Visual query designer** | Classic Admin query builder with source canvas, join editing, design grid, SQL preview, and saved designer layouts | Done | | **ETL pipelines** | Built-in package-driven pipeline runtime with validation, dry-run, execute/resume flows, API/CLI/client coverage, run history, and Admin visual designer support | Done | @@ -96,7 +96,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host, and Admin Forms can invoke trusted host commands from initial form-level events; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, control-level form events, macro actions, and sandboxed UDFs are not implemented | +| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host, and Admin Forms can invoke trusted host commands from form-level events and command buttons; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, broader control-level form events, macro action scripts, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | @@ -106,7 +106,7 @@ These are known simplifications in the current implementation: | **Collections** | `FindByIndexAsync` supports declared field-equality lookups; `FindByPathAsync` and `FindByPathRangeAsync` support path-based queries on indexed paths; `FindAsync` remains a full scan for unindexed predicates | | **Networking** | `CSharpDB.Daemon` now hosts both REST and gRPC from one process; named pipes remain reserved but are not implemented end to end today | | **Security** | Remote HTTP and gRPC deployment still rely on external network controls or front-end TLS termination; built-in authentication, authorization, and TLS/mTLS support are still planned | -| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, command/action events, advanced filtering/sorting, and broader controls | +| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, a broader action model and control events, advanced filtering/sorting, and broader controls | | **Admin Reports** | The Reports designer/runtime supports the core banded preview path, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | | **Text / Multilingual** | Text is stored as UTF-8 and supports all Unicode languages; default semantics remain ordinal, but opt-in `BINARY`, `NOCASE`, `NOCASE_AI`, and `ICU:` collation are implemented for SQL and collection indexes. Dedicated ordered SQL text index optimization remains planned | | **Concurrency** | The physical WAL commit path is still serialized at the storage boundary. Initial multi-writer support is shipped, but observed gains still depend on conflict shape and whether shared auto-commit `INSERT` is left on the default serialized path | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index b6d16bba..a73ce2d9 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -330,6 +330,30 @@ Supported form-level events in this slice are: Command context arguments include the current record fields converted to `DbValue`. Static arguments configured on the event binding override same-named record fields. Metadata includes `surface`, `formId`, `formName`, `tableName`, and `event`. +The Admin Forms designer preserves form event bindings and exposes them in the property inspector when no control is selected. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. + +Admin Forms also include a command button control. Command buttons store a display label, a command name, and optional JSON arguments in the form definition. At runtime, clicking the button invokes the registered host command with the current record fields plus the configured arguments. + +```csharp +var button = new ControlDefinition( + "btn-ship", + "commandButton", + new Rect(24, 320, 160, 34), + Binding: null, + Props: new PropertyBag(new Dictionary + { + ["text"] = "Ship Order", + ["commandName"] = "ShipOrder", + ["commandArguments"] = new Dictionary + { + ["source"] = "form-button", + }, + }), + ValidationOverride: null); +``` + +Command button metadata includes the same form metadata as lifecycle events, plus `event = "Click"`, `controlId`, and `controlType`. + --- ## Admin Reports @@ -477,5 +501,5 @@ V1 does not support: - Passing a database handle into the function context. - Sending delegates over HTTP, gRPC, or pipeline package files. - Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. -- Control-level form events such as button `OnClick`. +- Broader control-level form events beyond command-button clicks. - Stored macro/action scripts. diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor index 0b1de80a..f78c830f 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor @@ -32,7 +32,7 @@ @onpointerdown="e => OnItemPointerDown(e, c)" @onpointerdown:stopPropagation="true"> - @if (State.ShowTabOrder && c.Binding is not null) + @if (State.ShowTabOrder && IsTabOrderControl(c)) { var tabIdx = GetTabIndex(c); @if (tabIdx > 0) @@ -79,6 +79,9 @@ placeholder="@(string.IsNullOrEmpty(formula) ? "Computed" : formula)" disabled /> break; + case "commandButton": + + break; case "textarea": break; @@ -252,6 +255,7 @@ "radio" => (200.0, 80.0), "datagrid" => (560.0, 200.0), "childtabs" => (600.0, 280.0), + "commandButton" => (160.0, 34.0), _ => (320.0, 34.0) }; @@ -287,12 +291,17 @@ props["formula"] = ""; props["format"] = ""; } + if (controlType == "commandButton") + { + props["text"] = "Button"; + props["commandName"] = ""; + } var control = new ControlDefinition( ControlId: Guid.NewGuid().ToString("N"), ControlType: controlType, Rect: new Rect(x, y, width, height), - Binding: (controlType is "label" or "datagrid" or "childtabs") ? null : new BindingDefinition("", "TwoWay"), + Binding: (controlType is "label" or "datagrid" or "childtabs" or "commandButton") ? null : new BindingDefinition("", "TwoWay"), Props: new PropertyBag(props), ValidationOverride: null); @@ -412,6 +421,9 @@ return 0; } + private static bool IsTabOrderControl(ControlDefinition c) + => c.ControlType is not ("label" or "datagrid" or "childtabs"); + private void ApplyResize(PointerEventArgs e) { var o = State.ResizeOriginRect!; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 77eef41a..2aa80004 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -5,6 +5,7 @@ namespace CSharpDB.Admin.Forms.Components.Designer; public class DesignerState { private readonly List _controls = []; + private readonly List _eventBindings = []; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); @@ -16,6 +17,7 @@ public class DesignerState public LayoutDefinition Layout { get; private set; } = new("absolute", 8, true, [new Breakpoint("md", 0, null)]); public IReadOnlyList Controls => _controls; + public IReadOnlyList EventBindings => _eventBindings; public HashSet SelectedIds { get; } = []; // Active tool from toolbox (null = select mode) @@ -58,6 +60,8 @@ public void LoadForm(FormDefinition form) { _controls.Clear(); _controls.AddRange(form.Controls); + _eventBindings.Clear(); + _eventBindings.AddRange(form.EventBindings ?? []); _undoStack.Clear(); _redoStack.Clear(); SelectedIds.Clear(); @@ -82,7 +86,14 @@ public FormDefinition ToFormDefinition() { return new FormDefinition( FormId, FormName, TableName, DefinitionVersion, SourceSchemaSignature, - Layout, _controls.ToList()); + Layout, _controls.ToList(), EventBindings: _eventBindings.ToList()); + } + + public void UpdateEventBindings(IReadOnlyList bindings) + { + _eventBindings.Clear(); + _eventBindings.AddRange(bindings); + NotifyChanged(); } public void PushUndo() diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor new file mode 100644 index 00000000..ad6d7776 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor @@ -0,0 +1,194 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbCommandRegistry CommandRegistry + +
+ @if (EventBindings.Count == 0) + { +
No form events
+ } + + @for (int i = 0; i < EventBindings.Count; i++) + { + var idx = i; + var binding = EventBindings[idx]; +
+
+
+ + +
+ +
+ +
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+ +
+ +
+ +
+ + +
+
+ } + + @if (!string.IsNullOrWhiteSpace(_argumentError)) + { +
@_argumentError
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList EventBindings { get; set; } = []; + [Parameter] public EventCallback> EventBindingsChanged { get; set; } + + private readonly Dictionary _argumentText = []; + private string? _argumentError; + + private static readonly FormEventKind[] EventKinds = Enum.GetValues(); + + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); + + protected override void OnParametersSet() + { + for (int i = 0; i < EventBindings.Count; i++) + _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); + + foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) + _argumentText.Remove(staleIndex); + } + + private async Task AddBinding() + { + string commandName = RegisteredCommands.Count > 0 ? RegisteredCommands[0].Name : string.Empty; + var updated = EventBindings + .Append(new FormEventBinding(FormEventKind.OnLoad, commandName)) + .ToList(); + _argumentText[updated.Count - 1] = string.Empty; + await EventBindingsChanged.InvokeAsync(updated); + } + + private async Task RemoveBinding(int index) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + RebuildArgumentText(updated); + await EventBindingsChanged.InvokeAsync(updated); + } + + private Task UpdateEvent(int index, FormEventBinding binding, string? value) + { + if (!Enum.TryParse(value, ignoreCase: true, out FormEventKind eventKind)) + return Task.CompletedTask; + + return ReplaceBinding(index, binding with { Event = eventKind }); + } + + private Task UpdateCommand(int index, FormEventBinding binding, string commandName) + => ReplaceBinding(index, binding with { CommandName = commandName.Trim() }); + + private Task UpdateStopOnFailure(int index, FormEventBinding binding, bool stopOnFailure) + => ReplaceBinding(index, binding with { StopOnFailure = stopOnFailure }); + + private async Task UpdateArguments(int index, FormEventBinding binding, string text) + { + _argumentText[index] = text; + _argumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceBinding(index, binding with { Arguments = null }); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + await ReplaceBinding(index, binding with { Arguments = arguments }); + } + catch (JsonException ex) + { + _argumentError = $"Invalid arguments JSON: {ex.Message}"; + } + } + + private async Task ReplaceBinding(int index, FormEventBinding binding) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = binding; + await EventBindingsChanged.InvokeAsync(updated); + } + + private string GetArgumentsText(int index, FormEventBinding binding) + { + if (_argumentText.TryGetValue(index, out string? text)) + return text; + + text = FormatArguments(binding.Arguments); + _argumentText[index] = text; + return text; + } + + private void RebuildArgumentText(IReadOnlyList updated) + { + _argumentText.Clear(); + for (int i = 0; i < updated.Count; i++) + _argumentText[i] = FormatArguments(updated[i].Arguments); + } + + private bool ShouldRenderMissingCommand(string commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 1d75773b..570d20f2 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -1,4 +1,6 @@ @using CSharpDB.Admin.Forms.Models +@using CSharpDB.Primitives +@inject DbCommandRegistry Commands
@foreach (var control in Form.Controls) @@ -122,6 +124,16 @@ readonly tabindex="@tabIdx" /> break; + case "commandButton": + var isExecuting = _executingCommandButtons.Contains(c.ControlId); + + break; case "datagrid": var dgChildTable = GetProp(c, "childTable", ""); var dgFkField = GetProp(c, "foreignKeyField", ""); @@ -191,6 +203,9 @@ [Parameter] public Dictionary? ValidationErrors { get; set; } [Parameter] public IReadOnlyDictionary? ChildFormTableDefinitions { get; set; } [Parameter] public EventCallback OnChildRowsChanged { get; set; } + [Parameter] public EventCallback OnCommandError { get; set; } + + private readonly HashSet _executingCommandButtons = []; private string GetFieldValue(string? fieldName) { @@ -242,6 +257,55 @@ OnFieldChanged.InvokeAsync(fieldName); } + private async Task InvokeCommandButtonAsync(ControlDefinition control) + { + string commandName = GetProp(control, "commandName", string.Empty); + if (string.IsNullOrWhiteSpace(commandName)) + { + await ReportCommandErrorAsync("Command button has no command name."); + return; + } + + if (!Commands.TryGetCommand(commandName, out DbCommandDefinition definition)) + { + await ReportCommandErrorAsync($"Unknown form command '{commandName}'."); + return; + } + + _executingCommandButtons.Add(control.ControlId); + try + { + control.Props.Values.TryGetValue("commandArguments", out object? configuredArguments); + Dictionary arguments = FormCommandInvocation.BuildArguments( + Record, + FormCommandInvocation.ReadArgumentsProperty(configuredArguments)); + Dictionary metadata = FormCommandInvocation.BuildMetadata(Form); + metadata["event"] = "Click"; + metadata["controlId"] = control.ControlId; + metadata["controlType"] = control.ControlType; + + DbCommandResult result = await definition.InvokeAsync(arguments, metadata); + if (!result.Succeeded) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Form command '{definition.Name}' failed." + : result.Message; + await ReportCommandErrorAsync(message); + } + } + catch (Exception ex) + { + await ReportCommandErrorAsync($"Form command '{definition.Name}' failed: {ex.Message}"); + } + finally + { + _executingCommandButtons.Remove(control.ControlId); + } + } + + private Task ReportCommandErrorAsync(string message) + => OnCommandError.HasDelegate ? OnCommandError.InvokeAsync(message) : Task.CompletedTask; + private FormFieldDefinition? GetFieldDefinition(string? fieldName) => fieldName is null ? null diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index 63540d7b..de5bd7de 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -1,7 +1,11 @@ +@using System.Text.Json @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Components.Designer @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives @inject ISchemaProvider SchemaProvider +@inject DbCommandRegistry CommandRegistry @implements IDisposable
@@ -16,9 +20,14 @@ } else { -

Select a control to edit its properties

+

Form properties

}
+
+ + +
} else { @@ -37,6 +46,7 @@ + @@ -220,6 +230,57 @@
} + @if (_selected.ControlType == "commandButton") + { +
+ +
+ + +
+
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+
+ + + @if (!string.IsNullOrWhiteSpace(_commandArgumentError)) + { +
@_commandArgumentError
+ } +
+
+ + +
+
+ } + @if (_selected.ControlType == "datagrid") {
@@ -386,6 +447,8 @@ private const string PropValueField = "valueField"; private const string PropFormula = "formula"; private const string PropFormat = "format"; + private const string PropCommandName = "commandName"; + private const string PropCommandArguments = "commandArguments"; private enum RectPart { X, Y, W, H } @@ -400,6 +463,11 @@ // ChildTabs configuration state private List _currentTabs = []; + private string? _loadedCommandArgumentControlId; + private string _commandArgumentText = string.Empty; + private string? _commandArgumentError; + + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); protected override async Task OnInitializedAsync() { @@ -463,6 +531,13 @@ if (_selected?.ControlType != "lookup") _lookupTableDef = null; + if (_selected?.ControlType != "commandButton") + { + _loadedCommandArgumentControlId = null; + _commandArgumentText = string.Empty; + _commandArgumentError = null; + } + await InvokeAsync(StateHasChanged); } @@ -682,6 +757,60 @@ OnPropChanged("tabs", ChildTabConfigMapper.ToPropertyBag(tabs)); } + private string GetCommandArgumentsText() + { + if (_selected is null) + return string.Empty; + + if (string.Equals(_loadedCommandArgumentControlId, _selected.ControlId, StringComparison.Ordinal)) + return _commandArgumentText; + + _loadedCommandArgumentControlId = _selected.ControlId; + _commandArgumentError = null; + _commandArgumentText = _selected.Props.Values.TryGetValue(PropCommandArguments, out object? value) + ? FormatArguments(FormCommandInvocation.ReadArgumentsProperty(value)) + : string.Empty; + return _commandArgumentText; + } + + private void OnCommandArgumentsChanged(string text) + { + _commandArgumentText = text; + _commandArgumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + OnPropChanged(PropCommandArguments, null); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + OnPropChanged(PropCommandArguments, arguments); + } + catch (JsonException ex) + { + _commandArgumentError = $"Invalid arguments JSON: {ex.Message}"; + } + } + + private bool ShouldRenderMissingCommand(string commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private Task OnEventBindingsChanged(IReadOnlyList bindings) + { + State.UpdateEventBindings(bindings); + return Task.CompletedTask; + } + private async Task LoadTableDefinitionAsync(string? tableName) { if (string.IsNullOrWhiteSpace(tableName)) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor index 839aabd2..e36a665e 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor @@ -85,6 +85,15 @@
+
+
Automation
+ +
+
Clipboard
} @@ -586,6 +587,11 @@ _redoStack.Clear(); } + private void OnCommandError(string message) + { + _error = message; + } + private async Task OnFieldChanged(string fieldName) { _dirty = true; diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index dad17947..c142b639 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -15,6 +15,7 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - record paging, search, create, update, and delete services - validation rule inference and validation override support - child table/tab support for related records +- trusted command-backed form events and command buttons ## Main Components @@ -39,6 +40,15 @@ using CSharpDB.Admin.Forms.Services; builder.Services.AddCSharpDbAdminForms(); ``` +Trusted command callbacks can be registered with the overload: + +```csharp +builder.Services.AddCSharpDbAdminForms(commands => +{ + commands.AddCommand("AuditFormOpen", context => DbCommandResult.Success()); +}); +``` + The extension registers: - `IFormRepository` @@ -46,6 +56,8 @@ The extension registers: - `IFormRecordService` - `IFormGenerator` - `IValidationInferenceService` +- `IFormEventDispatcher` +- `DbCommandRegistry` ## Core Contracts @@ -70,7 +82,8 @@ public sealed record FormDefinition( string SourceSchemaSignature, LayoutDefinition Layout, IReadOnlyList Controls, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null); ``` Controls are stored as `ControlDefinition` records with geometry, binding, diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs index e5758620..42dbc757 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -1,4 +1,3 @@ -using System.Globalization; using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; using CSharpDB.Primitives; @@ -24,15 +23,9 @@ public async Task DispatchAsync( if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) return FormEventDispatchResult.Failure($"Unknown form command '{binding.CommandName}' for event '{eventKind}'."); - Dictionary arguments = BuildArguments(record, binding.Arguments); - Dictionary metadata = new(StringComparer.OrdinalIgnoreCase) - { - ["surface"] = "AdminForms", - ["formId"] = form.FormId, - ["formName"] = form.Name, - ["tableName"] = form.TableName, - ["event"] = eventKind.ToString(), - }; + Dictionary arguments = FormCommandInvocation.BuildArguments(record, binding.Arguments); + Dictionary metadata = FormCommandInvocation.BuildMetadata(form); + metadata["event"] = eventKind.ToString(); DbCommandResult result; try @@ -56,43 +49,4 @@ public async Task DispatchAsync( return FormEventDispatchResult.Success(); } - - private static Dictionary BuildArguments( - IReadOnlyDictionary? record, - IReadOnlyDictionary? configuredArguments) - { - var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (record is not null) - { - foreach ((string key, object? value) in record) - arguments[key] = ToDbValue(value); - } - - if (configuredArguments is not null) - { - foreach ((string key, object? value) in configuredArguments) - { - if (!string.IsNullOrWhiteSpace(key)) - arguments[key] = ToDbValue(value); - } - } - - return arguments; - } - - private static DbValue ToDbValue(object? value) => value switch - { - null => DbValue.Null, - DbValue dbValue => dbValue, - bool boolValue => DbValue.FromInteger(boolValue ? 1 : 0), - byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), - float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), - string text => DbValue.FromText(text), - Guid guid => DbValue.FromText(guid.ToString("D")), - DateOnly date => DbValue.FromText(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), - DateTime dateTime => DbValue.FromText(dateTime.ToString("O", CultureInfo.InvariantCulture)), - byte[] bytes => DbValue.FromBlob(bytes), - _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), - }; } diff --git a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs new file mode 100644 index 00000000..3fae0a7b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class FormCommandInvocation +{ + public static Dictionary BuildArguments( + IReadOnlyDictionary? record, + IReadOnlyDictionary? configuredArguments) + { + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (record is not null) + { + foreach ((string key, object? value) in record) + arguments[key] = ToDbValue(value); + } + + if (configuredArguments is not null) + { + foreach ((string key, object? value) in configuredArguments) + { + if (!string.IsNullOrWhiteSpace(key)) + arguments[key] = ToDbValue(value); + } + } + + return arguments; + } + + public static Dictionary BuildMetadata(FormDefinition form) + { + ArgumentNullException.ThrowIfNull(form); + + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["formId"] = form.FormId, + ["formName"] = form.Name, + ["tableName"] = form.TableName, + }; + } + + public static IReadOnlyDictionary? ReadArgumentsProperty(object? value) + { + if (value is null) + return null; + + if (value is IReadOnlyDictionary readOnly) + return readOnly; + + if (value is Dictionary dictionary) + return dictionary; + + if (value is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.Object } json) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (System.Text.Json.JsonProperty property in json.EnumerateObject()) + result[property.Name] = ReadJsonValue(property.Value); + return result; + } + + return null; + } + + private static object? ReadJsonValue(System.Text.Json.JsonElement value) + { + return value.ValueKind switch + { + System.Text.Json.JsonValueKind.Null => null, + System.Text.Json.JsonValueKind.True => true, + System.Text.Json.JsonValueKind.False => false, + System.Text.Json.JsonValueKind.Number => value.TryGetInt64(out long longValue) ? longValue : value.GetDouble(), + System.Text.Json.JsonValueKind.String => value.GetString(), + System.Text.Json.JsonValueKind.Object => ReadArgumentsProperty(value), + System.Text.Json.JsonValueKind.Array => value.EnumerateArray().Select(ReadJsonValue).ToArray(), + _ => value.ToString(), + }; + } + + private static DbValue ToDbValue(object? value) => value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolValue => DbValue.FromInteger(boolValue ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + Guid guid => DbValue.FromText(guid.ToString("D")), + DateOnly date => DbValue.FromText(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + DateTime dateTime => DbValue.FromText(dateTime.ToString("O", CultureInfo.InvariantCulture)), + byte[] bytes => DbValue.FromBlob(bytes), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; +} diff --git a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css index c96e92cd..a46fcb56 100644 --- a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css +++ b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css @@ -224,6 +224,27 @@ pointer-events: none; } +.preview-command-button, +.fr-command-button { + width: 100%; + height: 100%; + border: 1px solid #c0c0c0; + border-radius: 4px; + background: #fff; + color: #1a1a1a; + font: inherit; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-command-button:disabled, +.fr-command-button:disabled { + opacity: 0.6; + cursor: default; +} + /* ===== Resize Handles ===== */ .resize-handle { position: absolute; @@ -296,7 +317,8 @@ .pi-field input[type="text"], .pi-field input[type="number"], -.pi-field select { +.pi-field select, +.pi-field textarea { width: 100%; padding: 4px 6px; border: 1px solid #d0d0d0; @@ -322,7 +344,8 @@ .pi-field input[type="text"]:focus, .pi-field input[type="number"]:focus, -.pi-field select:focus { +.pi-field select:focus, +.pi-field textarea:focus { outline: none; border-color: #1a73e8; box-shadow: 0 0 0 2px rgba(26,115,232,0.15); @@ -332,6 +355,20 @@ margin: 4px 0; } +.pi-textarea, +.feb-arguments { + min-height: 68px; + resize: vertical; + font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +.pi-field-error, +.feb-error { + color: #d32f2f; + font-size: 11px; + margin-top: 4px; +} + .pi-readonly { background: #f5f5f5 !important; color: #888; @@ -1375,6 +1412,87 @@ background: #d2e3fc; } +/* ===== Form Event Binding Editor ===== */ +.feb-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.feb-empty { + color: #999; + font-size: 11px; + font-style: italic; +} + +.feb-entry { + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 8px; + background: #fafafa; +} + +.feb-row { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.feb-field { + flex: 1; + margin-bottom: 6px; +} + +.feb-field label { + display: block; + font-size: 11px; + color: #666; + margin-bottom: 2px; +} + +.feb-field input[type="text"], +.feb-field select, +.feb-field textarea { + width: 100%; + padding: 3px 6px; + border: 1px solid #d0d0d0; + border-radius: 3px; + font-size: 11px; + background: #fff; + box-sizing: border-box; +} + +.feb-btn { + padding: 2px 8px; + border: 1px solid #d0d0d0; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 11px; + color: #333; +} + +.feb-btn:hover { + background: #e8e8e8; +} + +.feb-btn-remove { + flex: 0 0 auto; + margin-top: 18px; +} + +.feb-btn-add { + align-self: flex-start; + background: #e8f0fe; + border-color: #c4d9f5; + color: #1a73e8; + font-weight: 500; +} + +.feb-btn-add:hover { + background: #d2e3fc; +} + /* ===== Layers Panel ===== */ .layers-panel { flex: 1; @@ -1627,12 +1745,19 @@ .pi-field input[type="text"], .pi-field input[type="number"], .pi-field select, +.pi-field textarea, .de-search-select, .de-search-input, .fr-input, +.fr-command-button, +.preview-command-button, .cdg-cell-input, .tce-field input[type="text"], -.tce-field select { +.tce-field select, +.feb-field input[type="text"], +.feb-field select, +.feb-field textarea, +.feb-btn { background: var(--fd-bg-elevated); color: var(--fd-text); border-color: var(--fd-border); @@ -1643,13 +1768,15 @@ .pi-btn:hover, .cdg-btn:hover, .tce-btn:hover, +.feb-btn:hover, .breakpoint-switcher button:hover, .page-btn:hover, .toolbox-item:hover, .layer-row:hover, .de-record-row:hover, .ctp-tab:hover, -.cdg-cell:hover { +.cdg-cell:hover, +.fr-command-button:hover { background: var(--fd-bg-hover); color: var(--fd-text); } @@ -1667,12 +1794,18 @@ .pi-field input[type="text"], .pi-field input[type="number"], .pi-field select, +.pi-field textarea, .de-search-select, .de-search-input, .fr-input, +.fr-command-button, +.preview-command-button, .cdg-cell-input, .tce-field input[type="text"], -.tce-field select { +.tce-field select, +.feb-field input[type="text"], +.feb-field select, +.feb-field textarea { outline: none; box-shadow: none; } @@ -1683,12 +1816,17 @@ .pi-field input[type="text"]:focus, .pi-field input[type="number"]:focus, .pi-field select:focus, +.pi-field textarea:focus, .de-search-select:focus, .de-search-input:focus, .fr-input:focus, +.fr-command-button:focus, .cdg-cell-input:focus, .tce-field input[type="text"]:focus, -.tce-field select:focus { +.tce-field select:focus, +.feb-field input[type="text"]:focus, +.feb-field select:focus, +.feb-field textarea:focus { border-color: var(--fd-accent); box-shadow: 0 0 0 2px var(--fd-accent-soft); } @@ -1780,6 +1918,8 @@ .preview-input, .fr-input, +.fr-command-button, +.preview-command-button, .cdg-cell-input, .designer-cell-input, .designer-cell-select { @@ -1791,6 +1931,8 @@ .preview-input::placeholder, .de-toolbar-input::placeholder, .de-search-input::placeholder, +.pi-textarea::placeholder, +.feb-arguments::placeholder, .designer-cell-input::placeholder, .toolbar-title-input::placeholder { color: var(--fd-text-muted); @@ -1827,6 +1969,7 @@ .preview-childtabs-tab, .pi-field label, .tce-field label, +.feb-field label, .property-label, .cdg-table thead th, .table-designer-grid thead th { @@ -1887,6 +2030,7 @@ .toolbox-group, .pi-section, .tce-tab-entry, +.feb-entry, .table-designer-grid tbody tr, .table-designer-grid td { border-color: var(--fd-border-light); @@ -1913,8 +2057,10 @@ .cdg-cell, .de-record-summary, .tce-btn, +.feb-btn, .pi-field input[type="checkbox"], -.tce-field input[type="checkbox"] { +.tce-field input[type="checkbox"], +.feb-field input[type="checkbox"] { color: var(--fd-text); } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs new file mode 100644 index 00000000..c79ee213 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -0,0 +1,62 @@ +using CSharpDB.Admin.Forms.Components.Designer; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Tests.Components.Designer; + +public sealed class DesignerStateTests +{ + [Fact] + public void ToFormDefinition_PreservesEventBindings() + { + var state = new DesignerState(); + var form = CreateForm() with + { + EventBindings = + [ + new FormEventBinding( + FormEventKind.AfterUpdate, + "AuditChange", + new Dictionary { ["reason"] = "manual" }), + ], + }; + + state.LoadForm(form); + + FormDefinition saved = state.ToFormDefinition(); + + Assert.NotNull(saved.EventBindings); + FormEventBinding binding = Assert.Single(saved.EventBindings); + Assert.Equal(FormEventKind.AfterUpdate, binding.Event); + Assert.Equal("AuditChange", binding.CommandName); + Assert.Equal("manual", binding.Arguments!["reason"]); + } + + [Fact] + public void UpdateEventBindings_ReplacesFormLevelBindings() + { + var state = new DesignerState(); + state.LoadForm(CreateForm()); + + state.UpdateEventBindings( + [ + new FormEventBinding(FormEventKind.BeforeDelete, "ConfirmDelete", StopOnFailure: false), + ]); + + FormDefinition saved = state.ToFormDefinition(); + + FormEventBinding binding = Assert.Single(saved.EventBindings!); + Assert.Equal(FormEventKind.BeforeDelete, binding.Event); + Assert.Equal("ConfirmDelete", binding.CommandName); + Assert.False(binding.StopOnFailure); + } + + private static FormDefinition CreateForm() + => new( + "customers-form", + "Customers", + "Customers", + 1, + "sig:customers", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + []); +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs new file mode 100644 index 00000000..3b7fabbc --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -0,0 +1,113 @@ +using System.Reflection; +using CSharpDB.Admin.Forms.Components.Designer; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; +using Microsoft.AspNetCore.Components; + +namespace CSharpDB.Admin.Forms.Tests.Components.Designer; + +public sealed class FormRendererCommandButtonTests +{ + [Fact] + public async Task CommandButton_InvokesRegisteredCommandWithRecordAndConfiguredArguments() + { + DbCommandContext? captured = null; + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("ShipOrder", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + ControlDefinition button = CreateCommandButton("ShipOrder"); + var renderer = CreateRenderer(commands, CreateForm(button)); + + await InvokeNonPublicAsync(renderer, "InvokeCommandButtonAsync", button); + + Assert.NotNull(captured); + Assert.Equal("ShipOrder", captured.CommandName); + Assert.Equal(42, captured.Arguments["OrderId"].AsInteger); + Assert.Equal("manual", captured.Arguments["reason"].AsText); + Assert.Equal("AdminForms", captured.Metadata["surface"]); + Assert.Equal("orders-form", captured.Metadata["formId"]); + Assert.Equal("Click", captured.Metadata["event"]); + Assert.Equal("button1", captured.Metadata["controlId"]); + } + + [Fact] + public async Task CommandButton_ReportsMissingCommand() + { + string? error = null; + ControlDefinition button = CreateCommandButton("MissingCommand"); + var renderer = CreateRenderer(DbCommandRegistry.Empty, CreateForm(button), message => error = message); + + await InvokeNonPublicAsync(renderer, "InvokeCommandButtonAsync", button); + + Assert.Equal("Unknown form command 'MissingCommand'.", error); + } + + private static FormRenderer CreateRenderer( + DbCommandRegistry commands, + FormDefinition form, + Action? onCommandError = null) + { + var renderer = new FormRenderer(); + SetProperty(renderer, nameof(FormRenderer.Form), form); + SetProperty(renderer, nameof(FormRenderer.Record), new Dictionary + { + ["OrderId"] = 42L, + ["Status"] = "Ready", + }); + SetProperty(renderer, "Commands", commands); + + if (onCommandError is not null) + { + EventCallback callback = EventCallback.Factory.Create(new object(), onCommandError); + SetProperty(renderer, nameof(FormRenderer.OnCommandError), callback); + } + + return renderer; + } + + private static ControlDefinition CreateCommandButton(string commandName) + => new( + "button1", + "commandButton", + new Rect(10, 20, 120, 34), + null, + new PropertyBag(new Dictionary + { + ["text"] = "Ship", + ["commandName"] = commandName, + ["commandArguments"] = new Dictionary { ["reason"] = "manual" }, + }), + null); + + private static FormDefinition CreateForm(ControlDefinition button) + => new( + "orders-form", + "Orders", + "Orders", + 1, + "sig:orders", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + [button]); + + private static void SetProperty(object instance, string propertyName, object? value) + { + PropertyInfo property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found."); + property.SetValue(instance, value); + } + + private static async Task InvokeNonPublicAsync(object instance, string methodName, params object?[] args) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + var task = (Task?)method.Invoke(instance, args) + ?? throw new InvalidOperationException($"Method '{methodName}' did not return a task."); + await task; + } +} From f19495d640ef49b51f305354ec73eed40a166e8b Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 08:04:10 -0700 Subject: [PATCH 05/39] Add report and pipeline trusted command hooks --- RELEASE_NOTES.md | 24 ++- docs/admin-reports-access-parity/README.md | 4 +- docs/roadmap.md | 8 +- docs/trusted-csharp-functions/README.md | 111 +++++++++++- .../Services/FormCommandInvocation.cs | 36 +--- .../Contracts/IReportEventDispatcher.cs | 13 ++ .../Contracts/ReportEventDispatchResult.cs | 12 ++ .../Models/ReportDefinition.cs | 3 +- .../Models/ReportEventBinding.cs | 14 ++ src/CSharpDB.Admin.Reports/README.md | 24 ++- ...AdminReportsServiceCollectionExtensions.cs | 14 ++ .../Services/DefaultReportEventDispatcher.cs | 67 ++++++++ .../Services/DefaultReportPreviewService.cs | 53 +++++- .../Services/NullReportEventDispatcher.cs | 21 +++ .../Components/Tabs/PipelineDesigner.razor | 4 + .../Pipelines/CSharpDbPipelineRunner.cs | 8 +- .../Pipelines/CSharpDbPipelineStorage.cs | 1 + .../Models/PipelinePackageDefinition.cs | 17 ++ src/CSharpDB.Pipelines/README.md | 29 +++- .../Runtime/PipelineOrchestrator.cs | 162 +++++++++++++++++- .../ObjectDictionaryConverter.cs | 111 ++++++++++++ .../PipelinePackageSerializer.cs | 1 + .../Validation/PipelinePackageValidator.cs | 30 ++++ src/CSharpDB.Primitives/DbCommandArguments.cs | 61 +++++++ .../DefaultReportEventDispatcherTests.cs | 87 ++++++++++ .../DefaultReportPreviewServiceTests.cs | 69 ++++++++ .../PipelineOrchestratorTests.cs | 138 ++++++++++++++- .../PipelinePackageSerializerTests.cs | 26 ++- .../PipelinePackageValidatorTests.cs | 55 ++++++ .../TrustedCommandRegistryTests.cs | 28 +++ 30 files changed, 1173 insertions(+), 58 deletions(-) create mode 100644 src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs create mode 100644 src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs create mode 100644 src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs create mode 100644 src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs create mode 100644 src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs create mode 100644 src/CSharpDB.Pipelines/Serialization/ObjectDictionaryConverter.cs create mode 100644 src/CSharpDB.Primitives/DbCommandArguments.cs create mode 100644 tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b0edbcf9..0d0d6cef 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -51,6 +51,20 @@ calculated text, and pipeline filter/derive expressions. - Added a command button control that invokes a trusted host command on click, passing current record fields, optional configured arguments, and form metadata to the command callback. +- Added shared command argument conversion helpers so Forms, Reports, and + Pipelines pass host command arguments with the same `DbValue` conversion + rules. +- Admin Reports can now bind `OnOpen`, `BeforeRender`, and `AfterRender` + preview lifecycle events to trusted commands. The preview service passes + report/source metadata plus row, truncation, page, and schema-drift metrics. +- `AddCSharpDbAdminReports(...)` now has a command-registration overload for + trusted host applications. +- Pipeline packages can now include trusted command hooks for `OnRunStarted`, + `OnBatchCompleted`, `OnRunSucceeded`, and `OnRunFailed`. Package JSON stores + hook names and arguments only; command bodies remain host-registered code. +- Pipeline hook failures fail the run through `PipelineRunResult`; failure-hook + errors are appended to the failed run summary instead of recursively + dispatching more failure hooks. ### Behavior And Safety @@ -83,6 +97,9 @@ calculated text, and pipeline filter/derive expressions. before-event cancellation. - Added designer-state and command-button tests covering event binding preservation and registered command invocation from rendered forms. +- Added report-event dispatcher and preview lifecycle tests, pipeline hook + serialization/validation/orchestrator tests, and shared command argument + conversion tests. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: @@ -130,9 +147,10 @@ otherwise neutral to improved. - Remote hosts must register functions in the daemon/API host process; direct clients can register functions locally through `DirectDatabaseOptions`, but callback delegates are never serialized over HTTP or gRPC. -- Admin Forms and Reports use the shared registry, but their formula surfaces - remain narrower than SQL: numeric formulas expect numeric returns, while - report calculated text supports scalar function expressions as rendered text. +- Admin Forms and Reports use the shared registries, but their formula and + automation surfaces remain narrower than SQL or stored macro systems: + formulas stay expression-focused, and command hooks invoke host-owned code by + name rather than storing executable scripts in database metadata. ## v3.5.0 diff --git a/docs/admin-reports-access-parity/README.md b/docs/admin-reports-access-parity/README.md index 1195d992..f03f1956 100644 --- a/docs/admin-reports-access-parity/README.md +++ b/docs/admin-reports-access-parity/README.md @@ -23,6 +23,8 @@ The current reports surface already includes: - preview pagination with page headers and footers - print support through the browser - schema-drift warnings +- trusted command-backed preview lifecycle events for `OnOpen`, + `BeforeRender`, and `AfterRender` ## Added Review Findings @@ -120,7 +122,7 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | -| Email/report delivery | Planned | Export and attach reports, with host-provided delivery hooks. | +| Email/report delivery | Planned | Export and attach reports; trusted `AfterRender` commands provide an initial host callback but not a full delivery pipeline. | | Scheduled reports | Research | Run recurring reports and store generated artifacts. | | Report artifact history | Research | Store generated report snapshots for auditing and re-download. | | Large-report cancellation | Planned | Add cancellation/progress for long render/export jobs. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e9e5ad9..5b36d617 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms event bindings and command-button clicks; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro action scripts, broader control events, and sandboxed UDFs remain future work | Partial | +| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms event bindings, command-button clicks, Admin Reports render lifecycle events, and pipeline run hooks; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro action scripts, broader control events, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -57,7 +57,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | **Connection pooling** | Pool underlying direct embedded sessions behind `CSharpDbConnection` to amortize open/close cost | Done | | **Admin dashboard improvements** | Richer SQL editor UX, query history, deeper diagnostics, and integrated Forms/Reports tooling beyond the core schema/procedure/storage surface | Done | | **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, broader action/event coverage, and broader control coverage; trusted command-backed form events and command buttons are now started | Partial | -| **Admin Reports Access parity** | Close the highest-impact Access-style report gaps: bounded saved-query previews, full report rendering/export, parameter/filter prompts, richer grouping/totals options, Layout View, conditional formatting, subreports, and broader report controls | Planned | +| **Admin Reports Access parity** | Close the highest-impact Access-style report gaps: bounded saved-query previews, full report rendering/export, parameter/filter prompts, richer grouping/totals options, Layout View, conditional formatting, subreports, and broader report controls; trusted command-backed report preview lifecycle events are now started | Partial | | **Visual query designer** | Classic Admin query builder with source canvas, join editing, design grid, SQL preview, and saved designer layouts | Done | | **ETL pipelines** | Built-in package-driven pipeline runtime with validation, dry-run, execute/resume flows, API/CLI/client coverage, run history, and Admin visual designer support | Done | | **VS Code extension** | Schema explorer, SQL editor with IntelliSense, data browser, table designer, storage diagnostics | Done | @@ -96,7 +96,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host, and Admin Forms can invoke trusted host commands from form-level events and command buttons; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, broader control-level form events, macro action scripts, and sandboxed UDFs are not implemented | +| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host; Admin Forms, Admin Reports, and pipelines can invoke trusted host commands from supported event/hook surfaces; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, broader control-level form events, macro action scripts, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | @@ -107,7 +107,7 @@ These are known simplifications in the current implementation: | **Networking** | `CSharpDB.Daemon` now hosts both REST and gRPC from one process; named pipes remain reserved but are not implemented end to end today | | **Security** | Remote HTTP and gRPC deployment still rely on external network controls or front-end TLS termination; built-in authentication, authorization, and TLS/mTLS support are still planned | | **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, a broader action model and control events, advanced filtering/sorting, and broader controls | -| **Admin Reports** | The Reports designer/runtime supports the core banded preview path, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | +| **Admin Reports** | The Reports designer/runtime supports the core banded preview path plus trusted command-backed preview lifecycle events, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | | **Text / Multilingual** | Text is stored as UTF-8 and supports all Unicode languages; default semantics remain ordinal, but opt-in `BINARY`, `NOCASE`, `NOCASE_AI`, and `ICU:` collation are implemented for SQL and collection indexes. Dedicated ordered SQL text index optimization remains planned | | **Concurrency** | The physical WAL commit path is still serialized at the storage boundary. Initial multi-writer support is shipped, but observed gains still depend on conflict shape and whether shared auto-commit `INSERT` is left on the default serialized path | | **Storage** | No page-level compression | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index a73ce2d9..69a8f618 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -11,9 +11,9 @@ This feature is intentionally trusted and in-process. It does not store C# sourc CSharpDB also supports trusted host-registered commands for application automation surfaces. Commands are different from scalar functions: - Scalar functions return a `DbValue` and can be used inside SQL or formulas. -- Commands return a `DbCommandResult` and are invoked by host-driven events such as Admin Forms lifecycle events. +- Commands return a `DbCommandResult` and are invoked by host-driven events such as Admin Forms lifecycle events, Admin Reports render events, and pipeline run hooks. -Commands are intended for Access-style application automation such as auditing, calling application services, sending notifications, refreshing derived state, or coordinating UI workflows. They are trusted in-process callbacks registered by the host application. +Commands are intended for Access-style application automation such as auditing, calling application services, sending notifications, refreshing derived state, coordinating UI workflows, or publishing operational run events. They are trusted in-process callbacks registered by the host application. ```csharp using CSharpDB.Admin.Forms.Services; @@ -384,6 +384,51 @@ Calculated text can use a scalar function as the whole expression, including tex Report aggregate formulas such as `=SUM([Subtotal])` remain built-in report behavior. +Admin Reports can also bind preview-render lifecycle events to trusted commands. Report definitions store event names, command names, and optional static arguments only; the C# command bodies stay registered by the host process. + +```csharp +using CSharpDB.Admin.Reports.Models; + +var report = existingReport with +{ + EventBindings = + [ + new ReportEventBinding(ReportEventKind.OnOpen, "AuditReportOpen"), + new ReportEventBinding(ReportEventKind.BeforeRender, "PrepareReportContext"), + new ReportEventBinding(ReportEventKind.AfterRender, "PublishReportRendered"), + ], +}; +``` + +Supported report events are: + +| Event | When it runs | +| --- | --- | +| `OnOpen` | After the report source is resolved, before preview rows are loaded. | +| `BeforeRender` | After preview rows are loaded and capped, before pagination and calculated text rendering. | +| `AfterRender` | After preview pages are produced, before the preview result is returned. | + +Command context arguments include render metrics such as `rowCount`, `loadedRowCount`, `rowTruncated`, `pageCount`, `isTruncated`, and `hasSchemaDrift` depending on the event. Static arguments configured on the binding override same-named runtime arguments. Metadata includes `surface = AdminReports`, `reportId`, `reportName`, `sourceKind`, `sourceName`, and `event`. + +Register report commands through the reports service registration overload: + +```csharp +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminReports(commands => +{ + commands.AddCommand("PublishReportRendered", static context => + { + string reportName = context.Metadata["reportName"]; + long pageCount = context.Arguments["pageCount"].AsInteger; + + PublishReportMetric(reportName, pageCount); + return DbCommandResult.Success(); + }); +}); +``` + --- ## Pipelines @@ -448,6 +493,66 @@ await runner.RunPackageAsync(package); Pipeline package JSON stores only expressions such as `NormalizeStatus(status)`. The C# delegate must be registered by the process that runs the package. +Pipelines can also invoke trusted commands from run hooks. Hook definitions are serialized with the package, but they store only the hook event, command name, and optional static arguments: + +```csharp +var commands = DbCommandRegistry.Create(builder => +{ + builder.AddCommand("NotifyPipeline", static context => + { + string pipelineName = context.Metadata["pipelineName"]; + string status = context.Arguments["status"].AsText; + long rowsWritten = context.Arguments["rowsWritten"].AsInteger; + + NotifyOps(pipelineName, status, rowsWritten); + return DbCommandResult.Success(); + }); +}); + +var runner = new CSharpDbPipelineRunner(client, functions, commands); + +var package = new PipelinePackageDefinition +{ + Name = "active-customers", + Version = "1.0.0", + Source = new PipelineSourceDefinition + { + Kind = PipelineSourceKind.CsvFile, + Path = "customers.csv", + }, + Destination = new PipelineDestinationDefinition + { + Kind = PipelineDestinationKind.JsonFile, + Path = "active-customers.json", + }, + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "NotifyPipeline", + Arguments = new Dictionary + { + ["channel"] = "ops", + }, + }, + ], +}; +``` + +Supported pipeline hook events are: + +| Event | When it runs | +| --- | --- | +| `OnRunStarted` | After package validation and run logging, before components are created. | +| `OnBatchCompleted` | After each source batch is transformed/written, metrics and checkpoints are updated, and reject limits pass. | +| `OnRunSucceeded` | After destination completion and before the successful run is logged as completed. | +| `OnRunFailed` | When the orchestrator is about to return a failed `PipelineRunResult`. | + +Hook arguments include `runId`, `pipelineName`, `pipelineVersion`, `mode`, `event`, `status`, `rowsRead`, `rowsWritten`, `rowsRejected`, and `batchesCompleted`. Batch hooks also include `batchNumber`, `startingRowNumber`, and `batchRowCount`. Failure hooks include `errorSummary`. Metadata includes `surface = Pipelines`, `pipelineName`, `pipelineVersion`, `runId`, `mode`, and `event`. + +`Validate` mode does not invoke command hooks, so package validation stays side-effect free. Missing command registration or a failing hook with `StopOnFailure = true` fails the run normally. For `OnRunFailed`, hook failures are appended to the failed run's error summary instead of recursively dispatching more failure hooks. + --- ## Error Handling @@ -473,6 +578,8 @@ For SQL write statements, a failing function aborts the statement. If the statem Admin Forms formulas intentionally return `null` for invalid formulas, unsupported function return types, missing functions, division by zero, or exceptions. Pipeline functions throw runtime errors unless the pipeline error mode handles the affected row. +Trusted command failures are surface-specific. Form before-events can cancel writes, report event failures fail preview rendering, and pipeline hook failures produce a failed `PipelineRunResult` unless the binding sets `StopOnFailure = false`. + --- ## Performance Guidance diff --git a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs index 3fae0a7b..8ae61778 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs @@ -1,4 +1,3 @@ -using System.Globalization; using CSharpDB.Admin.Forms.Models; using CSharpDB.Primitives; @@ -9,26 +8,7 @@ internal static class FormCommandInvocation public static Dictionary BuildArguments( IReadOnlyDictionary? record, IReadOnlyDictionary? configuredArguments) - { - var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (record is not null) - { - foreach ((string key, object? value) in record) - arguments[key] = ToDbValue(value); - } - - if (configuredArguments is not null) - { - foreach ((string key, object? value) in configuredArguments) - { - if (!string.IsNullOrWhiteSpace(key)) - arguments[key] = ToDbValue(value); - } - } - - return arguments; - } + => DbCommandArguments.FromObjectDictionary(record, configuredArguments); public static Dictionary BuildMetadata(FormDefinition form) { @@ -80,18 +60,4 @@ public static Dictionary BuildMetadata(FormDefinition form) }; } - private static DbValue ToDbValue(object? value) => value switch - { - null => DbValue.Null, - DbValue dbValue => dbValue, - bool boolValue => DbValue.FromInteger(boolValue ? 1 : 0), - byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), - float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), - string text => DbValue.FromText(text), - Guid guid => DbValue.FromText(guid.ToString("D")), - DateOnly date => DbValue.FromText(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), - DateTime dateTime => DbValue.FromText(dateTime.ToString("O", CultureInfo.InvariantCulture)), - byte[] bytes => DbValue.FromBlob(bytes), - _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), - }; } diff --git a/src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs new file mode 100644 index 00000000..96946ac3 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Contracts/IReportEventDispatcher.cs @@ -0,0 +1,13 @@ +using CSharpDB.Admin.Reports.Models; + +namespace CSharpDB.Admin.Reports.Contracts; + +public interface IReportEventDispatcher +{ + Task DispatchAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null, + CancellationToken ct = default); +} diff --git a/src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs b/src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs new file mode 100644 index 00000000..5f5ef8b1 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Contracts/ReportEventDispatchResult.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Reports.Contracts; + +public sealed record ReportEventDispatchResult(bool Succeeded, string? Message = null) +{ + public static ReportEventDispatchResult Success(string? message = null) => new(true, message); + + public static ReportEventDispatchResult Failure(string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return new(false, message); + } +} diff --git a/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs b/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs index b6cbed04..9b292688 100644 --- a/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs +++ b/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs @@ -10,4 +10,5 @@ public sealed record ReportDefinition( IReadOnlyList Groups, IReadOnlyList Sorts, IReadOnlyList Bands, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null); diff --git a/src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs b/src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs new file mode 100644 index 00000000..20f0efc0 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Models/ReportEventBinding.cs @@ -0,0 +1,14 @@ +namespace CSharpDB.Admin.Reports.Models; + +public enum ReportEventKind +{ + OnOpen, + BeforeRender, + AfterRender, +} + +public sealed record ReportEventBinding( + ReportEventKind Event, + string CommandName, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true); diff --git a/src/CSharpDB.Admin.Reports/README.md b/src/CSharpDB.Admin.Reports/README.md index 46cef9a5..3dad3a4c 100644 --- a/src/CSharpDB.Admin.Reports/README.md +++ b/src/CSharpDB.Admin.Reports/README.md @@ -16,6 +16,7 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - grouping, sorting, bands, bound text, calculated text, labels, lines, and box controls - preview pagination and simple expression evaluation +- trusted command-backed preview lifecycle events ## Main Components @@ -39,11 +40,27 @@ using CSharpDB.Admin.Reports.Services; builder.Services.AddCSharpDbAdminReports(); ``` +Hosts that want Access-style report automation can register trusted commands: + +```csharp +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminReports(commands => +{ + commands.AddCommand("PublishReportRendered", static context => + { + long pageCount = context.Arguments["pageCount"].AsInteger; + return DbCommandResult.Success($"Rendered {pageCount} page(s)."); + }); +}); +``` + The extension registers: - `IReportRepository` - `IReportSourceProvider` - `IReportGenerator` +- `IReportEventDispatcher` - `IReportPreviewService` ## Core Contracts @@ -70,12 +87,17 @@ public sealed record ReportDefinition( IReadOnlyList Groups, IReadOnlyList Sorts, IReadOnlyList Bands, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null); ``` Report layout is band-based. Each `ReportBandDefinition` owns a list of `ReportControlDefinition` records positioned within that band. +`EventBindings` can reference host-registered commands for `OnOpen`, +`BeforeRender`, and `AfterRender`. Report JSON stores event names, command +names, and optional arguments only; C# command bodies stay in the host process. + ## Build ```powershell diff --git a/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs index 60f9a3c3..ce3760c4 100644 --- a/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Primitives; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace CSharpDB.Admin.Reports.Services; @@ -7,10 +9,22 @@ public static class AdminReportsServiceCollectionExtensions { public static IServiceCollection AddCSharpDbAdminReports(this IServiceCollection services) { + services.TryAddSingleton(DbCommandRegistry.Empty); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } + + public static IServiceCollection AddCSharpDbAdminReports( + this IServiceCollection services, + Action configureCommands) + { + ArgumentNullException.ThrowIfNull(configureCommands); + + services.AddSingleton(DbCommandRegistry.Create(configureCommands)); + return services.AddCSharpDbAdminReports(); + } } diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs new file mode 100644 index 00000000..1d1ee990 --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs @@ -0,0 +1,67 @@ +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Reports.Services; + +public sealed class DefaultReportEventDispatcher(DbCommandRegistry commands) : IReportEventDispatcher +{ + public async Task DispatchAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(source); + + IReadOnlyList bindings = report.EventBindings ?? []; + foreach (ReportEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) + { + if (string.IsNullOrWhiteSpace(binding.CommandName)) + return ReportEventDispatchResult.Failure($"Report event '{eventKind}' has an empty command name."); + + if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + return ReportEventDispatchResult.Failure($"Unknown report command '{binding.CommandName}' for event '{eventKind}'."); + + Dictionary arguments = DbCommandArguments.FromObjectDictionary(runtimeArguments, binding.Arguments); + Dictionary metadata = BuildMetadata(report, source, eventKind); + + DbCommandResult result; + try + { + result = await definition.InvokeAsync(arguments, metadata, ct); + } + catch (Exception ex) + { + return ReportEventDispatchResult.Failure( + $"Report event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"); + } + + if (!result.Succeeded && binding.StopOnFailure) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Report event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + return ReportEventDispatchResult.Failure(message); + } + } + + return ReportEventDispatchResult.Success(); + } + + private static Dictionary BuildMetadata( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind) + => new(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminReports", + ["reportId"] = report.ReportId, + ["reportName"] = report.Name, + ["sourceKind"] = source.Kind.ToString(), + ["sourceName"] = source.Name, + ["event"] = eventKind.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs index 6d0b292a..40e09d59 100644 --- a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs @@ -8,17 +8,21 @@ namespace CSharpDB.Admin.Reports.Services; public sealed class DefaultReportPreviewService( ICSharpDbClient dbClient, IReportSourceProvider sourceProvider, - DbFunctionRegistry? functions = null) : IReportPreviewService + DbFunctionRegistry? functions = null, + IReportEventDispatcher? reportEvents = null) : IReportPreviewService { internal const int MaxPreviewRows = 10000; internal const int MaxPreviewPages = 250; private const double PixelsPerInch = 96.0; + private readonly IReportEventDispatcher _reportEvents = reportEvents ?? NullReportEventDispatcher.Instance; public async Task BuildPreviewAsync(ReportDefinition report, CancellationToken ct = default) { ReportSourceDefinition source = await sourceProvider.GetSourceDefinitionAsync(report.Source) ?? throw new InvalidOperationException($"Source '{report.Source.Name}' is no longer available."); + await DispatchReportEventOrThrowAsync(report, source, ReportEventKind.OnOpen, runtimeArguments: null, ct); + IReadOnlyList> loadedRows = source.Kind switch { ReportSourceKind.SavedQuery => SortRowsInMemory( @@ -29,11 +33,24 @@ public async Task BuildPreviewAsync(ReportDefinition report bool rowTruncated = loadedRows.Count > MaxPreviewRows; List> rows = loadedRows.Take(MaxPreviewRows).ToList(); + await DispatchReportEventOrThrowAsync( + report, + source, + ReportEventKind.BeforeRender, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rowCount"] = rows.Count, + ["loadedRowCount"] = loadedRows.Count, + ["rowTruncated"] = rowTruncated, + ["maxPreviewRows"] = MaxPreviewRows, + }, + ct); + IReadOnlyList pages = Paginate(report, rows, functions ?? DbFunctionRegistry.Empty, out bool pageTruncated); bool hasSchemaDrift = !string.Equals(source.SourceSchemaSignature, report.SourceSchemaSignature, StringComparison.Ordinal); string? warning = BuildWarning(rowTruncated, pageTruncated, hasSchemaDrift); - return new ReportPreviewResult( + var result = new ReportPreviewResult( report, source, pages, @@ -42,6 +59,38 @@ public async Task BuildPreviewAsync(ReportDefinition report hasSchemaDrift, warning, DateTime.UtcNow); + + await DispatchReportEventOrThrowAsync( + report, + source, + ReportEventKind.AfterRender, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["rowCount"] = result.TotalRows, + ["pageCount"] = result.Pages.Count, + ["isTruncated"] = result.IsTruncated, + ["hasSchemaDrift"] = result.HasSchemaDrift, + }, + ct); + + return result; + } + + private async Task DispatchReportEventOrThrowAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments, + CancellationToken ct) + { + ReportEventDispatchResult dispatchResult = await _reportEvents.DispatchAsync(report, source, eventKind, runtimeArguments, ct); + if (!dispatchResult.Succeeded) + { + string message = string.IsNullOrWhiteSpace(dispatchResult.Message) + ? $"Report event '{eventKind}' failed." + : dispatchResult.Message; + throw new InvalidOperationException(message); + } } private static string? BuildWarning(bool rowTruncated, bool pageTruncated, bool hasSchemaDrift) diff --git a/src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs new file mode 100644 index 00000000..80dd322b --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Services/NullReportEventDispatcher.cs @@ -0,0 +1,21 @@ +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; + +namespace CSharpDB.Admin.Reports.Services; + +public sealed class NullReportEventDispatcher : IReportEventDispatcher +{ + public static NullReportEventDispatcher Instance { get; } = new(); + + private NullReportEventDispatcher() + { + } + + public Task DispatchAsync( + ReportDefinition report, + ReportSourceDefinition source, + ReportEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null, + CancellationToken ct = default) + => Task.FromResult(ReportEventDispatchResult.Success()); +} diff --git a/src/CSharpDB.Admin/Components/Tabs/PipelineDesigner.razor b/src/CSharpDB.Admin/Components/Tabs/PipelineDesigner.razor index 49ea1655..f997c8f3 100644 --- a/src/CSharpDB.Admin/Components/Tabs/PipelineDesigner.razor +++ b/src/CSharpDB.Admin/Components/Tabs/PipelineDesigner.razor @@ -749,6 +749,7 @@ _state.IncrementalEnabled = false; _state.IncrementalWatermarkColumn = string.Empty; _state.IncrementalLastProcessedValue = string.Empty; + _state.Hooks = []; _state.Transforms.Clear(); _sourceFileColumns = []; _sourcePreviewError = null; @@ -787,6 +788,7 @@ _state.IncrementalEnabled = package.Incremental is not null; _state.IncrementalWatermarkColumn = package.Incremental?.WatermarkColumn ?? string.Empty; _state.IncrementalLastProcessedValue = package.Incremental?.LastProcessedValue ?? string.Empty; + _state.Hooks = package.Hooks ?? []; _state.Transforms.Clear(); foreach (var transform in transforms) @@ -1042,6 +1044,7 @@ LastProcessedValue = NullIfBlank(_state.IncrementalLastProcessedValue), } : null, + Hooks = _state.Hooks, }; } @@ -1748,6 +1751,7 @@ public bool IncrementalEnabled { get; set; } public string IncrementalWatermarkColumn { get; set; } = string.Empty; public string IncrementalLastProcessedValue { get; set; } = string.Empty; + public IReadOnlyList Hooks { get; set; } = []; public string VersionOrDefault => string.IsNullOrWhiteSpace(Version) ? "1.0.0" : Version; } diff --git a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs index 98aeff80..dcddf857 100644 --- a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs +++ b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs @@ -9,11 +9,15 @@ public sealed class CSharpDbPipelineRunner { private readonly IPipelineOrchestrator _orchestrator; - public CSharpDbPipelineRunner(ICSharpDbClient client, DbFunctionRegistry? functions = null) + public CSharpDbPipelineRunner( + ICSharpDbClient client, + DbFunctionRegistry? functions = null, + DbCommandRegistry? commands = null) : this(new PipelineOrchestrator( new CSharpDbPipelineComponentFactory(client, functions), new CSharpDbPipelineCheckpointStore(client), - new CSharpDbPipelineRunLogger(client))) + new CSharpDbPipelineRunLogger(client), + commands)) { } diff --git a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineStorage.cs b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineStorage.cs index c70d185e..e3c71133 100644 --- a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineStorage.cs +++ b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineStorage.cs @@ -493,6 +493,7 @@ public async Task SavePipelineAsync(PipelinePackageDe Destination = package.Destination, Options = package.Options, Incremental = package.Incremental, + Hooks = package.Hooks, }; string packageJson = PipelinePackageSerializer.Serialize(normalized); diff --git a/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs b/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs index ed1bb8de..75dab8db 100644 --- a/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs +++ b/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs @@ -12,6 +12,23 @@ public sealed class PipelinePackageDefinition public PipelineDestinationDefinition Destination { get; init; } = new(); public PipelineExecutionOptions Options { get; init; } = new(); public PipelineIncrementalOptions? Incremental { get; init; } + public IReadOnlyList Hooks { get; init; } = []; +} + +public enum PipelineCommandHookEvent +{ + OnRunStarted, + OnBatchCompleted, + OnRunSucceeded, + OnRunFailed, +} + +public sealed class PipelineCommandHookDefinition +{ + public PipelineCommandHookEvent Event { get; init; } + public string CommandName { get; init; } = string.Empty; + public IReadOnlyDictionary? Arguments { get; init; } + public bool StopOnFailure { get; init; } = true; } public enum PipelineSourceKind diff --git a/src/CSharpDB.Pipelines/README.md b/src/CSharpDB.Pipelines/README.md index a91b3fc7..77b9786f 100644 --- a/src/CSharpDB.Pipelines/README.md +++ b/src/CSharpDB.Pipelines/README.md @@ -14,6 +14,9 @@ runtime for batch ETL work. A pipeline package describes the source, transformations, destination, execution options, and optional incremental state. The built-in runtime can validate packages, serialize them to JSON, execute them in batches, capture checkpoints, and report rejects and run metrics. +Packages can also name trusted host commands for lifecycle hooks; command bodies +are registered by the process that runs the pipeline and are not serialized into +the package. Current boundary: - Built-in runtime components currently support CSV and JSON file sources/destinations @@ -29,6 +32,7 @@ Current boundary: - **Built-in connectors**: CSV and JSON file readers/writers - **Built-in transforms**: select, rename, cast, filter, derive, deduplicate - **Checkpointing hooks**: pluggable checkpoint store and run logger abstractions +- **Trusted command hooks**: host-registered commands for run started, batch completed, run succeeded, and run failed events - **Batch metrics**: rows read/written/rejected plus batch counts ## Usage @@ -118,6 +122,18 @@ var package = new PipelinePackageDefinition CheckpointInterval = 1, ErrorMode = PipelineErrorMode.FailFast, }, + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "NotifyPipeline", + Arguments = new Dictionary + { + ["channel"] = "ops", + }, + }, + ], }; PipelineValidationResult validation = PipelinePackageValidator.Validate(package); @@ -135,7 +151,17 @@ PipelinePackageDefinition loadedPackage = var orchestrator = new PipelineOrchestrator( new DefaultPipelineComponentFactory(), new NullPipelineCheckpointStore(), - new NullPipelineRunLogger()); + new NullPipelineRunLogger(), + DbCommandRegistry.Create(commands => + { + commands.AddCommand("NotifyPipeline", static context => + { + string pipelineName = context.Metadata["pipelineName"]; + long rowsWritten = context.Arguments["rowsWritten"].AsInteger; + Console.WriteLine($"{pipelineName}: {rowsWritten} row(s) written."); + return DbCommandResult.Success(); + }); + })); PipelineRunResult result = await orchestrator.ExecuteAsync(new PipelineRunRequest { @@ -184,6 +210,7 @@ The output file contains the active customers only, with duplicate IDs removed: - Use `NullPipelineCheckpointStore` and `NullPipelineRunLogger` when you want a minimal in-process setup - Relative source file paths are searched from the current directory and app base directory; relative output paths are written relative to the current directory - `Derive` expressions are intentionally simple today: use a source column name or a literal such as `'csv'`, `123`, `true`, or `null` +- Trusted command hooks are skipped in `Validate` mode. Missing command registration or a failing hook with `StopOnFailure = true` fails the run through `PipelineRunResult`. ## Installation diff --git a/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs b/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs index 3f77aa05..122e3b48 100644 --- a/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs +++ b/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs @@ -1,5 +1,6 @@ using CSharpDB.Pipelines.Models; using CSharpDB.Pipelines.Validation; +using CSharpDB.Primitives; using System.Text.Json; namespace CSharpDB.Pipelines.Runtime; @@ -9,15 +10,18 @@ public sealed class PipelineOrchestrator : IPipelineOrchestrator private readonly IPipelineComponentFactory _componentFactory; private readonly IPipelineCheckpointStore _checkpointStore; private readonly IPipelineRunLogger _runLogger; + private readonly DbCommandRegistry _commands; public PipelineOrchestrator( IPipelineComponentFactory componentFactory, IPipelineCheckpointStore checkpointStore, - IPipelineRunLogger runLogger) + IPipelineRunLogger runLogger, + DbCommandRegistry? commands = null) { _componentFactory = componentFactory; _checkpointStore = checkpointStore; _runLogger = runLogger; + _commands = commands ?? DbCommandRegistry.Empty; } public async Task ExecuteAsync(PipelineRunRequest request, CancellationToken ct = default) @@ -87,6 +91,16 @@ public async Task ExecuteAsync(PipelineRunRequest request, Ca return validateResult; } + currentStep = "command-hook"; + currentComponent = PipelineCommandHookEvent.OnRunStarted.ToString(); + await DispatchHooksAsync( + request.Package, + PipelineCommandHookEvent.OnRunStarted, + context, + metrics, + status: PipelineRunStatus.Running.ToString(), + ct: ct); + var source = _componentFactory.CreateSource(request.Package.Source); var transforms = _componentFactory.CreateTransforms(request.Package.Transforms); var destination = _componentFactory.CreateDestination(request.Package.Destination); @@ -208,6 +222,18 @@ public async Task ExecuteAsync(PipelineRunRequest request, Ca $"Pipeline rejected {metrics.RowsRejected} row(s), exceeding MaxRejects={request.Package.Options.MaxRejects}."); } + currentStep = "command-hook"; + currentComponent = PipelineCommandHookEvent.OnBatchCompleted.ToString(); + currentBatch = sourceBatch; + await DispatchHooksAsync( + request.Package, + PipelineCommandHookEvent.OnBatchCompleted, + context, + metrics, + sourceBatch, + PipelineRunStatus.Running.ToString(), + ct: ct); + await _runLogger.StatusChangedAsync(runId, PipelineRunStatus.Running, metrics, ct); } @@ -230,6 +256,17 @@ public async Task ExecuteAsync(PipelineRunRequest request, Ca Checkpoint = latestCheckpoint, }; + currentStep = "command-hook"; + currentComponent = PipelineCommandHookEvent.OnRunSucceeded.ToString(); + currentBatch = null; + await DispatchHooksAsync( + request.Package, + PipelineCommandHookEvent.OnRunSucceeded, + context, + metrics, + status: PipelineRunStatus.Succeeded.ToString(), + ct: ct); + await _runLogger.RunCompletedAsync(result, ct); return result; } @@ -239,6 +276,27 @@ public async Task ExecuteAsync(PipelineRunRequest request, Ca } catch (Exception ex) { + string errorSummary = FormatErrorSummary(currentStep, currentComponent, currentBatch, ex); + try + { + await DispatchHooksAsync( + request.Package, + PipelineCommandHookEvent.OnRunFailed, + context, + metrics, + currentBatch, + PipelineRunStatus.Failed.ToString(), + errorSummary, + ct); + } + catch (Exception hookEx) + { + errorSummary = string.Join( + Environment.NewLine, + errorSummary, + $"Run-failed hook error: {hookEx.Message}"); + } + var failedResult = new PipelineRunResult { RunId = runId, @@ -249,7 +307,7 @@ public async Task ExecuteAsync(PipelineRunRequest request, Ca CompletedUtc = DateTimeOffset.UtcNow, Metrics = metrics, Checkpoint = latestCheckpoint, - ErrorSummary = FormatErrorSummary(currentStep, currentComponent, currentBatch, ex), + ErrorSummary = errorSummary, }; await _runLogger.RunCompletedAsync(failedResult, ct); @@ -257,6 +315,106 @@ public async Task ExecuteAsync(PipelineRunRequest request, Ca } } + private async Task DispatchHooksAsync( + PipelinePackageDefinition package, + PipelineCommandHookEvent eventKind, + PipelineExecutionContext context, + PipelineRunMetrics metrics, + PipelineRowBatch? batch = null, + string? status = null, + string? errorSummary = null, + CancellationToken ct = default) + { + IReadOnlyList hooks = package.Hooks ?? []; + foreach (PipelineCommandHookDefinition hook in hooks.Where(hook => hook.Event == eventKind)) + { + if (string.IsNullOrWhiteSpace(hook.CommandName)) + throw new InvalidOperationException($"Pipeline hook '{eventKind}' has an empty command name."); + + if (!_commands.TryGetCommand(hook.CommandName, out DbCommandDefinition definition)) + throw new InvalidOperationException($"Unknown pipeline command '{hook.CommandName}' for hook '{eventKind}'."); + + Dictionary arguments = DbCommandArguments.FromObjectDictionary( + BuildHookArguments(package, eventKind, context.RunId, context.Mode, metrics, batch, status, errorSummary), + hook.Arguments); + Dictionary metadata = BuildHookMetadata(package, eventKind, context.RunId, context.Mode); + + DbCommandResult result; + try + { + result = await definition.InvokeAsync(arguments, metadata, ct); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Pipeline hook '{eventKind}' command '{definition.Name}' failed: {ex.Message}", + ex); + } + + if (!result.Succeeded && hook.StopOnFailure) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Pipeline hook '{eventKind}' command '{definition.Name}' failed." + : result.Message; + throw new InvalidOperationException(message); + } + } + } + + private static Dictionary BuildHookArguments( + PipelinePackageDefinition package, + PipelineCommandHookEvent eventKind, + string runId, + PipelineExecutionMode mode, + PipelineRunMetrics metrics, + PipelineRowBatch? batch, + string? status, + string? errorSummary) + { + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["runId"] = runId, + ["pipelineName"] = package.Name, + ["pipelineVersion"] = package.Version, + ["mode"] = mode.ToString(), + ["event"] = eventKind.ToString(), + ["rowsRead"] = metrics.RowsRead, + ["rowsWritten"] = metrics.RowsWritten, + ["rowsRejected"] = metrics.RowsRejected, + ["batchesCompleted"] = metrics.BatchesCompleted, + }; + + if (!string.IsNullOrWhiteSpace(status)) + arguments["status"] = status; + + if (!string.IsNullOrWhiteSpace(errorSummary)) + arguments["errorSummary"] = errorSummary; + + if (batch is not null) + { + arguments["batchNumber"] = batch.BatchNumber; + arguments["startingRowNumber"] = batch.StartingRowNumber; + arguments["batchRowCount"] = batch.Rows.Count; + } + + return arguments; + } + + private static Dictionary BuildHookMetadata( + PipelinePackageDefinition package, + PipelineCommandHookEvent eventKind, + string runId, + PipelineExecutionMode mode) + => new(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "Pipelines", + ["pipelineName"] = package.Name, + ["pipelineVersion"] = package.Version, + ["runId"] = runId, + ["mode"] = mode.ToString(), + ["event"] = eventKind.ToString(), + }; + private static string FormatErrorSummary(string step, string? component, PipelineRowBatch? batch, Exception exception) { var segments = new List diff --git a/src/CSharpDB.Pipelines/Serialization/ObjectDictionaryConverter.cs b/src/CSharpDB.Pipelines/Serialization/ObjectDictionaryConverter.cs new file mode 100644 index 00000000..e3092904 --- /dev/null +++ b/src/CSharpDB.Pipelines/Serialization/ObjectDictionaryConverter.cs @@ -0,0 +1,111 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CSharpDB.Pipelines.Serialization; + +internal sealed class ObjectDictionaryConverter : JsonConverter> +{ + public override IReadOnlyDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("Expected StartObject."); + + return ReadDictionary(ref reader); + } + + public override void Write(Utf8JsonWriter writer, IReadOnlyDictionary value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach ((string key, object? item) in value) + { + writer.WritePropertyName(key); + WriteValue(writer, item, options); + } + + writer.WriteEndObject(); + } + + private static void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case bool boolValue: + writer.WriteBooleanValue(boolValue); + break; + case byte or sbyte or short or ushort or int or uint or long: + writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case float or double or decimal: + writer.WriteNumberValue(Convert.ToDouble(value, CultureInfo.InvariantCulture)); + break; + case string text: + writer.WriteStringValue(text); + break; + default: + JsonSerializer.Serialize(writer, value, options); + break; + } + } + + private static Dictionary ReadDictionary(ref Utf8JsonReader reader) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + return dict; + + if (reader.TokenType != JsonTokenType.PropertyName) + throw new JsonException("Expected PropertyName."); + + string key = reader.GetString() ?? string.Empty; + reader.Read(); + dict[key] = ReadValue(ref reader); + } + + throw new JsonException("Unexpected end of JSON."); + } + + private static object? ReadValue(ref Utf8JsonReader reader) + { + return reader.TokenType switch + { + JsonTokenType.Null => null, + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => ReadNumber(ref reader), + JsonTokenType.StartObject => ReadDictionary(ref reader), + JsonTokenType.StartArray => ReadArray(ref reader), + _ => throw new JsonException($"Unexpected token {reader.TokenType}."), + }; + } + + private static object ReadNumber(ref Utf8JsonReader reader) + { + decimal number = reader.GetDecimal(); + if (number >= long.MinValue && number <= long.MaxValue && decimal.Truncate(number) == number) + return (long)number; + + return (double)number; + } + + private static object?[] ReadArray(ref Utf8JsonReader reader) + { + var values = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + return values.ToArray(); + + values.Add(ReadValue(ref reader)); + } + + throw new JsonException("Unexpected end of JSON in array."); + } +} diff --git a/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs b/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs index 72c2809b..4de18169 100644 --- a/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs +++ b/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs @@ -13,6 +13,7 @@ public static class PipelinePackageSerializer PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { + new ObjectDictionaryConverter(), new JsonStringEnumConverter(JsonNamingPolicy.CamelCase), }, }; diff --git a/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs b/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs index 7d7df869..07e56894 100644 --- a/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs +++ b/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs @@ -25,6 +25,7 @@ public static PipelineValidationResult Validate(PipelinePackageDefinition packag ValidateOptions(package.Options, errors); ValidateIncremental(package.Incremental, errors); ValidateTransforms(package.Transforms, errors); + ValidateHooks(package.Hooks, errors); return errors.Count == 0 ? PipelineValidationResult.Success @@ -216,6 +217,35 @@ private static void ValidateTransforms(IReadOnlyList? hooks, List errors) + { + if (hooks is null) + return; + + for (int i = 0; i < hooks.Count; i++) + { + PipelineCommandHookDefinition hook = hooks[i]; + string path = $"hooks[{i}]"; + + if (string.IsNullOrWhiteSpace(hook.CommandName)) + { + errors.Add(Error("pipeline.hook.command.required", $"{path}.commandName", "Pipeline command hooks require a command name.")); + } + + if (hook.Arguments is null) + continue; + + foreach (string key in hook.Arguments.Keys) + { + if (string.IsNullOrWhiteSpace(key)) + { + errors.Add(Error("pipeline.hook.argument.name.required", $"{path}.arguments", "Pipeline command hook arguments require non-empty names.")); + break; + } + } + } + } + private static void ValidateFunctionSyntax(string expression, string path, List errors) { bool inString = false; diff --git a/src/CSharpDB.Primitives/DbCommandArguments.cs b/src/CSharpDB.Primitives/DbCommandArguments.cs new file mode 100644 index 00000000..7257bfb4 --- /dev/null +++ b/src/CSharpDB.Primitives/DbCommandArguments.cs @@ -0,0 +1,61 @@ +using System.Globalization; + +namespace CSharpDB.Primitives; + +public static class DbCommandArguments +{ + public static Dictionary FromObjectDictionary( + IReadOnlyDictionary? first, + IReadOnlyDictionary? second = null) + { + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); + AddDictionary(arguments, first); + AddDictionary(arguments, second); + return arguments; + } + + public static DbValue FromObject(object? value) => value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolValue => DbValue.FromInteger(boolValue ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float floatValue when IsIntegerLike(floatValue) => DbValue.FromInteger((long)floatValue), + double doubleValue when IsIntegerLike(doubleValue) => DbValue.FromInteger((long)doubleValue), + decimal decimalValue when IsIntegerLike(decimalValue) => DbValue.FromInteger((long)decimalValue), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + Guid guid => DbValue.FromText(guid.ToString("D")), + DateOnly date => DbValue.FromText(date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + DateTime dateTime => DbValue.FromText(dateTime.ToString("O", CultureInfo.InvariantCulture)), + DateTimeOffset dateTimeOffset => DbValue.FromText(dateTimeOffset.ToString("O", CultureInfo.InvariantCulture)), + byte[] bytes => DbValue.FromBlob(bytes), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + + private static bool IsIntegerLike(double value) + => !double.IsNaN(value) + && !double.IsInfinity(value) + && value >= long.MinValue + && value <= long.MaxValue + && Math.Truncate(value) == value; + + private static bool IsIntegerLike(decimal value) + => value >= long.MinValue + && value <= long.MaxValue + && decimal.Truncate(value) == value; + + private static void AddDictionary( + Dictionary arguments, + IReadOnlyDictionary? source) + { + if (source is null) + return; + + foreach ((string key, object? value) in source) + { + if (!string.IsNullOrWhiteSpace(key)) + arguments[key] = FromObject(value); + } + } +} diff --git a/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs b/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs new file mode 100644 index 00000000..4e859dda --- /dev/null +++ b/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs @@ -0,0 +1,87 @@ +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Reports.Tests.Services; + +public sealed class DefaultReportEventDispatcherTests +{ + [Fact] + public async Task DispatchAsync_InvokesMatchingCommandsWithRuntimeArgumentsAndMetadata() + { + DbCommandContext? captured = null; + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditReport", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultReportEventDispatcher(commands); + ReportDefinition report = CreateReport([ + new ReportEventBinding( + ReportEventKind.BeforeRender, + "AuditReport", + new Dictionary { ["Source"] = "configured" }), + ]); + + ReportEventDispatchResult result = await dispatcher.DispatchAsync( + report, + CreateSource(), + ReportEventKind.BeforeRender, + new Dictionary { ["RowCount"] = 4, ["Source"] = "runtime" }, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.Equal("AdminReports", captured!.Metadata["surface"]); + Assert.Equal("sales-report", captured.Metadata["reportId"]); + Assert.Equal("Sales", captured.Metadata["sourceName"]); + Assert.Equal("BeforeRender", captured.Metadata["event"]); + Assert.Equal(4, captured.Arguments["RowCount"].AsInteger); + Assert.Equal("configured", captured.Arguments["Source"].AsText); + } + + [Fact] + public async Task DispatchAsync_StopsOnCommandFailureByDefault() + { + var commands = DbCommandRegistry.Create(builder => + builder.AddCommand("Reject", _ => DbCommandResult.Failure("Report rejected."))); + var dispatcher = new DefaultReportEventDispatcher(commands); + ReportDefinition report = CreateReport([new ReportEventBinding(ReportEventKind.OnOpen, "Reject")]); + + ReportEventDispatchResult result = await dispatcher.DispatchAsync( + report, + CreateSource(), + ReportEventKind.OnOpen, + ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Equal("Report rejected.", result.Message); + } + + private static ReportDefinition CreateReport(IReadOnlyList eventBindings) + => new( + "sales-report", + "Sales Report", + new ReportSourceReference(ReportSourceKind.Table, "Sales"), + DefinitionVersion: 1, + SourceSchemaSignature: "sales:v1", + PageSettings: ReportPageSettings.DefaultLetterPortrait, + Groups: [], + Sorts: [], + Bands: [], + EventBindings: eventBindings); + + private static ReportSourceDefinition CreateSource() + => new( + ReportSourceKind.Table, + "Sales", + "Sales", + "SELECT * FROM Sales", + "sales:v1", + []); +} diff --git a/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportPreviewServiceTests.cs b/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportPreviewServiceTests.cs index eb3c26ad..edf94902 100644 --- a/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportPreviewServiceTests.cs +++ b/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportPreviewServiceTests.cs @@ -1,6 +1,7 @@ using System.Text; using CSharpDB.Admin.Reports.Models; using CSharpDB.Admin.Reports.Services; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Reports.Tests.Services; @@ -70,6 +71,74 @@ public async Task BuildPreviewAsync_PageCapSetsTruncationWarning() Assert.Contains("250 pages", result.WarningMessage, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task BuildPreviewAsync_DispatchesReportLifecycleEvents() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await CreateSalesSchemaAsync(db); + var provider = new DbReportSourceProvider(db.Client); + var generator = new DefaultReportGenerator(); + var source = (await provider.GetSourceDefinitionAsync(new ReportSourceReference(ReportSourceKind.Table, "Sales")))!; + var captured = new List(); + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("RecordReportEvent", context => + { + captured.Add(context); + return DbCommandResult.Success(); + }); + }); + var previewService = new DefaultReportPreviewService( + db.Client, + provider, + reportEvents: new DefaultReportEventDispatcher(commands)); + + ReportDefinition report = generator.GenerateDefault(source) with + { + EventBindings = + [ + new ReportEventBinding(ReportEventKind.OnOpen, "RecordReportEvent"), + new ReportEventBinding(ReportEventKind.BeforeRender, "RecordReportEvent", new Dictionary { ["configured"] = "yes" }), + new ReportEventBinding(ReportEventKind.AfterRender, "RecordReportEvent"), + ], + }; + + ReportPreviewResult result = await previewService.BuildPreviewAsync(report, TestContext.Current.CancellationToken); + + Assert.Equal(4, result.TotalRows); + Assert.Equal(["OnOpen", "BeforeRender", "AfterRender"], captured.Select(context => context.Metadata["event"]).ToArray()); + Assert.All(captured, context => Assert.Equal("AdminReports", context.Metadata["surface"])); + Assert.Equal(4, captured[1].Arguments["rowCount"].AsInteger); + Assert.Equal("yes", captured[1].Arguments["configured"].AsText); + Assert.Equal(result.Pages.Count, captured[2].Arguments["pageCount"].AsInteger); + Assert.Equal(0, captured[2].Arguments["hasSchemaDrift"].AsInteger); + } + + [Fact] + public async Task BuildPreviewAsync_FailsWhenReportEventCommandFails() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await CreateSalesSchemaAsync(db); + var provider = new DbReportSourceProvider(db.Client); + var generator = new DefaultReportGenerator(); + var source = (await provider.GetSourceDefinitionAsync(new ReportSourceReference(ReportSourceKind.Table, "Sales")))!; + var commands = DbCommandRegistry.Create(builder => + builder.AddCommand("RejectReport", _ => DbCommandResult.Failure("Rejected by host command."))); + var previewService = new DefaultReportPreviewService( + db.Client, + provider, + reportEvents: new DefaultReportEventDispatcher(commands)); + + ReportDefinition report = generator.GenerateDefault(source) with + { + EventBindings = [new ReportEventBinding(ReportEventKind.OnOpen, "RejectReport")], + }; + + InvalidOperationException ex = await Assert.ThrowsAsync( + () => previewService.BuildPreviewAsync(report, TestContext.Current.CancellationToken)); + Assert.Contains("Rejected by host command.", ex.Message); + } + private static async Task CreateSalesSchemaAsync(TestDatabaseScope db) { await db.ExecuteAsync( diff --git a/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs index 74979b0b..dcfe44fa 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; using CSharpDB.Pipelines.Models; using CSharpDB.Pipelines.Runtime; +using CSharpDB.Primitives; namespace CSharpDB.Pipelines.Tests; @@ -238,9 +239,143 @@ public async Task ExecuteAsync_SkipBadRows_RespectsMaxRejects() Assert.Equal(1, checkpointStore.Saved[0].BatchNumber); } + [Fact] + public async Task ExecuteAsync_RunMode_DispatchesCommandHooksWithMetricsAndMetadata() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var source = new FakeSource( + [ + CreateBatch(1, 1, 2), + CreateBatch(2, 3), + ]); + var captured = new List(); + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("RecordHook", context => + { + captured.Add(context); + return DbCommandResult.Success(); + }); + }); + var orchestrator = new PipelineOrchestrator( + new FakeComponentFactory(source, new FakeDestination(), []), + new RecordingCheckpointStore(), + new RecordingRunLogger(), + commands); + + PipelineRunResult result = await orchestrator.ExecuteAsync(new PipelineRunRequest + { + Package = CreatePackage(hooks: + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunStarted, + CommandName = "RecordHook", + Arguments = new Dictionary { ["configured"] = "start" }, + }, + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnBatchCompleted, + CommandName = "RecordHook", + }, + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "RecordHook", + }, + ]), + Mode = PipelineExecutionMode.Run, + }, ct); + + Assert.Equal(PipelineRunStatus.Succeeded, result.Status); + Assert.Equal(["OnRunStarted", "OnBatchCompleted", "OnBatchCompleted", "OnRunSucceeded"], captured.Select(context => context.Metadata["event"]).ToArray()); + Assert.All(captured, context => Assert.Equal("Pipelines", context.Metadata["surface"])); + Assert.Equal("start", captured[0].Arguments["configured"].AsText); + Assert.Equal(2, captured[1].Arguments["rowsRead"].AsInteger); + Assert.Equal(2, captured[1].Arguments["rowsWritten"].AsInteger); + Assert.Equal(1, captured[1].Arguments["batchNumber"].AsInteger); + Assert.Equal(3, captured[2].Arguments["rowsRead"].AsInteger); + Assert.Equal("Succeeded", captured[3].Arguments["status"].AsText); + } + + [Fact] + public async Task ExecuteAsync_OnRunFailedHookRunsWhenPipelineFails() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var source = new FakeSource([CreateBatch(3, 41, 42)]); + var transform = new ThrowingTransform("cast", "Cannot parse integer."); + DbCommandContext? captured = null; + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("RecordFailure", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + var orchestrator = new PipelineOrchestrator( + new FakeComponentFactory(source, new FakeDestination(), [transform]), + new RecordingCheckpointStore(), + new RecordingRunLogger(), + commands); + + PipelineRunResult result = await orchestrator.ExecuteAsync(new PipelineRunRequest + { + Package = CreatePackage( + errorMode: PipelineErrorMode.FailFast, + hooks: + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunFailed, + CommandName = "RecordFailure", + }, + ]), + Mode = PipelineExecutionMode.Run, + }, ct); + + Assert.Equal(PipelineRunStatus.Failed, result.Status); + Assert.NotNull(captured); + Assert.Equal("OnRunFailed", captured!.Metadata["event"]); + Assert.Equal("Failed", captured.Arguments["status"].AsText); + Assert.Contains("Cannot parse integer.", captured.Arguments["errorSummary"].AsText); + } + + [Fact] + public async Task ExecuteAsync_CommandHookFailureReturnsFailedRun() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var commands = DbCommandRegistry.Create(builder => + builder.AddCommand("RejectHook", _ => DbCommandResult.Failure("Hook rejected run."))); + var orchestrator = new PipelineOrchestrator( + new FakeComponentFactory(new FakeSource([CreateBatch(1, 1)]), new FakeDestination(), []), + new RecordingCheckpointStore(), + new RecordingRunLogger(), + commands); + + PipelineRunResult result = await orchestrator.ExecuteAsync(new PipelineRunRequest + { + Package = CreatePackage(hooks: + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunStarted, + CommandName = "RejectHook", + }, + ]), + Mode = PipelineExecutionMode.Run, + }, ct); + + Assert.Equal(PipelineRunStatus.Failed, result.Status); + Assert.Contains("Step: command-hook", result.ErrorSummary); + Assert.Contains("Component: OnRunStarted", result.ErrorSummary); + Assert.Contains("Hook rejected run.", result.ErrorSummary); + } + private static PipelinePackageDefinition CreatePackage( PipelineErrorMode errorMode = PipelineErrorMode.SkipBadRows, - int maxRejects = 10) => new() + int maxRejects = 10, + IReadOnlyList? hooks = null) => new() { Name = "customers-import", Version = "1.0.0", @@ -261,6 +396,7 @@ private static PipelinePackageDefinition CreatePackage( ErrorMode = errorMode, MaxRejects = errorMode == PipelineErrorMode.SkipBadRows ? maxRejects : 0, }, + Hooks = hooks ?? [], }; private static PipelineRowBatch CreateBatch(long batchNumber, params int[] ids) => new() diff --git a/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs index 17faa734..6dd132f3 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs @@ -17,6 +17,7 @@ public void Serialize_UsesCamelCaseAndEnumStrings() Assert.Contains("\"errorMode\": \"skipBadRows\"", json); Assert.Contains("\"kind\": \"csvFile\"", json); Assert.Contains("\"targetType\": \"integer\"", json); + Assert.Contains("\"event\": \"onRunSucceeded\"", json); } [Fact] @@ -36,6 +37,11 @@ public void Deserialize_RoundTripsPackage() Assert.Equal(package.Options.ErrorMode, clone.Options.ErrorMode); Assert.Equal(package.Transforms.Count, clone.Transforms.Count); Assert.Equal(package.Incremental?.WatermarkColumn, clone.Incremental?.WatermarkColumn); + PipelineCommandHookDefinition hook = Assert.Single(clone.Hooks); + Assert.Equal(PipelineCommandHookEvent.OnRunSucceeded, hook.Event); + Assert.Equal("NotifyImport", hook.CommandName); + Assert.Equal("ops", Assert.IsType(hook.Arguments!["channel"])); + Assert.Equal(3, DbCommandArguments.FromObject(hook.Arguments["priority"]).AsInteger); } [Fact] @@ -50,9 +56,10 @@ public async Task SaveToFileAsync_AndLoadFromFileAsync_RoundTripPackage() await PipelinePackageSerializer.SaveToFileAsync(package, path, ct); PipelinePackageDefinition loaded = await PipelinePackageSerializer.LoadFromFileAsync(path, ct); - Assert.Equal(package.Name, loaded.Name); - Assert.Equal(package.Transforms.Count, loaded.Transforms.Count); - Assert.Equal(package.Options.BatchSize, loaded.Options.BatchSize); + Assert.Equal(package.Name, loaded.Name); + Assert.Equal(package.Transforms.Count, loaded.Transforms.Count); + Assert.Equal(package.Options.BatchSize, loaded.Options.BatchSize); + Assert.Single(loaded.Hooks); } finally { @@ -118,5 +125,18 @@ public async Task SaveToFileAsync_AndLoadFromFileAsync_RoundTripPackage() WatermarkColumn = "updated_at", LastProcessedValue = "2026-01-01T00:00:00Z", }, + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "NotifyImport", + Arguments = new Dictionary + { + ["channel"] = "ops", + ["priority"] = 3, + }, + }, + ], }; } diff --git a/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs index 14916814..61960faa 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs @@ -189,6 +189,61 @@ public void Validate_ReturnsError_WhenFunctionSyntaxIsMalformed() Assert.Contains(result.Errors, e => e.Code == "pipeline.expression.function.syntax"); } + [Fact] + public void Validate_ReturnsError_WhenHookCommandNameIsMissing() + { + var validPackage = CreateValidPackage(); + var package = new PipelinePackageDefinition + { + Name = validPackage.Name, + Version = validPackage.Version, + Source = validPackage.Source, + Destination = validPackage.Destination, + Options = validPackage.Options, + Transforms = validPackage.Transforms, + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = " ", + }, + ], + }; + + PipelineValidationResult result = PipelinePackageValidator.Validate(package); + + Assert.Contains(result.Errors, e => e.Code == "pipeline.hook.command.required"); + } + + [Fact] + public void Validate_ReturnsError_WhenHookArgumentNameIsMissing() + { + var validPackage = CreateValidPackage(); + var package = new PipelinePackageDefinition + { + Name = validPackage.Name, + Version = validPackage.Version, + Source = validPackage.Source, + Destination = validPackage.Destination, + Options = validPackage.Options, + Transforms = validPackage.Transforms, + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "Notify", + Arguments = new Dictionary { [" "] = "invalid" }, + }, + ], + }; + + PipelineValidationResult result = PipelinePackageValidator.Validate(package); + + Assert.Contains(result.Errors, e => e.Code == "pipeline.hook.argument.name.required"); + } + [Fact] public void Validate_ReturnsMultipleErrors_ForCompoundInvalidPackage() { diff --git a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs index 577c2d4c..614f4453 100644 --- a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs +++ b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs @@ -71,4 +71,32 @@ static async (context, ct) => Assert.True(result.Succeeded); Assert.Equal("AfterUpdate", result.Message); } + + [Fact] + public void CommandArguments_ConvertObjectDictionariesAndLetConfiguredValuesOverrideRuntimeValues() + { + Dictionary arguments = DbCommandArguments.FromObjectDictionary( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Id"] = 7, + ["IsActive"] = true, + ["Total"] = 12.5m, + ["JsonInteger"] = 3.0d, + ["Name"] = "Alice", + [""] = "ignored", + }, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Name"] = "Override", + ["NullValue"] = null, + }); + + Assert.Equal(7, arguments["Id"].AsInteger); + Assert.Equal(1, arguments["IsActive"].AsInteger); + Assert.Equal(12.5, arguments["Total"].AsReal); + Assert.Equal(3, arguments["JsonInteger"].AsInteger); + Assert.Equal("Override", arguments["Name"].AsText); + Assert.True(arguments["NullValue"].IsNull); + Assert.False(arguments.ContainsKey("")); + } } From 72a8cdabe321189c246df0b35eed4ee909cd6b92 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 08:29:08 -0700 Subject: [PATCH 06/39] Add Admin Forms control command events --- RELEASE_NOTES.md | 10 +- docs/admin-forms-access-parity/README.md | 2 +- docs/roadmap.md | 8 +- docs/trusted-csharp-functions/README.md | 35 +++- .../Designer/ControlEventBindingsEditor.razor | 195 ++++++++++++++++++ .../Components/Designer/DesignerState.cs | 9 + .../Components/Designer/FormRenderer.razor | 192 +++++++++++++---- .../Designer/PropertyInspector.razor | 14 ++ .../Models/ControlDefinition.cs | 3 +- .../Models/ControlEventBinding.cs | 15 ++ src/CSharpDB.Admin.Forms/README.md | 5 +- .../Services/FormCommandInvocation.cs | 21 ++ src/CSharpDB.Primitives/DbCommandArguments.cs | 8 +- .../Components/Designer/DesignerStateTests.cs | 32 +++ .../FormRendererCommandButtonTests.cs | 80 +++++++ .../Serialization/JsonRoundtripTests.cs | 13 +- 16 files changed, 592 insertions(+), 50 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor create mode 100644 src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0d0d6cef..48573f1b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -51,6 +51,12 @@ calculated text, and pipeline filter/derive expressions. - Added a command button control that invokes a trusted host command on click, passing current record fields, optional configured arguments, and form metadata to the command callback. +- Added control-level Admin Forms event bindings for `OnClick`, `OnChange`, + `OnGotFocus`, and `OnLostFocus`, so ordinary controls can invoke trusted + host commands without being command buttons. +- The Forms property inspector now edits selected-control event bindings using + the same registered-command picker and JSON argument editor as form-level + events. - Added shared command argument conversion helpers so Forms, Reports, and Pipelines pass host command arguments with the same `DbValue` conversion rules. @@ -95,8 +101,8 @@ calculated text, and pipeline filter/derive expressions. - Added command-registry, form-event dispatcher, event JSON round-trip, and Forms data-entry tests for create/update/delete event dispatch and before-event cancellation. -- Added designer-state and command-button tests covering event binding - preservation and registered command invocation from rendered forms. +- Added designer-state, command-button, and control-event tests covering event + binding preservation and registered command invocation from rendered forms. - Added report-event dispatcher and preview lifecycle tests, pipeline hook serialization/validation/orchestrator tests, and shared command argument conversion tests. diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index fc98beb8..7e16bb62 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -101,7 +101,7 @@ Expected fix: | --- | --- | --- | | Command button control | Partial | Trusted command buttons can invoke host-registered C# commands; built-in form actions remain future work. | | Action model | Planned | Support actions such as open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, and show message. | -| Event hooks | Partial | Form lifecycle events and command-button clicks can call trusted commands; field/control event coverage remains future work. | +| Event hooks | Partial | Form lifecycle events, command-button clicks, and selected control events can call trusted commands; additional Access-style events remain future work. | | Conditional UI rules | Planned | Add visible/enabled/read-only expressions for controls. | ### Phase 5: Broader Control and Property Coverage diff --git a/docs/roadmap.md b/docs/roadmap.md index 5b36d617..a87bbcf5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms event bindings, command-button clicks, Admin Reports render lifecycle events, and pipeline run hooks; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro action scripts, broader control events, and sandboxed UDFs remain future work | Partial | +| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms lifecycle events, command-button clicks, selected control events, Admin Reports render lifecycle events, and pipeline run hooks; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro action scripts, additional Access-style control events, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -56,7 +56,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | **NuGet package** | Publish and maintain `CSharpDB.Engine`, `CSharpDB.Data`, `CSharpDB.Client`, and `CSharpDB.Primitives` as the primary NuGet packages | Done | | **Connection pooling** | Pool underlying direct embedded sessions behind `CSharpDbConnection` to amortize open/close cost | Done | | **Admin dashboard improvements** | Richer SQL editor UX, query history, deeper diagnostics, and integrated Forms/Reports tooling beyond the core schema/procedure/storage surface | Done | -| **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, broader action/event coverage, and broader control coverage; trusted command-backed form events and command buttons are now started | Partial | +| **Admin Forms Access parity** | Close the highest-impact Access-style form gaps: runtime responsive layouts, full inferred validation enforcement, richer record-source/filter/sort models, Layout View, form modes, broader action/event coverage, and broader control coverage; trusted command-backed form lifecycle events, command buttons, and selected control events are now started | Partial | | **Admin Reports Access parity** | Close the highest-impact Access-style report gaps: bounded saved-query previews, full report rendering/export, parameter/filter prompts, richer grouping/totals options, Layout View, conditional formatting, subreports, and broader report controls; trusted command-backed report preview lifecycle events are now started | Partial | | **Visual query designer** | Classic Admin query builder with source canvas, join editing, design grid, SQL preview, and saved designer layouts | Done | | **ETL pipelines** | Built-in package-driven pipeline runtime with validation, dry-run, execute/resume flows, API/CLI/client coverage, run history, and Admin visual designer support | Done | @@ -96,7 +96,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host; Admin Forms, Admin Reports, and pipelines can invoke trusted host commands from supported event/hook surfaces; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, broader control-level form events, macro action scripts, and sandboxed UDFs are not implemented | +| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host; Admin Forms, Admin Reports, and pipelines can invoke trusted host commands from supported event/hook surfaces; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, additional Access-style control events, macro action scripts, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | @@ -106,7 +106,7 @@ These are known simplifications in the current implementation: | **Collections** | `FindByIndexAsync` supports declared field-equality lookups; `FindByPathAsync` and `FindByPathRangeAsync` support path-based queries on indexed paths; `FindAsync` remains a full scan for unindexed predicates | | **Networking** | `CSharpDB.Daemon` now hosts both REST and gRPC from one process; named pipes remain reserved but are not implemented end to end today | | **Security** | Remote HTTP and gRPC deployment still rely on external network controls or front-end TLS termination; built-in authentication, authorization, and TLS/mTLS support are still planned | -| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, a broader action model and control events, advanced filtering/sorting, and broader controls | +| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, including lifecycle events, command buttons, and selected control events, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, a broader action model, additional events, advanced filtering/sorting, and broader controls | | **Admin Reports** | The Reports designer/runtime supports the core banded preview path plus trusted command-backed preview lifecycle events, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | | **Text / Multilingual** | Text is stored as UTF-8 and supports all Unicode languages; default semantics remain ordinal, but opt-in `BINARY`, `NOCASE`, `NOCASE_AI`, and `ICU:` collation are implemented for SQL and collection indexes. Dedicated ordered SQL text index optimization remains planned | | **Concurrency** | The physical WAL commit path is still serialized at the storage boundary. Initial multi-writer support is shipped, but observed gains still depend on conflict shape and whether shared auto-commit `INSERT` is left on the default serialized path | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 69a8f618..1110cb78 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -332,7 +332,36 @@ Command context arguments include the current record fields converted to `DbValu The Admin Forms designer preserves form event bindings and exposes them in the property inspector when no control is selected. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. -Admin Forms also include a command button control. Command buttons store a display label, a command name, and optional JSON arguments in the form definition. At runtime, clicking the button invokes the registered host command with the current record fields plus the configured arguments. +Admin Forms also include control-level trusted command events. Form controls store event names, command names, and optional JSON arguments in the form definition. At runtime, the renderer invokes the registered host command with the current record fields plus event-specific arguments. + +```csharp +var textBox = existingTextBox with +{ + EventBindings = + [ + new ControlEventBinding( + ControlEventKind.OnChange, + "NormalizeCustomerName", + new Dictionary { ["source"] = "name-textbox" }), + new ControlEventBinding(ControlEventKind.OnLostFocus, "ValidateCustomerName"), + ], +}; +``` + +Supported control events in this slice are: + +| Event | When it runs | +| --- | --- | +| `OnClick` | When a label or command button is clicked. | +| `OnChange` | After an input, checkbox, radio, select, lookup, or textarea updates its bound field. | +| `OnGotFocus` | When an interactive control receives focus. | +| `OnLostFocus` | When an interactive control loses focus. | + +Control event metadata includes the Forms metadata plus `event`, `controlId`, `controlType`, and `fieldName` for bound controls. Arguments include current record fields and event details such as `fieldName`, `value`, and `oldValue` for field changes. Static arguments configured on the event binding override same-named runtime arguments. + +The Admin Forms designer exposes selected-control event bindings in the property inspector. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. + +Admin Forms also include a command button control. Command buttons store a display label, a command name, and optional JSON arguments in the form definition. At runtime, clicking the button invokes the registered host command with the current record fields plus the configured arguments. Command buttons can also use `ControlEventKind.OnClick` bindings, which allows a button to be driven entirely by the shared control-event model. ```csharp var button = new ControlDefinition( @@ -352,7 +381,7 @@ var button = new ControlDefinition( ValidationOverride: null); ``` -Command button metadata includes the same form metadata as lifecycle events, plus `event = "Click"`, `controlId`, and `controlType`. +Command button direct-command metadata includes the same form metadata as lifecycle events, plus `event = "Click"`, `controlId`, and `controlType`. --- @@ -608,5 +637,5 @@ V1 does not support: - Passing a database handle into the function context. - Sending delegates over HTTP, gRPC, or pipeline package files. - Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. -- Broader control-level form events beyond command-button clicks. +- Additional Access-style control events such as double-click, key, mouse, timer, and dirty/current events. - Stored macro/action scripts. diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor new file mode 100644 index 00000000..16fdfe7b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor @@ -0,0 +1,195 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbCommandRegistry CommandRegistry + +
+ @if (EventBindings.Count == 0) + { +
No control events
+ } + + @for (int i = 0; i < EventBindings.Count; i++) + { + var idx = i; + var binding = EventBindings[idx]; +
+
+
+ + +
+ +
+ +
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+ +
+ +
+ +
+ + +
+
+ } + + @if (!string.IsNullOrWhiteSpace(_argumentError)) + { +
@_argumentError
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList EventBindings { get; set; } = []; + [Parameter] public EventCallback> EventBindingsChanged { get; set; } + + private readonly Dictionary _argumentText = []; + private string? _argumentError; + + private static readonly ControlEventKind[] EventKinds = Enum.GetValues(); + + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); + + protected override void OnParametersSet() + { + for (int i = 0; i < EventBindings.Count; i++) + _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); + + foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) + _argumentText.Remove(staleIndex); + } + + private async Task AddBinding() + { + string commandName = RegisteredCommands.Count > 0 ? RegisteredCommands[0].Name : string.Empty; + var updated = EventBindings + .Append(new ControlEventBinding(ControlEventKind.OnClick, commandName)) + .ToList(); + _argumentText[updated.Count - 1] = string.Empty; + await EventBindingsChanged.InvokeAsync(updated); + } + + private async Task RemoveBinding(int index) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + RebuildArgumentText(updated); + await EventBindingsChanged.InvokeAsync(updated); + } + + private Task UpdateEvent(int index, ControlEventBinding binding, string? value) + { + if (!Enum.TryParse(value, ignoreCase: true, out ControlEventKind eventKind)) + return Task.CompletedTask; + + return ReplaceBinding(index, binding with { Event = eventKind }); + } + + private Task UpdateCommand(int index, ControlEventBinding binding, string commandName) + => ReplaceBinding(index, binding with { CommandName = commandName.Trim() }); + + private Task UpdateStopOnFailure(int index, ControlEventBinding binding, bool stopOnFailure) + => ReplaceBinding(index, binding with { StopOnFailure = stopOnFailure }); + + private async Task UpdateArguments(int index, ControlEventBinding binding, string text) + { + _argumentText[index] = text; + _argumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceBinding(index, binding with { Arguments = null }); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + await ReplaceBinding(index, binding with { Arguments = arguments }); + } + catch (JsonException ex) + { + _argumentError = $"Invalid arguments JSON: {ex.Message}"; + } + } + + private async Task ReplaceBinding(int index, ControlEventBinding binding) + { + var updated = EventBindings.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = binding; + await EventBindingsChanged.InvokeAsync(updated); + } + + private string GetArgumentsText(int index, ControlEventBinding binding) + { + if (_argumentText.TryGetValue(index, out string? text)) + return text; + + text = FormatArguments(binding.Arguments); + _argumentText[index] = text; + return text; + } + + private void RebuildArgumentText(IReadOnlyList updated) + { + _argumentText.Clear(); + for (int i = 0; i < updated.Count; i++) + _argumentText[i] = FormatArguments(updated[i].Arguments); + } + + private bool ShouldRenderMissingCommand(string commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 2aa80004..a88fda1f 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -96,6 +96,15 @@ public void UpdateEventBindings(IReadOnlyList bindings) NotifyChanged(); } + public void UpdateControlEventBindings(string controlId, IReadOnlyList bindings) + { + var idx = _controls.FindIndex(c => c.ControlId == controlId); + if (idx < 0) return; + PushUndo(); + _controls[idx] = _controls[idx] with { EventBindings = bindings.ToList() }; + NotifyChanged(); + } + public void PushUndo() { _undoStack.Push(_controls.Select(c => c).ToList()); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 570d20f2..5d2b2bf7 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -15,7 +15,8 @@ @switch (c.ControlType) { case "label": - @GetProp(c, "text", "Label") + @GetProp(c, "text", "Label") break; case "text": + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))" /> break; case "number": + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, ParseNumber(e.Value)))" /> break; case "date": + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))" /> break; case "checkbox": break; @@ -56,7 +65,9 @@ + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))"> @if (Choices?.TryGetValue(fieldName ?? "", out var lookupChoices) == true && lookupChoices is not null) { @@ -88,7 +101,9 @@ value="@GetFieldValue(fieldName)" readonly="@GetBoolProp(c, "readOnly")" tabindex="@tabIdx" - @onchange="@(e => SetFieldValue(fieldName, e.Value))"> + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))"> break; case "radio": @if (Choices?.TryGetValue(fieldName ?? "", out var radioChoices) == true && radioChoices is not null) @@ -103,7 +118,9 @@ value="@val" checked="@IsRadioChoiceSelected(fieldName, val)" tabindex="@tabIdx" - @onchange="@(e => SetRadioFieldValue(fieldName, val))" /> + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" + @onchange="@(e => SetRadioFieldValueAsync(c, fieldName, val))" /> @ch.Label } @@ -122,7 +139,9 @@ class="fr-input fr-computed" value="@displayValue" readonly - tabindex="@tabIdx" /> + tabindex="@tabIdx" + @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" + @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" /> break; case "commandButton": var isExecuting = _executingCommandButtons.Contains(c.ControlId); @@ -243,30 +262,36 @@ private bool IsRadioChoiceSelected(string? fieldName, string? choiceValue) => FormControlValueConverter.ChoiceMatchesValue(GetFieldObjectValue(fieldName), choiceValue, GetFieldDefinition(fieldName)); - private void SetCheckboxFieldValue(string? fieldName, bool isChecked) - => SetFieldValue(fieldName, FormControlValueConverter.ConvertCheckboxValue(isChecked, GetFieldDefinition(fieldName))); + private Task SetCheckboxFieldValueAsync(ControlDefinition control, string? fieldName, bool isChecked) + => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertCheckboxValue(isChecked, GetFieldDefinition(fieldName))); - private void SetRadioFieldValue(string? fieldName, string? choiceValue) - => SetFieldValue(fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue, GetFieldDefinition(fieldName))); + private Task SetRadioFieldValueAsync(ControlDefinition control, string? fieldName, string? choiceValue) + => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue, GetFieldDefinition(fieldName))); - private void SetFieldValue(string? fieldName, object? value) + private async Task SetFieldValueAsync(ControlDefinition control, string? fieldName, object? value) { if (fieldName is null) return; - BeforeFieldChanged.InvokeAsync(fieldName); + object? oldValue = GetFieldObjectValue(fieldName); + await BeforeFieldChanged.InvokeAsync(fieldName); Record[fieldName] = value; - OnFieldChanged.InvokeAsync(fieldName); + await InvokeControlEventsAsync( + control, + ControlEventKind.OnChange, + BuildFieldEventArguments(control, ControlEventKind.OnChange, fieldName, value, oldValue)); + await OnFieldChanged.InvokeAsync(fieldName); } private async Task InvokeCommandButtonAsync(ControlDefinition control) { string commandName = GetProp(control, "commandName", string.Empty); - if (string.IsNullOrWhiteSpace(commandName)) + if (string.IsNullOrWhiteSpace(commandName) && !HasControlEventBindings(control, ControlEventKind.OnClick)) { await ReportCommandErrorAsync("Command button has no command name."); return; } - if (!Commands.TryGetCommand(commandName, out DbCommandDefinition definition)) + DbCommandDefinition? definition = null; + if (!string.IsNullOrWhiteSpace(commandName) && !Commands.TryGetCommand(commandName, out definition)) { await ReportCommandErrorAsync($"Unknown form command '{commandName}'."); return; @@ -275,27 +300,36 @@ _executingCommandButtons.Add(control.ControlId); try { - control.Props.Values.TryGetValue("commandArguments", out object? configuredArguments); - Dictionary arguments = FormCommandInvocation.BuildArguments( - Record, - FormCommandInvocation.ReadArgumentsProperty(configuredArguments)); - Dictionary metadata = FormCommandInvocation.BuildMetadata(Form); - metadata["event"] = "Click"; - metadata["controlId"] = control.ControlId; - metadata["controlType"] = control.ControlType; - - DbCommandResult result = await definition.InvokeAsync(arguments, metadata); - if (!result.Succeeded) + if (!string.IsNullOrWhiteSpace(commandName)) { - string message = string.IsNullOrWhiteSpace(result.Message) - ? $"Form command '{definition.Name}' failed." - : result.Message; - await ReportCommandErrorAsync(message); + if (definition is null) + throw new InvalidOperationException($"Form command '{commandName}' was not resolved."); + + control.Props.Values.TryGetValue("commandArguments", out object? configuredArguments); + Dictionary arguments = FormCommandInvocation.BuildArguments( + Record, + FormCommandInvocation.ReadArgumentsProperty(configuredArguments)); + Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, "Click"); + + DbCommandResult result = await definition.InvokeAsync(arguments, metadata); + if (!result.Succeeded) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Form command '{definition.Name}' failed." + : result.Message; + await ReportCommandErrorAsync(message); + return; + } } + + await InvokeControlEventsAsync(control, ControlEventKind.OnClick); } catch (Exception ex) { - await ReportCommandErrorAsync($"Form command '{definition.Name}' failed: {ex.Message}"); + string subject = string.IsNullOrWhiteSpace(commandName) + ? "Control click command" + : $"Form command '{commandName}'"; + await ReportCommandErrorAsync($"{subject} failed: {ex.Message}"); } finally { @@ -303,6 +337,94 @@ } } + private Task InvokeFieldControlEventAsync(ControlDefinition control, ControlEventKind eventKind, string? fieldName) + => InvokeControlEventsAsync( + control, + eventKind, + BuildFieldEventArguments(control, eventKind, fieldName, GetFieldObjectValue(fieldName), oldValue: null)); + + private async Task InvokeControlEventsAsync( + ControlDefinition control, + ControlEventKind eventKind, + IReadOnlyDictionary? runtimeArguments = null) + { + IReadOnlyList bindings = control.EventBindings ?? []; + foreach (ControlEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) + { + if (string.IsNullOrWhiteSpace(binding.CommandName)) + { + await ReportCommandErrorAsync($"Control event '{eventKind}' has an empty command name."); + return; + } + + if (!Commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + { + await ReportCommandErrorAsync($"Unknown form command '{binding.CommandName}' for control event '{eventKind}'."); + return; + } + + Dictionary arguments = FormCommandInvocation.BuildArguments( + Record, + runtimeArguments, + binding.Arguments); + Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, eventKind.ToString()); + + DbCommandResult result; + try + { + result = await definition.InvokeAsync(arguments, metadata); + } + catch (Exception ex) + { + if (binding.StopOnFailure) + { + await ReportCommandErrorAsync($"Control event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"); + return; + } + + continue; + } + + if (!result.Succeeded && binding.StopOnFailure) + { + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Control event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + await ReportCommandErrorAsync(message); + return; + } + } + } + + private static bool HasControlEventBindings(ControlDefinition control, ControlEventKind eventKind) + => control.EventBindings?.Any(binding => binding.Event == eventKind) == true; + + private static Dictionary BuildFieldEventArguments( + ControlDefinition control, + ControlEventKind eventKind, + string? fieldName, + object? value, + object? oldValue) + { + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["controlId"] = control.ControlId, + ["controlType"] = control.ControlType, + ["event"] = eventKind.ToString(), + }; + + if (!string.IsNullOrWhiteSpace(fieldName)) + arguments["fieldName"] = fieldName; + + if (value is not null) + arguments["value"] = value; + + if (oldValue is not null) + arguments["oldValue"] = oldValue; + + return arguments; + } + private Task ReportCommandErrorAsync(string message) => OnCommandError.HasDelegate ? OnCommandError.InvokeAsync(message) : Task.CompletedTask; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index de5bd7de..c1f07777 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -57,6 +57,12 @@ +
+ + +
+
@@ -811,6 +817,14 @@ return Task.CompletedTask; } + private Task OnControlEventBindingsChanged(IReadOnlyList bindings) + { + if (_selected is not null) + State.UpdateControlEventBindings(_selected.ControlId, bindings); + + return Task.CompletedTask; + } + private async Task LoadTableDefinitionAsync(string? tableName) { if (string.IsNullOrWhiteSpace(tableName)) diff --git a/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs b/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs index a1b02b12..cde54234 100644 --- a/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/ControlDefinition.cs @@ -7,4 +7,5 @@ public sealed record ControlDefinition( BindingDefinition? Binding, PropertyBag Props, ValidationOverride? ValidationOverride, - IReadOnlyDictionary? RendererHints = null); + IReadOnlyDictionary? RendererHints = null, + IReadOnlyList? EventBindings = null); diff --git a/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs b/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs new file mode 100644 index 00000000..88f9a604 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs @@ -0,0 +1,15 @@ +namespace CSharpDB.Admin.Forms.Models; + +public enum ControlEventKind +{ + OnClick, + OnChange, + OnGotFocus, + OnLostFocus, +} + +public sealed record ControlEventBinding( + ControlEventKind Event, + string CommandName, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true); diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index c142b639..f5d9b191 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -16,6 +16,7 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - validation rule inference and validation override support - child table/tab support for related records - trusted command-backed form events and command buttons +- trusted command-backed selected-control events ## Main Components @@ -87,7 +88,9 @@ public sealed record FormDefinition( ``` Controls are stored as `ControlDefinition` records with geometry, binding, -properties, optional validation overrides, and optional renderer hints. +properties, optional validation overrides, optional renderer hints, and optional +`ControlEventBinding` entries for selected control events such as `OnClick`, +`OnChange`, `OnGotFocus`, and `OnLostFocus`. ## Build diff --git a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs index 8ae61778..1532041b 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs @@ -10,6 +10,12 @@ public static Dictionary BuildArguments( IReadOnlyDictionary? configuredArguments) => DbCommandArguments.FromObjectDictionary(record, configuredArguments); + public static Dictionary BuildArguments( + IReadOnlyDictionary? record, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary? configuredArguments) + => DbCommandArguments.FromObjectDictionaries(record, runtimeArguments, configuredArguments); + public static Dictionary BuildMetadata(FormDefinition form) { ArgumentNullException.ThrowIfNull(form); @@ -23,6 +29,21 @@ public static Dictionary BuildMetadata(FormDefinition form) }; } + public static Dictionary BuildControlMetadata( + FormDefinition form, + ControlDefinition control, + string eventName) + { + Dictionary metadata = BuildMetadata(form); + metadata["event"] = eventName; + metadata["controlId"] = control.ControlId; + metadata["controlType"] = control.ControlType; + if (!string.IsNullOrWhiteSpace(control.Binding?.FieldName)) + metadata["fieldName"] = control.Binding.FieldName; + + return metadata; + } + public static IReadOnlyDictionary? ReadArgumentsProperty(object? value) { if (value is null) diff --git a/src/CSharpDB.Primitives/DbCommandArguments.cs b/src/CSharpDB.Primitives/DbCommandArguments.cs index 7257bfb4..ce6269e6 100644 --- a/src/CSharpDB.Primitives/DbCommandArguments.cs +++ b/src/CSharpDB.Primitives/DbCommandArguments.cs @@ -7,10 +7,14 @@ public static class DbCommandArguments public static Dictionary FromObjectDictionary( IReadOnlyDictionary? first, IReadOnlyDictionary? second = null) + => FromObjectDictionaries(first, second); + + public static Dictionary FromObjectDictionaries( + params IReadOnlyDictionary?[] sources) { var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); - AddDictionary(arguments, first); - AddDictionary(arguments, second); + foreach (IReadOnlyDictionary? source in sources) + AddDictionary(arguments, source); return arguments; } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs index c79ee213..e30337eb 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -50,6 +50,38 @@ public void UpdateEventBindings_ReplacesFormLevelBindings() Assert.False(binding.StopOnFailure); } + [Fact] + public void UpdateControlEventBindings_ReplacesSelectedControlBindings() + { + var state = new DesignerState(); + ControlDefinition textControl = new( + "name", + "text", + new Rect(0, 0, 120, 24), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + null); + state.LoadForm(CreateForm() with { Controls = [textControl] }); + + state.UpdateControlEventBindings( + "name", + [ + new ControlEventBinding( + ControlEventKind.OnChange, + "NormalizeName", + new Dictionary { ["source"] = "designer" }, + StopOnFailure: false), + ]); + + FormDefinition saved = state.ToFormDefinition(); + + ControlEventBinding binding = Assert.Single(saved.Controls[0].EventBindings!); + Assert.Equal(ControlEventKind.OnChange, binding.Event); + Assert.Equal("NormalizeName", binding.CommandName); + Assert.Equal("designer", binding.Arguments!["source"]); + Assert.False(binding.StopOnFailure); + } + private static FormDefinition CreateForm() => new( "customers-form", diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs index 3b7fabbc..46da1753 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -48,6 +48,86 @@ public async Task CommandButton_ReportsMissingCommand() Assert.Equal("Unknown form command 'MissingCommand'.", error); } + [Fact] + public async Task ControlOnChange_InvokesRegisteredCommandWithFieldRuntimeArguments() + { + DbCommandContext? captured = null; + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditStatus", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + ControlDefinition text = new( + "status", + "text", + new Rect(10, 20, 180, 34), + new BindingDefinition("Status", "TwoWay"), + PropertyBag.Empty, + null, + EventBindings: + [ + new ControlEventBinding( + ControlEventKind.OnChange, + "AuditStatus", + new Dictionary { ["source"] = "control-event" }), + ]); + var renderer = CreateRenderer(commands, CreateForm(text)); + + await InvokeNonPublicAsync(renderer, "SetFieldValueAsync", text, "Status", "Shipped"); + + Assert.NotNull(captured); + Assert.Equal("AuditStatus", captured.CommandName); + Assert.Equal("AdminForms", captured.Metadata["surface"]); + Assert.Equal("OnChange", captured.Metadata["event"]); + Assert.Equal("status", captured.Metadata["controlId"]); + Assert.Equal("Status", captured.Metadata["fieldName"]); + Assert.Equal("Shipped", captured.Arguments["Status"].AsText); + Assert.Equal("Status", captured.Arguments["fieldName"].AsText); + Assert.Equal("Shipped", captured.Arguments["value"].AsText); + Assert.Equal("Ready", captured.Arguments["oldValue"].AsText); + Assert.Equal("control-event", captured.Arguments["source"].AsText); + } + + [Fact] + public async Task CommandButton_UsesOnClickBindingWhenNoDirectCommandIsConfigured() + { + DbCommandContext? captured = null; + string? error = null; + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("ShipOrder", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + ControlDefinition button = new( + "button1", + "commandButton", + new Rect(10, 20, 120, 34), + null, + new PropertyBag(new Dictionary { ["text"] = "Ship" }), + null, + EventBindings: + [ + new ControlEventBinding(ControlEventKind.OnClick, "ShipOrder"), + ]); + var renderer = CreateRenderer(commands, CreateForm(button), message => error = message); + + await InvokeNonPublicAsync(renderer, "InvokeCommandButtonAsync", button); + + Assert.Null(error); + Assert.NotNull(captured); + Assert.Equal("OnClick", captured!.Metadata["event"]); + Assert.Equal("button1", captured.Metadata["controlId"]); + Assert.Equal(42, captured.Arguments["OrderId"].AsInteger); + } + private static FormRenderer CreateRenderer( DbCommandRegistry commands, FormDefinition form, diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index f3231c5f..33c61ae0 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -43,6 +43,10 @@ public void FormDefinition_RoundTrips() Assert.Equal(FormEventKind.AfterUpdate, deserialized.EventBindings[0].Event); Assert.Equal("AuditChange", deserialized.EventBindings[0].CommandName); Assert.Equal("manual", deserialized.EventBindings[0].Arguments!["reason"]); + ControlEventBinding controlBinding = Assert.Single(deserialized.Controls[1].EventBindings!); + Assert.Equal(ControlEventKind.OnChange, controlBinding.Event); + Assert.Equal("NormalizeName", controlBinding.CommandName); + Assert.Equal("control", controlBinding.Arguments!["source"]); } [Fact] @@ -286,7 +290,14 @@ private static FormDefinition CreateSampleForm() new PropertyBag(new Dictionary { ["text"] = "First Name" }), null), new("c1", "text", new Rect(220, 24, 320, 34), new BindingDefinition("FirstName", "TwoWay"), - new PropertyBag(new Dictionary { ["placeholder"] = "Enter first name" }), null) + new PropertyBag(new Dictionary { ["placeholder"] = "Enter first name" }), null, + EventBindings: + [ + new ControlEventBinding( + ControlEventKind.OnChange, + "NormalizeName", + new Dictionary { ["source"] = "control" }), + ]) }; return new FormDefinition( From 2d88c2770fd241731a692300c8f3ff46162e9de4 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 10:16:28 -0700 Subject: [PATCH 07/39] Add Admin Forms declarative action sequences --- RELEASE_NOTES.md | 15 +- docs/admin-forms-access-parity/README.md | 7 +- docs/roadmap.md | 6 +- docs/trusted-csharp-functions/README.md | 99 ++++++- .../Designer/ControlEventBindingsEditor.razor | 65 +++++ .../Designer/FormEventBindingsEditor.razor | 65 +++++ .../Components/Designer/FormRenderer.razor | 102 +++++--- .../Models/ControlEventBinding.cs | 5 +- .../Models/FormEventBinding.cs | 5 +- src/CSharpDB.Admin.Forms/README.md | 7 + .../Services/DefaultFormEventDispatcher.cs | 68 +++-- .../Services/FormActionSequenceExecutor.cs | 241 ++++++++++++++++++ src/CSharpDB.Primitives/DbActions.cs | 22 ++ .../FormRendererCommandButtonTests.cs | 54 ++++ .../Serialization/JsonRoundtripTests.cs | 42 +++ .../DefaultFormEventDispatcherTests.cs | 72 ++++++ 16 files changed, 816 insertions(+), 59 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs create mode 100644 src/CSharpDB.Primitives/DbActions.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 48573f1b..7431d236 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -57,6 +57,14 @@ calculated text, and pipeline filter/derive expressions. - The Forms property inspector now edits selected-control event bindings using the same registered-command picker and JSON argument editor as form-level events. +- Added shared declarative action sequence metadata with `RunCommand`, + `SetFieldValue`, `ShowMessage`, and `Stop` steps for Admin Forms automation. + Form and control event bindings can now be command-only, + action-sequence-only, or a command followed by an action sequence. +- The existing form-event and selected-control event editors now expose an + action-sequence JSON field so designers can edit the stored sequence metadata. +- Action sequences store names, arguments, field targets, and literal values + only. They do not store C# source, serialize delegates, or run untrusted code. - Added shared command argument conversion helpers so Forms, Reports, and Pipelines pass host command arguments with the same `DbValue` conversion rules. @@ -103,6 +111,8 @@ calculated text, and pipeline filter/derive expressions. before-event cancellation. - Added designer-state, command-button, and control-event tests covering event binding preservation and registered command invocation from rendered forms. +- Added Forms action-sequence tests for event dispatch, mutable record updates, + command button action-only clicks, and JSON round-tripping. - Added report-event dispatcher and preview lifecycle tests, pipeline hook serialization/validation/orchestrator tests, and shared command argument conversion tests. @@ -155,8 +165,9 @@ otherwise neutral to improved. callback delegates are never serialized over HTTP or gRPC. - Admin Forms and Reports use the shared registries, but their formula and automation surfaces remain narrower than SQL or stored macro systems: - formulas stay expression-focused, and command hooks invoke host-owned code by - name rather than storing executable scripts in database metadata. + formulas stay expression-focused, command hooks invoke host-owned code by + name, and declarative action sequences store only limited action metadata + rather than executable scripts in database metadata. ## v3.5.0 diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index 7e16bb62..e9d1f60c 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -25,6 +25,9 @@ The current forms surface already includes: - trusted host command registration for form lifecycle events - designer editing for form-level event bindings - command button controls that invoke trusted host commands +- trusted command-backed selected-control events +- declarative form action sequences for run-command, set-field, show-message, + and stop steps ## Added Review Findings @@ -99,8 +102,8 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | -| Command button control | Partial | Trusted command buttons can invoke host-registered C# commands; built-in form actions remain future work. | -| Action model | Planned | Support actions such as open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, and show message. | +| Command button control | Partial | Trusted command buttons can invoke host-registered C# commands and action-only click sequences; built-in navigation/save/delete actions remain future work. | +| Action model | Partial | Declarative action sequences support run-command, set-field, show-message, and stop steps; open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, conditions, and loops remain future work. | | Event hooks | Partial | Form lifecycle events, command-button clicks, and selected control events can call trusted commands; additional Access-style events remain future work. | | Conditional UI rules | Planned | Add visible/enabled/read-only expressions for controls. | diff --git a/docs/roadmap.md b/docs/roadmap.md index a87bbcf5..3ee153ba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ SQL feature parity, provider/tooling compatibility, and ecosystem expansion. | Feature | Description | Status | |---------|-------------|--------| -| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms lifecycle events, command-button clicks, selected control events, Admin Reports render lifecycle events, and pipeline run hooks; broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, macro action scripts, additional Access-style control events, and sandboxed UDFs remain future work | Partial | +| **User-defined functions and commands** | Trusted in-process C# scalar functions are implemented for SQL, triggers/procedures, direct clients, Admin Forms/Reports, and pipelines; trusted commands now back Admin Forms lifecycle events, command-button clicks, selected control events, Admin Reports render lifecycle events, and pipeline run hooks; Admin Forms now have declarative action sequences for run-command, set-field, show-message, and stop steps. Broader built-in scalar functions, native plugin extensions, aggregate/table-valued UDFs, richer macro flow, additional Access-style control events, and sandboxed UDFs remain future work | Partial | | **Subqueries** | Scalar subqueries, `IN (SELECT ...)`, `EXISTS (SELECT ...)`, including correlated evaluation in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions | Done | | **`UNION` / `INTERSECT` / `EXCEPT`** | Set operations across SELECT results, including use in top-level queries, views, and CTE query bodies | Done | | **Window functions** | `ROW_NUMBER()`, `RANK()`, `DENSE_RANK()`, `LEAD()`, `LAG()` | Planned | @@ -96,7 +96,7 @@ These are known simplifications in the current implementation: | Area | Limitation | |------|-----------| -| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host; Admin Forms, Admin Reports, and pipelines can invoke trusted host commands from supported event/hook surfaces; broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, additional Access-style control events, macro action scripts, and sandboxed UDFs are not implemented | +| **Functions and automation** | Trusted in-process C# scalar functions are supported when registered by the host; Admin Forms, Admin Reports, and pipelines can invoke trusted host commands from supported event/hook surfaces; Admin Forms support declarative action sequences for run-command, set-field, show-message, and stop steps. Broader built-in scalar functions, aggregate/table-valued UDFs, stored C# modules, remote delegate serialization, additional Access-style control events, richer macro flow, and sandboxed UDFs are not implemented | | **Query** | Scalar/`IN`/`EXISTS` subqueries are supported, including correlated cases in `WHERE`, non-aggregate projection, and `UPDATE`/`DELETE` expressions; correlated subqueries are not yet supported in `JOIN ON`, `GROUP BY`, `HAVING`, `ORDER BY`, or aggregate projections | | **Query** | `UNION`, `INTERSECT`, and `EXCEPT` are supported; `UNION ALL` is not implemented yet | | **Query** | No window functions | @@ -106,7 +106,7 @@ These are known simplifications in the current implementation: | **Collections** | `FindByIndexAsync` supports declared field-equality lookups; `FindByPathAsync` and `FindByPathRangeAsync` support path-based queries on indexed paths; `FindAsync` remains a full scan for unindexed predicates | | **Networking** | `CSharpDB.Daemon` now hosts both REST and gRPC from one process; named pipes remain reserved but are not implemented end to end today | | **Security** | Remote HTTP and gRPC deployment still rely on external network controls or front-end TLS termination; built-in authentication, authorization, and TLS/mTLS support are still planned | -| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, including lifecycle events, command buttons, and selected control events, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, a broader action model, additional events, advanced filtering/sorting, and broader controls | +| **Admin Forms** | The Forms designer/runtime supports the core generated-form and data-entry path plus initial trusted command-backed automation, including lifecycle events, command buttons, selected control events, and declarative action sequences, but still needs Access-parity work for responsive runtime rendering, complete inferred validation, richer form modes, broader built-in actions, additional events, advanced filtering/sorting, and broader controls | | **Admin Reports** | The Reports designer/runtime supports the core banded preview path plus trusted command-backed preview lifecycle events, but still needs Access-parity work for bounded saved-query previews, full report output/export, parameters, richer grouping and totals semantics, conditional formatting, subreports, and broader controls | | **Text / Multilingual** | Text is stored as UTF-8 and supports all Unicode languages; default semantics remain ordinal, but opt-in `BINARY`, `NOCASE`, `NOCASE_AI`, and `ICU:` collation are implemented for SQL and collection indexes. Dedicated ordered SQL text index optimization remains planned | | **Concurrency** | The physical WAL commit path is still serialized at the storage boundary. Initial multi-writer support is shipped, but observed gains still depend on conflict shape and whether shared auto-commit `INSERT` is left on the default serialized path | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 1110cb78..cfead794 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -385,6 +385,98 @@ Command button direct-command metadata includes the same form metadata as lifecy --- +## Declarative Admin Forms Action Sequences + +Admin Forms event bindings can also store small declarative action sequences. +This is the first Access-style macro layer for CSharpDB Forms: the form stores +action metadata, while any executable C# still lives in host-registered trusted +commands. + +```csharp +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +var shipButton = existingButton with +{ + EventBindings = + [ + new ControlEventBinding( + ControlEventKind.OnClick, + CommandName: string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Shipped"), + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditOrderStatus", + Arguments: new Dictionary + { + ["source"] = "ship-button", + }), + new DbActionStep( + DbActionKind.ShowMessage, + Message: "Order marked as shipped."), + ], + Name: "ShipButtonActions")), + ], +}; +``` + +The initial action set is intentionally small: + +| Action | Behavior | +| --- | --- | +| `RunCommand` | Invokes a host-registered trusted command by name. | +| `SetFieldValue` | Updates a target field in the current mutable form record. | +| `ShowMessage` | Sends a message when the current Forms surface provides a command/message callback. | +| `Stop` | Ends the current sequence successfully. | + +Action sequences can be attached to form lifecycle bindings or selected-control +bindings. A binding can contain only a command, only an action sequence, or a +command followed by an action sequence: + +```csharp +var form = existingForm with +{ + EventBindings = + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + "ValidateOrder", + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Draft"), + ])), + ], +}; +``` + +The Admin Forms property inspector exposes action sequences as editable JSON on +form-level and selected-control event bindings. + +For `RunCommand`, command arguments are built from current record fields, +binding arguments, runtime event arguments, and step arguments, with later +sources overriding earlier ones. Command metadata includes the Forms metadata +plus `actionKind`, `actionStep`, and optional `actionSequence`. + +`SetFieldValue` can update mutable records in form lifecycle events such as +`BeforeInsert` and `BeforeUpdate`, and it can update the current rendered record +from control events or command-button clicks. It does not add built-in database +operations by itself; use `RunCommand` for host-owned work. + +V1 action sequences do not include conditions, loops, stored C# source, +database-owned plugins, built-in navigation actions, built-in save/delete +actions, direct SQL/procedure execution actions, or remote delegate +serialization. + +--- + ## Admin Reports Admin Reports preview rendering accepts the same registry through `DefaultReportPreviewService`: @@ -609,6 +701,11 @@ Admin Forms formulas intentionally return `null` for invalid formulas, unsupport Trusted command failures are surface-specific. Form before-events can cancel writes, report event failures fail preview rendering, and pipeline hook failures produce a failed `PipelineRunResult` unless the binding sets `StopOnFailure = false`. +Forms action-sequence failures follow the same binding-level `StopOnFailure` +rule. Step-level `StopOnFailure = false` lets a later step continue after that +step fails; otherwise the sequence reports the failure to the surrounding form +or control event. + --- ## Performance Guidance @@ -638,4 +735,4 @@ V1 does not support: - Sending delegates over HTTP, gRPC, or pipeline package files. - Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. - Additional Access-style control events such as double-click, key, mouse, timer, and dirty/current events. -- Stored macro/action scripts. +- Richer macro/action scripts with conditions, loops, built-in navigation, built-in save/delete, direct SQL/procedure actions, or database-owned executable code. diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor index 16fdfe7b..e164118b 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor @@ -68,6 +68,14 @@ placeholder="{"source":"control"}" @onchange="@(e => UpdateArguments(idx, binding, e.Value?.ToString() ?? string.Empty))">
+ +
+ + +
} @@ -75,6 +83,10 @@ {
@_argumentError
} + @if (!string.IsNullOrWhiteSpace(_actionSequenceError)) + { +
@_actionSequenceError
+ } @@ -84,7 +96,9 @@ [Parameter] public EventCallback> EventBindingsChanged { get; set; } private readonly Dictionary _argumentText = []; + private readonly Dictionary _actionSequenceText = []; private string? _argumentError; + private string? _actionSequenceError; private static readonly ControlEventKind[] EventKinds = Enum.GetValues(); @@ -93,10 +107,15 @@ protected override void OnParametersSet() { for (int i = 0; i < EventBindings.Count; i++) + { _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); + _actionSequenceText.TryAdd(i, FormatActionSequence(EventBindings[i].ActionSequence)); + } foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) _argumentText.Remove(staleIndex); + foreach (int staleIndex in _actionSequenceText.Keys.Where(index => index >= EventBindings.Count).ToList()) + _actionSequenceText.Remove(staleIndex); } private async Task AddBinding() @@ -106,6 +125,7 @@ .Append(new ControlEventBinding(ControlEventKind.OnClick, commandName)) .ToList(); _argumentText[updated.Count - 1] = string.Empty; + _actionSequenceText[updated.Count - 1] = string.Empty; await EventBindingsChanged.InvokeAsync(updated); } @@ -117,6 +137,7 @@ updated.RemoveAt(index); RebuildArgumentText(updated); + RebuildActionSequenceText(updated); await EventBindingsChanged.InvokeAsync(updated); } @@ -157,6 +178,28 @@ } } + private async Task UpdateActionSequence(int index, ControlEventBinding binding, string text) + { + _actionSequenceText[index] = text; + _actionSequenceError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceBinding(index, binding with { ActionSequence = null }); + return; + } + + try + { + DbActionSequence? actionSequence = JsonSerializer.Deserialize(text, JsonDefaults.Options); + await ReplaceBinding(index, binding with { ActionSequence = actionSequence }); + } + catch (JsonException ex) + { + _actionSequenceError = $"Invalid action sequence JSON: {ex.Message}"; + } + } + private async Task ReplaceBinding(int index, ControlEventBinding binding) { var updated = EventBindings.ToList(); @@ -177,6 +220,16 @@ return text; } + private string GetActionSequenceText(int index, ControlEventBinding binding) + { + if (_actionSequenceText.TryGetValue(index, out string? text)) + return text; + + text = FormatActionSequence(binding.ActionSequence); + _actionSequenceText[index] = text; + return text; + } + private void RebuildArgumentText(IReadOnlyList updated) { _argumentText.Clear(); @@ -184,6 +237,13 @@ _argumentText[i] = FormatArguments(updated[i].Arguments); } + private void RebuildActionSequenceText(IReadOnlyList updated) + { + _actionSequenceText.Clear(); + for (int i = 0; i < updated.Count; i++) + _actionSequenceText[i] = FormatActionSequence(updated[i].ActionSequence); + } + private bool ShouldRenderMissingCommand(string commandName) => !string.IsNullOrWhiteSpace(commandName) && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); @@ -192,4 +252,9 @@ => arguments is null || arguments.Count == 0 ? string.Empty : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private static string FormatActionSequence(DbActionSequence? actionSequence) + => actionSequence is null + ? string.Empty + : JsonSerializer.Serialize(actionSequence, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor index ad6d7776..16baaaef 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor @@ -67,6 +67,14 @@ placeholder="{"reason":"manual"}" @onchange="@(e => UpdateArguments(idx, binding, e.Value?.ToString() ?? string.Empty))"> + +
+ + +
} @@ -74,6 +82,10 @@ {
@_argumentError
} + @if (!string.IsNullOrWhiteSpace(_actionSequenceError)) + { +
@_actionSequenceError
+ } @@ -83,7 +95,9 @@ [Parameter] public EventCallback> EventBindingsChanged { get; set; } private readonly Dictionary _argumentText = []; + private readonly Dictionary _actionSequenceText = []; private string? _argumentError; + private string? _actionSequenceError; private static readonly FormEventKind[] EventKinds = Enum.GetValues(); @@ -92,10 +106,15 @@ protected override void OnParametersSet() { for (int i = 0; i < EventBindings.Count; i++) + { _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); + _actionSequenceText.TryAdd(i, FormatActionSequence(EventBindings[i].ActionSequence)); + } foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) _argumentText.Remove(staleIndex); + foreach (int staleIndex in _actionSequenceText.Keys.Where(index => index >= EventBindings.Count).ToList()) + _actionSequenceText.Remove(staleIndex); } private async Task AddBinding() @@ -105,6 +124,7 @@ .Append(new FormEventBinding(FormEventKind.OnLoad, commandName)) .ToList(); _argumentText[updated.Count - 1] = string.Empty; + _actionSequenceText[updated.Count - 1] = string.Empty; await EventBindingsChanged.InvokeAsync(updated); } @@ -116,6 +136,7 @@ updated.RemoveAt(index); RebuildArgumentText(updated); + RebuildActionSequenceText(updated); await EventBindingsChanged.InvokeAsync(updated); } @@ -156,6 +177,28 @@ } } + private async Task UpdateActionSequence(int index, FormEventBinding binding, string text) + { + _actionSequenceText[index] = text; + _actionSequenceError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceBinding(index, binding with { ActionSequence = null }); + return; + } + + try + { + DbActionSequence? actionSequence = JsonSerializer.Deserialize(text, JsonDefaults.Options); + await ReplaceBinding(index, binding with { ActionSequence = actionSequence }); + } + catch (JsonException ex) + { + _actionSequenceError = $"Invalid action sequence JSON: {ex.Message}"; + } + } + private async Task ReplaceBinding(int index, FormEventBinding binding) { var updated = EventBindings.ToList(); @@ -176,6 +219,16 @@ return text; } + private string GetActionSequenceText(int index, FormEventBinding binding) + { + if (_actionSequenceText.TryGetValue(index, out string? text)) + return text; + + text = FormatActionSequence(binding.ActionSequence); + _actionSequenceText[index] = text; + return text; + } + private void RebuildArgumentText(IReadOnlyList updated) { _argumentText.Clear(); @@ -183,6 +236,13 @@ _argumentText[i] = FormatArguments(updated[i].Arguments); } + private void RebuildActionSequenceText(IReadOnlyList updated) + { + _actionSequenceText.Clear(); + for (int i = 0; i < updated.Count; i++) + _actionSequenceText[i] = FormatActionSequence(updated[i].ActionSequence); + } + private bool ShouldRenderMissingCommand(string commandName) => !string.IsNullOrWhiteSpace(commandName) && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); @@ -191,4 +251,9 @@ => arguments is null || arguments.Count == 0 ? string.Empty : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private static string FormatActionSequence(DbActionSequence? actionSequence) + => actionSequence is null + ? string.Empty + : JsonSerializer.Serialize(actionSequence, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 5d2b2bf7..37118a37 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -351,48 +351,76 @@ IReadOnlyList bindings = control.EventBindings ?? []; foreach (ControlEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) { - if (string.IsNullOrWhiteSpace(binding.CommandName)) - { - await ReportCommandErrorAsync($"Control event '{eventKind}' has an empty command name."); - return; - } - - if (!Commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) - { - await ReportCommandErrorAsync($"Unknown form command '{binding.CommandName}' for control event '{eventKind}'."); - return; - } - - Dictionary arguments = FormCommandInvocation.BuildArguments( - Record, - runtimeArguments, - binding.Arguments); Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, eventKind.ToString()); - DbCommandResult result; - try - { - result = await definition.InvokeAsync(arguments, metadata); - } - catch (Exception ex) + if (!string.IsNullOrWhiteSpace(binding.CommandName)) { - if (binding.StopOnFailure) + if (!Commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) { - await ReportCommandErrorAsync($"Control event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"); + await ReportCommandErrorAsync($"Unknown form command '{binding.CommandName}' for control event '{eventKind}'."); return; } - continue; - } + Dictionary arguments = FormCommandInvocation.BuildArguments( + Record, + runtimeArguments, + binding.Arguments); + + bool commandFailed = false; + string? commandFailureMessage = null; + try + { + DbCommandResult result = await definition.InvokeAsync(arguments, metadata); + if (!result.Succeeded) + { + commandFailed = true; + commandFailureMessage = string.IsNullOrWhiteSpace(result.Message) + ? $"Control event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + } + } + catch (Exception ex) + { + commandFailed = true; + commandFailureMessage = $"Control event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"; + } - if (!result.Succeeded && binding.StopOnFailure) + if (commandFailed) + { + if (binding.StopOnFailure) + { + await ReportCommandErrorAsync(commandFailureMessage!); + return; + } + + if (binding.ActionSequence is null) + continue; + } + } + else if (binding.ActionSequence is null) { - string message = string.IsNullOrWhiteSpace(result.Message) - ? $"Control event '{eventKind}' command '{definition.Name}' failed." - : result.Message; - await ReportCommandErrorAsync(message); + await ReportCommandErrorAsync($"Control event '{eventKind}' has no command or action sequence."); return; } + + if (binding.ActionSequence is not null) + { + FormEventDispatchResult actionResult = await FormActionSequenceExecutor.ExecuteAsync( + binding.ActionSequence, + Commands, + Record, + binding.Arguments, + runtimeArguments, + metadata, + setFieldValue: SetActionFieldValueAsync, + showMessage: ReportCommandErrorAsync); + + if (!actionResult.Succeeded && binding.StopOnFailure) + { + await ReportCommandErrorAsync(actionResult.Message ?? $"Control event '{eventKind}' action sequence failed."); + return; + } + } } } @@ -428,6 +456,18 @@ private Task ReportCommandErrorAsync(string message) => OnCommandError.HasDelegate ? OnCommandError.InvokeAsync(message) : Task.CompletedTask; + private async Task SetActionFieldValueAsync(string fieldName, object? value) + { + if (string.IsNullOrWhiteSpace(fieldName)) + return; + + string key = Record.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)) + ?? fieldName; + await BeforeFieldChanged.InvokeAsync(key); + Record[key] = value; + await OnFieldChanged.InvokeAsync(key); + } + private FormFieldDefinition? GetFieldDefinition(string? fieldName) => fieldName is null ? null diff --git a/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs b/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs index 88f9a604..c4f21be1 100644 --- a/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs +++ b/src/CSharpDB.Admin.Forms/Models/ControlEventBinding.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Forms.Models; public enum ControlEventKind @@ -12,4 +14,5 @@ public sealed record ControlEventBinding( ControlEventKind Event, string CommandName, IReadOnlyDictionary? Arguments = null, - bool StopOnFailure = true); + bool StopOnFailure = true, + DbActionSequence? ActionSequence = null); diff --git a/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs b/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs index 7e26f6fe..754b031c 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormEventBinding.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Forms.Models; public enum FormEventKind @@ -16,4 +18,5 @@ public sealed record FormEventBinding( FormEventKind Event, string CommandName, IReadOnlyDictionary? Arguments = null, - bool StopOnFailure = true); + bool StopOnFailure = true, + DbActionSequence? ActionSequence = null); diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index f5d9b191..ff62cf44 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -17,6 +17,7 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - child table/tab support for related records - trusted command-backed form events and command buttons - trusted command-backed selected-control events +- declarative action sequences for form and selected-control events ## Main Components @@ -92,6 +93,12 @@ properties, optional validation overrides, optional renderer hints, and optional `ControlEventBinding` entries for selected control events such as `OnClick`, `OnChange`, `OnGotFocus`, and `OnLostFocus`. +Form and control event bindings can reference a trusted command name and can +optionally include a `DbActionSequence`. Action sequences store declarative +steps such as `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop`; they do +not store C# source or serialized delegates. The property inspector exposes the +sequence as JSON on form-level and selected-control event bindings. + ## Build ```powershell diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs index 42dbc757..f50bbbad 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -17,33 +17,65 @@ public async Task DispatchAsync( IReadOnlyList bindings = form.EventBindings ?? []; foreach (FormEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) { - if (string.IsNullOrWhiteSpace(binding.CommandName)) - return FormEventDispatchResult.Failure($"Form event '{eventKind}' has an empty command name."); - - if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) - return FormEventDispatchResult.Failure($"Unknown form command '{binding.CommandName}' for event '{eventKind}'."); - - Dictionary arguments = FormCommandInvocation.BuildArguments(record, binding.Arguments); Dictionary metadata = FormCommandInvocation.BuildMetadata(form); metadata["event"] = eventKind.ToString(); - DbCommandResult result; - try + if (!string.IsNullOrWhiteSpace(binding.CommandName)) { - result = await definition.InvokeAsync(arguments, metadata, ct); + if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + return FormEventDispatchResult.Failure($"Unknown form command '{binding.CommandName}' for event '{eventKind}'."); + + Dictionary arguments = FormCommandInvocation.BuildArguments(record, binding.Arguments); + bool commandFailed = false; + string? commandFailureMessage = null; + try + { + DbCommandResult result = await definition.InvokeAsync(arguments, metadata, ct); + if (!result.Succeeded) + { + commandFailed = true; + commandFailureMessage = string.IsNullOrWhiteSpace(result.Message) + ? $"Form event '{eventKind}' command '{definition.Name}' failed." + : result.Message; + } + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + commandFailed = true; + commandFailureMessage = $"Form event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"; + } + + if (commandFailed) + { + if (binding.StopOnFailure) + return FormEventDispatchResult.Failure(commandFailureMessage!); + + if (binding.ActionSequence is null) + continue; + } } - catch (Exception ex) + else if (binding.ActionSequence is null) { - return FormEventDispatchResult.Failure( - $"Form event '{eventKind}' command '{definition.Name}' failed: {ex.Message}"); + return FormEventDispatchResult.Failure($"Form event '{eventKind}' has no command or action sequence."); } - if (!result.Succeeded && binding.StopOnFailure) + if (binding.ActionSequence is not null) { - string message = string.IsNullOrWhiteSpace(result.Message) - ? $"Form event '{eventKind}' command '{definition.Name}' failed." - : result.Message; - return FormEventDispatchResult.Failure(message); + FormEventDispatchResult actionResult = await FormActionSequenceExecutor.ExecuteAsync( + binding.ActionSequence, + commands, + record, + binding.Arguments, + runtimeArguments: null, + metadata, + ct: ct); + + if (!actionResult.Succeeded && binding.StopOnFailure) + return actionResult; } } diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs new file mode 100644 index 00000000..cb7912e8 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class FormActionSequenceExecutor +{ + public static async Task ExecuteAsync( + DbActionSequence sequence, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + Func? setFieldValue = null, + Func? showMessage = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(sequence); + ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(metadata); + + IReadOnlyList steps = sequence.Steps ?? []; + string? lastMessage = null; + for (int i = 0; i < steps.Count; i++) + { + DbActionStep step = steps[i]; + FormEventDispatchResult result = await ExecuteStepAsync( + sequence, + step, + i, + commands, + record, + bindingArguments, + runtimeArguments, + metadata, + setFieldValue, + showMessage, + ct); + + if (!result.Succeeded && step.StopOnFailure) + return result; + + if (result.Succeeded && !string.IsNullOrWhiteSpace(result.Message)) + lastMessage = result.Message; + + if (step.Kind == DbActionKind.Stop) + return result; + } + + return FormEventDispatchResult.Success(lastMessage); + } + + private static async Task ExecuteStepAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + Func? setFieldValue, + Func? showMessage, + CancellationToken ct) + { + try + { + return step.Kind switch + { + DbActionKind.RunCommand => await RunCommandAsync(sequence, step, stepIndex, commands, record, bindingArguments, runtimeArguments, metadata, ct), + DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), + DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), + DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), + _ => FormEventDispatchResult.Failure($"Unsupported form action kind '{step.Kind}'."), + }; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return FormEventDispatchResult.Failure( + $"Form action '{step.Kind}' failed: {ex.Message}"); + } + } + + private static async Task RunCommandAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(step.CommandName)) + return FormEventDispatchResult.Failure("RunCommand action requires a command name."); + + if (!commands.TryGetCommand(step.CommandName, out DbCommandDefinition definition)) + return FormEventDispatchResult.Failure($"Unknown form command '{step.CommandName}' for action sequence."); + + Dictionary arguments = DbCommandArguments.FromObjectDictionaries( + record, + bindingArguments, + runtimeArguments, + step.Arguments); + Dictionary stepMetadata = BuildStepMetadata(sequence, step, stepIndex, metadata); + + try + { + DbCommandResult result = await definition.InvokeAsync(arguments, stepMetadata, ct); + if (result.Succeeded) + return FormEventDispatchResult.Success(result.Message); + + string message = string.IsNullOrWhiteSpace(result.Message) + ? $"Action command '{definition.Name}' failed." + : result.Message; + return FormEventDispatchResult.Failure(message); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return FormEventDispatchResult.Failure( + $"Action command '{definition.Name}' failed: {ex.Message}"); + } + } + + private static async Task SetFieldValueAsync( + DbActionStep step, + IReadOnlyDictionary? record, + Func? setFieldValue) + { + if (string.IsNullOrWhiteSpace(step.Target)) + return FormEventDispatchResult.Failure("SetFieldValue action requires a target field."); + + object? value = step.Value is null && step.Arguments?.TryGetValue("value", out object? argumentValue) == true + ? NormalizeValue(argumentValue) + : NormalizeValue(step.Value); + if (setFieldValue is not null) + { + await setFieldValue(step.Target, value); + return FormEventDispatchResult.Success(); + } + + if (record is IDictionary mutableRecord) + { + string key = mutableRecord.Keys.FirstOrDefault(candidate => string.Equals(candidate, step.Target, StringComparison.OrdinalIgnoreCase)) + ?? step.Target; + mutableRecord[key] = value; + return FormEventDispatchResult.Success(); + } + + return FormEventDispatchResult.Failure( + $"SetFieldValue action could not update field '{step.Target}' because the current record is read-only."); + } + + private static async Task ShowMessageAsync( + DbActionStep step, + Func? showMessage) + { + string message = ReadMessage(step) ?? string.Empty; + if (string.IsNullOrWhiteSpace(message)) + return FormEventDispatchResult.Failure("ShowMessage action requires a message."); + + if (showMessage is not null) + await showMessage(message); + + return FormEventDispatchResult.Success(message); + } + + private static Dictionary BuildStepMetadata( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary metadata) + { + var result = new Dictionary(metadata, StringComparer.OrdinalIgnoreCase) + { + ["actionKind"] = step.Kind.ToString(), + ["actionStep"] = stepIndex.ToString(CultureInfo.InvariantCulture), + }; + + if (!string.IsNullOrWhiteSpace(sequence.Name)) + result["actionSequence"] = sequence.Name; + + if (!string.IsNullOrWhiteSpace(step.Target)) + result["actionTarget"] = step.Target; + + return result; + } + + private static string? ReadMessage(DbActionStep step) + { + if (!string.IsNullOrWhiteSpace(step.Message)) + return step.Message; + + if (step.Value is string text) + return text; + + if (step.Value is JsonElement { ValueKind: JsonValueKind.String } json) + return json.GetString(); + + return null; + } + + private static object? NormalizeValue(object? value) + { + return value switch + { + JsonElement json => NormalizeJsonValue(json), + _ => value, + }; + } + + private static object? NormalizeJsonValue(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + static property => property.Name, + static property => NormalizeJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(NormalizeJsonValue).ToArray(), + _ => value.ToString(), + }; + } +} diff --git a/src/CSharpDB.Primitives/DbActions.cs b/src/CSharpDB.Primitives/DbActions.cs new file mode 100644 index 00000000..570365d3 --- /dev/null +++ b/src/CSharpDB.Primitives/DbActions.cs @@ -0,0 +1,22 @@ +namespace CSharpDB.Primitives; + +public enum DbActionKind +{ + RunCommand, + SetFieldValue, + ShowMessage, + Stop, +} + +public sealed record DbActionSequence( + IReadOnlyList Steps, + string? Name = null); + +public sealed record DbActionStep( + DbActionKind Kind, + string? CommandName = null, + string? Target = null, + object? Value = null, + string? Message = null, + IReadOnlyDictionary? Arguments = null, + bool StopOnFailure = true); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs index 46da1753..de57e7ca 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -128,6 +128,53 @@ public async Task CommandButton_UsesOnClickBindingWhenNoDirectCommandIsConfigure Assert.Equal(42, captured.Arguments["OrderId"].AsInteger); } + [Fact] + public async Task CommandButton_ExecutesOnClickActionSequenceWhenNoDirectCommandIsConfigured() + { + DbCommandContext? captured = null; + string? error = null; + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditButtonAction", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + ControlDefinition button = new( + "button1", + "commandButton", + new Rect(10, 20, 120, 34), + null, + new PropertyBag(new Dictionary { ["text"] = "Ship" }), + null, + EventBindings: + [ + new ControlEventBinding( + ControlEventKind.OnClick, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Shipped"), + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditButtonAction"), + ], + Name: "ShipButtonActions")), + ]); + var renderer = CreateRenderer(commands, CreateForm(button), message => error = message); + + await InvokeNonPublicAsync(renderer, "InvokeCommandButtonAsync", button); + + var record = (Dictionary)GetProperty(renderer, nameof(FormRenderer.Record))!; + Assert.Null(error); + Assert.Equal("Shipped", record["Status"]); + Assert.NotNull(captured); + Assert.Equal("Shipped", captured!.Arguments["Status"].AsText); + Assert.Equal("RunCommand", captured.Metadata["actionKind"]); + Assert.Equal("1", captured.Metadata["actionStep"]); + Assert.Equal("ShipButtonActions", captured.Metadata["actionSequence"]); + } + private static FormRenderer CreateRenderer( DbCommandRegistry commands, FormDefinition form, @@ -182,6 +229,13 @@ private static void SetProperty(object instance, string propertyName, object? va property.SetValue(instance, value); } + private static object? GetProperty(object instance, string propertyName) + { + PropertyInfo property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found."); + return property.GetValue(instance); + } + private static async Task InvokeNonPublicAsync(object instance, string methodName, params object?[] args) { MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index 33c61ae0..18d3877a 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Serialization; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Tests.Serialization; @@ -159,6 +160,47 @@ public void ControlDefinition_WithBinding_RoundTrips() Assert.Equal("Enter first name", deserialized.Props.Values["placeholder"]); } + [Fact] + public void FormEventBinding_WithActionSequence_RoundTrips() + { + var form = new FormDefinition( + "f-actions", + "Action Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, []), + [], + EventBindings: + [ + new FormEventBinding( + FormEventKind.OnLoad, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Ready"), + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditAction", + Arguments: new Dictionary { ["source"] = "roundtrip" }), + ], + Name: "LoadActions")), + ]); + + string json = JsonSerializer.Serialize(form, Options); + FormDefinition deserialized = JsonSerializer.Deserialize(json, Options)!; + + DbActionSequence sequence = deserialized.EventBindings![0].ActionSequence!; + Assert.Equal("LoadActions", sequence.Name); + Assert.Equal(2, sequence.Steps.Count); + Assert.Equal(DbActionKind.SetFieldValue, sequence.Steps[0].Kind); + Assert.Equal("Status", sequence.Steps[0].Target); + Assert.Equal("Ready", sequence.Steps[0].Value?.ToString()); + Assert.Equal(DbActionKind.RunCommand, sequence.Steps[1].Kind); + Assert.Equal("AuditAction", sequence.Steps[1].CommandName); + Assert.Equal("roundtrip", sequence.Steps[1].Arguments!["source"]); + } + [Fact] public void ControlDefinition_WithoutBinding_RoundTrips() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs index c1313ea6..fecd7331 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs @@ -1,3 +1,4 @@ +using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Services; using CSharpDB.Primitives; @@ -117,6 +118,77 @@ public async Task DispatchAsync_ContinuesWhenStopOnFailureIsFalse() Assert.Equal(["reject", "after"], calls); } + [Fact] + public async Task DispatchAsync_ExecutesActionSequenceAndMutatesMutableRecord() + { + DbCommandContext? captured = null; + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditChange", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm([ + new FormEventBinding( + FormEventKind.BeforeUpdate, + string.Empty, + new Dictionary { ["Reason"] = "action-sequence" }, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Name", Value: "Bob"), + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditChange", + Arguments: new Dictionary { ["Step"] = "audit" }), + ], + Name: "BeforeUpdateActions")), + ]); + var record = new Dictionary { ["Id"] = 7L, ["Name"] = "Alice" }; + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.BeforeUpdate, + record, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal("Bob", record["Name"]); + Assert.NotNull(captured); + Assert.Equal("Bob", captured!.Arguments["Name"].AsText); + Assert.Equal("action-sequence", captured.Arguments["Reason"].AsText); + Assert.Equal("audit", captured.Arguments["Step"].AsText); + Assert.Equal("RunCommand", captured.Metadata["actionKind"]); + Assert.Equal("1", captured.Metadata["actionStep"]); + Assert.Equal("BeforeUpdateActions", captured.Metadata["actionSequence"]); + } + + [Fact] + public async Task DispatchAsync_ActionSequenceFailureStopsByDefault() + { + var commands = DbCommandRegistry.Empty; + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm([ + new FormEventBinding( + FormEventKind.AfterUpdate, + string.Empty, + ActionSequence: new DbActionSequence( + [new DbActionStep(DbActionKind.RunCommand, CommandName: "MissingCommand")])), + ]); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.AfterUpdate, + new Dictionary { ["Id"] = 7L }, + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("Unknown form command 'MissingCommand'", result.Message); + } + private static FormDefinition CreateForm(IReadOnlyList eventBindings) => new( "customers-form", From f9e3c0ae5584c4c318857f1afb9c01eac8282489 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 10:38:38 -0700 Subject: [PATCH 08/39] Add trusted C# host developer sample --- CSharpDB.slnx | 1 + README.md | 1 + RELEASE_NOTES.md | 10 ++ docs/trusted-csharp-functions/README.md | 24 ++++ .../trusted-csharp-host/.vscode/launch.json | 16 +++ .../trusted-csharp-host/.vscode/tasks.json | 28 ++++ samples/trusted-csharp-host/Program.cs | 127 ++++++++++++++++++ samples/trusted-csharp-host/README.md | 38 ++++++ .../TrustedCSharpHostSample.csproj | 16 +++ 9 files changed, 261 insertions(+) create mode 100644 samples/trusted-csharp-host/.vscode/launch.json create mode 100644 samples/trusted-csharp-host/.vscode/tasks.json create mode 100644 samples/trusted-csharp-host/Program.cs create mode 100644 samples/trusted-csharp-host/README.md create mode 100644 samples/trusted-csharp-host/TrustedCSharpHostSample.csproj diff --git a/CSharpDB.slnx b/CSharpDB.slnx index 83d8c108..f4685ad9 100644 --- a/CSharpDB.slnx +++ b/CSharpDB.slnx @@ -21,6 +21,7 @@ + diff --git a/README.md b/README.md index 8ca510b6..359d98ad 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ The native library exports 20 C functions. See the [Native Library Reference](ht | [Tools & Ecosystem](https://csharpdb.com/docs/ecosystem.html) | APIs, hosts, designers, and integrations | | [EF Core Provider](https://csharpdb.com/docs/entity-framework-core.html) | Embedded EF Core 10 provider guide | | [Trusted C# Scalar Functions](docs/trusted-csharp-functions/README.md) | Register in-process C# functions for SQL, forms, reports, and pipelines | +| [Trusted C# Host Sample](samples/trusted-csharp-host/README.md) | VS Code-ready C# host project for trusted functions, commands, and form actions | | [Admin UI Guide](https://csharpdb.com/docs/admin-ui.html) | Querying, schema, pipelines, forms, reports, and storage | | [CSharpDB.Client](src/CSharpDB.Client/README.md) | Unified client API and transports | | [Pipelines](https://csharpdb.com/docs/pipelines.html) | ETL package model and visual designer | diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7431d236..a76f0bd6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -80,6 +80,16 @@ calculated text, and pipeline filter/derive expressions. errors are appended to the failed run summary instead of recursively dispatching more failure hooks. +### Developer Experience + +- Added `samples/trusted-csharp-host`, a VS Code-ready C# host project for + writing and debugging trusted C# callbacks in ordinary application code. +- The sample registers a trusted scalar function, calls it from SQL, registers + a trusted command, and runs an Admin Forms action sequence that sets a field + before invoking that command. +- The sample includes local `.vscode` launch/tasks files so developers can open + the sample folder, press `F5`, and set breakpoints inside callback code. + ### Behavior And Safety - Function names are case-insensitive SQL identifiers, and registration rejects diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index cfead794..e122e1a2 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -149,6 +149,30 @@ await using var result = await db.ExecuteAsync(""" """); ``` +### VS Code Host Project Workflow + +For a runnable C# host project, open +`samples/trusted-csharp-host` in VS Code. The sample includes `.vscode` launch +and task files so a developer can press `F5`, set breakpoints inside the +registered callbacks, and watch SQL and Admin Forms automation invoke ordinary +C# code. + +```powershell +dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj +``` + +The sample demonstrates: + +- `DatabaseOptions.ConfigureFunctions(...)` for a trusted scalar function. +- SQL calling the function by name. +- `DbCommandRegistry` for a trusted host command. +- An Admin Forms `DbActionSequence` that sets a field and then runs the + command. + +The VS Code story stays host-owned: VS Code is the editor/debugger for the C# +host project, while database metadata stores names and declarative action data +only. + --- ## Registration Rules diff --git a/samples/trusted-csharp-host/.vscode/launch.json b/samples/trusted-csharp-host/.vscode/launch.json new file mode 100644 index 00000000..c41047b9 --- /dev/null +++ b/samples/trusted-csharp-host/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Trusted C# Host Sample", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build trusted C# host sample", + "program": "${workspaceFolder}/bin/Debug/net10.0/TrustedCSharpHostSample.dll", + "args": [], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + } + ] +} diff --git a/samples/trusted-csharp-host/.vscode/tasks.json b/samples/trusted-csharp-host/.vscode/tasks.json new file mode 100644 index 00000000..5d40ff03 --- /dev/null +++ b/samples/trusted-csharp-host/.vscode/tasks.json @@ -0,0 +1,28 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build trusted C# host sample", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/TrustedCSharpHostSample.csproj" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, + { + "label": "run trusted C# host sample", + "type": "process", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/TrustedCSharpHostSample.csproj" + ], + "problemMatcher": "$msCompile", + "group": "test" + } + ] +} diff --git a/samples/trusted-csharp-host/Program.cs b/samples/trusted-csharp-host/Program.cs new file mode 100644 index 00000000..7e77c1d9 --- /dev/null +++ b/samples/trusted-csharp-host/Program.cs @@ -0,0 +1,127 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Engine; +using CSharpDB.Primitives; + +Console.WriteLine("CSharpDB trusted C# host sample"); +Console.WriteLine(); + +DatabaseOptions databaseOptions = new DatabaseOptions() + .ConfigureFunctions(functions => + { + functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromText(Slugify(args[0].AsText))); + }); + +await using Database db = await Database.OpenInMemoryAsync(databaseOptions); + +await db.ExecuteAsync(""" + CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT + ); + """); + +await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello From VS Code', Slugify('Hello From VS Code'));"); +await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Trusted CSharpDB Callbacks', Slugify('Trusted CSharpDB Callbacks'));"); + +Console.WriteLine("SQL scalar function result:"); +await using var rows = await db.ExecuteAsync(""" + SELECT id, title, slug + FROM articles + ORDER BY id; + """); + +while (await rows.MoveNextAsync()) +{ + IReadOnlyList row = rows.Current; + Console.WriteLine($" {row[0].AsInteger}: {row[1].AsText} -> {row[2].AsText}"); +} + +List auditLog = []; +DbCommandRegistry commands = DbCommandRegistry.Create(builder => +{ + builder.AddCommand( + "AuditCustomerChange", + new DbCommandOptions("Records a customer workflow event."), + context => + { + long customerId = context.Arguments["Id"].AsInteger; + string status = context.Arguments["Status"].AsText; + string source = context.Arguments["source"].AsText; + string eventName = context.Metadata["event"]; + string actionSequence = context.Metadata.TryGetValue("actionSequence", out string? value) + ? value + : "(none)"; + + auditLog.Add( + $"Customer {customerId} -> {status}; source={source}; event={eventName}; sequence={actionSequence}"); + return DbCommandResult.Success(); + }); +}); + +FormDefinition form = new( + "customers-entry", + "Customers Entry", + "Customers", + DefinitionVersion: 1, + SourceSchemaSignature: "sample:customers:v1", + Layout: new LayoutDefinition("absolute", 8, SnapToGrid: true, Breakpoints: []), + Controls: [], + EventBindings: + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + CommandName: string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Ready"), + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditCustomerChange", + Arguments: new Dictionary + { + ["source"] = "trusted-csharp-host-sample", + }), + ], + Name: "PrepareCustomerInsert")), + ]); + +var record = new Dictionary(StringComparer.OrdinalIgnoreCase) +{ + ["Id"] = 42L, + ["Name"] = "Ada Lovelace", +}; + +var dispatcher = new DefaultFormEventDispatcher(commands); +FormEventDispatchResult dispatchResult = await dispatcher.DispatchAsync(form, FormEventKind.BeforeInsert, record); + +Console.WriteLine(); +Console.WriteLine("Admin Forms action sequence result:"); +Console.WriteLine($" Succeeded: {dispatchResult.Succeeded}"); +Console.WriteLine($" Status field: {record["Status"]}"); +foreach (string auditEntry in auditLog) + Console.WriteLine($" Audit: {auditEntry}"); + +Console.WriteLine(); +Console.WriteLine("Set a breakpoint inside Slugify or AuditCustomerChange, then run this sample from VS Code."); + +static string Slugify(string text) +{ + return text + .Trim() + .ToLowerInvariant() + .Replace(' ', '-'); +} diff --git a/samples/trusted-csharp-host/README.md b/samples/trusted-csharp-host/README.md new file mode 100644 index 00000000..77852cc9 --- /dev/null +++ b/samples/trusted-csharp-host/README.md @@ -0,0 +1,38 @@ +# Trusted C# Host Sample + +This sample is the VS Code workflow for CSharpDB's trusted C# integration. It +is a normal C# project: open this folder in VS Code, set breakpoints inside the +registered callbacks, and run or debug the host process. + +It demonstrates: + +- registering a trusted scalar function with `DatabaseOptions.ConfigureFunctions` +- calling that function from SQL +- registering a trusted command with `DbCommandRegistry` +- running an Admin Forms action sequence that sets a field and calls the command + +The sample keeps the important runtime boundary visible: C# callback bodies live +in the host project. The database/form metadata stores names and action data +only. + +## Run From VS Code + +1. Open `samples/trusted-csharp-host` in VS Code. +2. Install the C# Dev Kit or C# extension if VS Code prompts for it. +3. Press `F5`, or run the task `run trusted C# host sample`. +4. Put breakpoints in `Slugify` or the `AuditCustomerChange` command callback. + +## Run From Terminal + +```powershell +dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj +``` + +Expected output includes slug values from SQL and an audit entry from the form +action sequence. + +## Files + +- `Program.cs` contains the host registration code and runnable demo. +- `.vscode/launch.json` launches the sample under the debugger. +- `.vscode/tasks.json` builds and runs the sample from VS Code tasks. diff --git a/samples/trusted-csharp-host/TrustedCSharpHostSample.csproj b/samples/trusted-csharp-host/TrustedCSharpHostSample.csproj new file mode 100644 index 00000000..a8952804 --- /dev/null +++ b/samples/trusted-csharp-host/TrustedCSharpHostSample.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + From 099309fab7c228108ff14d611f1f3621cd54eb5c Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 11:12:00 -0700 Subject: [PATCH 09/39] Add Admin Forms action sequence designer UX --- RELEASE_NOTES.md | 13 +- docs/admin-forms-access-parity/README.md | 3 +- docs/trusted-csharp-functions/README.md | 12 +- .../Designer/ActionSequenceEditor.razor | 327 ++++++++++++++++++ .../Designer/ControlEventBindingsEditor.razor | 64 +--- .../Designer/FormEventBindingsEditor.razor | 64 +--- src/CSharpDB.Admin.Forms/README.md | 5 +- .../wwwroot/css/designer.css | 25 ++ .../Components/Designer/DesignerStateTests.cs | 71 ++++ 9 files changed, 453 insertions(+), 131 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a76f0bd6..350a3edb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -61,8 +61,12 @@ calculated text, and pipeline filter/derive expressions. `SetFieldValue`, `ShowMessage`, and `Stop` steps for Admin Forms automation. Form and control event bindings can now be command-only, action-sequence-only, or a command followed by an action sequence. -- The existing form-event and selected-control event editors now expose an - action-sequence JSON field so designers can edit the stored sequence metadata. +- The form-event and selected-control event editors now include a visual + action-sequence editor for adding, ordering, removing, and configuring + `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop` steps. +- The action-sequence editor uses registered-command pickers when commands are + available, preserves missing command names for portable form metadata, and + keeps JSON editing limited to optional argument payloads. - Action sequences store names, arguments, field targets, and literal values only. They do not store C# source, serialize delegates, or run untrusted code. - Added shared command argument conversion helpers so Forms, Reports, and @@ -119,8 +123,9 @@ calculated text, and pipeline filter/derive expressions. - Added command-registry, form-event dispatcher, event JSON round-trip, and Forms data-entry tests for create/update/delete event dispatch and before-event cancellation. -- Added designer-state, command-button, and control-event tests covering event - binding preservation and registered command invocation from rendered forms. +- Added designer-state tests for action sequences, plus command-button and + control-event tests covering event binding preservation and registered + command invocation from rendered forms. - Added Forms action-sequence tests for event dispatch, mutable record updates, command button action-only clicks, and JSON round-tripping. - Added report-event dispatcher and preview lifecycle tests, pipeline hook diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index e9d1f60c..4c4c39c8 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -28,6 +28,7 @@ The current forms surface already includes: - trusted command-backed selected-control events - declarative form action sequences for run-command, set-field, show-message, and stop steps +- visual designer editing for form and selected-control action sequences ## Added Review Findings @@ -103,7 +104,7 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | | Command button control | Partial | Trusted command buttons can invoke host-registered C# commands and action-only click sequences; built-in navigation/save/delete actions remain future work. | -| Action model | Partial | Declarative action sequences support run-command, set-field, show-message, and stop steps; open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, conditions, and loops remain future work. | +| Action model | Partial | Declarative action sequences support run-command, set-field, show-message, and stop steps with visual designer editing for form and selected-control events; open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, conditions, and loops remain future work. | | Event hooks | Partial | Form lifecycle events, command-button clicks, and selected control events can call trusted commands; additional Access-style events remain future work. | | Conditional UI rules | Planned | Add visible/enabled/read-only expressions for controls. | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index e122e1a2..3f3d5a77 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -354,7 +354,7 @@ Supported form-level events in this slice are: Command context arguments include the current record fields converted to `DbValue`. Static arguments configured on the event binding override same-named record fields. Metadata includes `surface`, `formId`, `formName`, `tableName`, and `event`. -The Admin Forms designer preserves form event bindings and exposes them in the property inspector when no control is selected. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. +The Admin Forms designer preserves form event bindings and exposes them in the property inspector when no control is selected. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. The same editor can attach a visual action sequence to the event. Admin Forms also include control-level trusted command events. Form controls store event names, command names, and optional JSON arguments in the form definition. At runtime, the renderer invokes the registered host command with the current record fields plus event-specific arguments. @@ -383,7 +383,7 @@ Supported control events in this slice are: Control event metadata includes the Forms metadata plus `event`, `controlId`, `controlType`, and `fieldName` for bound controls. Arguments include current record fields and event details such as `fieldName`, `value`, and `oldValue` for field changes. Static arguments configured on the event binding override same-named runtime arguments. -The Admin Forms designer exposes selected-control event bindings in the property inspector. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. +The Admin Forms designer exposes selected-control event bindings in the property inspector. If the host has registered trusted commands, the designer shows those command names; otherwise it stores the command name typed by the designer. Selected-control events use the same visual action-sequence editor as form lifecycle events. Admin Forms also include a command button control. Command buttons store a display label, a command name, and optional JSON arguments in the form definition. At runtime, clicking the button invokes the registered host command with the current record fields plus the configured arguments. Command buttons can also use `ControlEventKind.OnClick` bindings, which allows a button to be driven entirely by the shared control-event model. @@ -481,8 +481,12 @@ var form = existingForm with }; ``` -The Admin Forms property inspector exposes action sequences as editable JSON on -form-level and selected-control event bindings. +The Admin Forms property inspector exposes action sequences with a visual +editor on form-level and selected-control event bindings. Designers can add a +sequence, name it, add `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop` +steps, reorder or remove steps, choose registered commands when available, and +toggle per-step `StopOnFailure`. JSON editing remains only for optional binding +or `RunCommand` step argument payloads. For `RunCommand`, command arguments are built from current record fields, binding arguments, runtime event arguments, and step arguments, with later diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor new file mode 100644 index 00000000..497e4541 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor @@ -0,0 +1,327 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbCommandRegistry CommandRegistry + +
+ @if (ActionSequence is null) + { + + } + else + { +
+
+ + +
+ +
+ + @if (ActionSequence.Steps.Count == 0) + { +
No action steps
+ } + + @for (int i = 0; i < ActionSequence.Steps.Count; i++) + { + var idx = i; + var step = ActionSequence.Steps[idx]; +
+
+
+ + +
+ + + +
+ + @switch (step.Kind) + { + case DbActionKind.RunCommand: +
+ + @if (RegisteredCommands.Count > 0) + { + + } + else + { + + } +
+
+ + +
+ break; + case DbActionKind.SetFieldValue: +
+
+ + +
+
+ + +
+
+ break; + case DbActionKind.ShowMessage: + case DbActionKind.Stop: +
+ + +
+ break; + } + +
+ +
+
+ } + + @if (!string.IsNullOrWhiteSpace(_argumentError)) + { +
@_argumentError
+ } + +
+ + + + +
+ } +
+ +@code { + [Parameter] public DbActionSequence? ActionSequence { get; set; } + [Parameter] public EventCallback ActionSequenceChanged { get; set; } + + private readonly Dictionary _argumentText = []; + private string? _argumentError; + + private static readonly DbActionKind[] ActionKinds = Enum.GetValues(); + private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); + + protected override void OnParametersSet() + { + if (ActionSequence is null) + { + _argumentText.Clear(); + return; + } + + for (int i = 0; i < ActionSequence.Steps.Count; i++) + _argumentText.TryAdd(i, FormatArguments(ActionSequence.Steps[i].Arguments)); + + foreach (int staleIndex in _argumentText.Keys.Where(index => index >= ActionSequence.Steps.Count).ToList()) + _argumentText.Remove(staleIndex); + } + + private Task CreateSequence() + => ActionSequenceChanged.InvokeAsync(new DbActionSequence([])); + + private Task ClearSequence() + { + _argumentText.Clear(); + _argumentError = null; + return ActionSequenceChanged.InvokeAsync(null); + } + + private Task UpdateName(string? name) + => UpdateSequence(CurrentSequence() with { Name = string.IsNullOrWhiteSpace(name) ? null : name.Trim() }); + + private Task AddStep(DbActionKind kind) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + steps.Add(CreateDefaultStep(kind)); + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task RemoveStep(int index) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + if (index < 0 || index >= steps.Count) + return Task.CompletedTask; + + steps.RemoveAt(index); + RebuildArgumentText(steps); + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task MoveStep(int index, int delta) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + int target = index + delta; + if (index < 0 || index >= steps.Count || target < 0 || target >= steps.Count) + return Task.CompletedTask; + + (steps[index], steps[target]) = (steps[target], steps[index]); + RebuildArgumentText(steps); + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task UpdateKind(int index, DbActionStep step, string? value) + { + if (!Enum.TryParse(value, ignoreCase: true, out DbActionKind kind)) + return Task.CompletedTask; + + DbActionStep updated = CreateDefaultStep(kind) with + { + Arguments = step.Arguments, + StopOnFailure = step.StopOnFailure, + }; + return ReplaceStep(index, updated); + } + + private Task UpdateCommandName(int index, DbActionStep step, string? commandName) + => ReplaceStep(index, step with { CommandName = string.IsNullOrWhiteSpace(commandName) ? null : commandName.Trim() }); + + private Task UpdateTarget(int index, DbActionStep step, string? target) + => ReplaceStep(index, step with { Target = string.IsNullOrWhiteSpace(target) ? null : target.Trim() }); + + private Task UpdateValue(int index, DbActionStep step, string? value) + => ReplaceStep(index, step with { Value = string.IsNullOrEmpty(value) ? null : value }); + + private Task UpdateMessage(int index, DbActionStep step, string? message) + => ReplaceStep(index, step with { Message = string.IsNullOrWhiteSpace(message) ? null : message }); + + private Task UpdateStepStopOnFailure(int index, DbActionStep step, bool stopOnFailure) + => ReplaceStep(index, step with { StopOnFailure = stopOnFailure }); + + private async Task UpdateArguments(int index, DbActionStep step, string text) + { + _argumentText[index] = text; + _argumentError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + await ReplaceStep(index, step with { Arguments = null }); + return; + } + + try + { + IReadOnlyDictionary? arguments = + JsonSerializer.Deserialize>(text, JsonDefaults.Options); + await ReplaceStep(index, step with { Arguments = arguments }); + } + catch (JsonException ex) + { + _argumentError = $"Invalid step arguments JSON: {ex.Message}"; + } + } + + private Task ReplaceStep(int index, DbActionStep step) + { + DbActionSequence sequence = CurrentSequence(); + List steps = sequence.Steps.ToList(); + if (index < 0 || index >= steps.Count) + return Task.CompletedTask; + + steps[index] = step; + return UpdateSequence(sequence with { Steps = steps }); + } + + private Task UpdateSequence(DbActionSequence sequence) + => ActionSequenceChanged.InvokeAsync(sequence); + + private DbActionSequence CurrentSequence() + => ActionSequence ?? new DbActionSequence([]); + + private DbActionStep CreateDefaultStep(DbActionKind kind) + => kind switch + { + DbActionKind.RunCommand => new DbActionStep(kind, CommandName: RegisteredCommands.FirstOrDefault()?.Name), + DbActionKind.SetFieldValue => new DbActionStep(kind, Target: string.Empty, Value: string.Empty), + DbActionKind.ShowMessage => new DbActionStep(kind, Message: string.Empty), + DbActionKind.Stop => new DbActionStep(kind), + _ => new DbActionStep(kind), + }; + + private string GetArgumentsText(int index, DbActionStep step) + { + if (_argumentText.TryGetValue(index, out string? text)) + return text; + + text = FormatArguments(step.Arguments); + _argumentText[index] = text; + return text; + } + + private void RebuildArgumentText(IReadOnlyList steps) + { + _argumentText.Clear(); + for (int i = 0; i < steps.Count; i++) + _argumentText[i] = FormatArguments(steps[i].Arguments); + } + + private bool ShouldRenderMissingCommand(string? commandName) + => !string.IsNullOrWhiteSpace(commandName) + && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + private static string FormatArguments(IReadOnlyDictionary? arguments) + => arguments is null || arguments.Count == 0 + ? string.Empty + : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private static string FormatValue(object? value) + => value switch + { + null => string.Empty, + JsonElement { ValueKind: JsonValueKind.String } json => json.GetString() ?? string.Empty, + JsonElement json => json.ToString(), + _ => value.ToString() ?? string.Empty, + }; + + private static string GetActionLabel(DbActionKind kind) + => kind switch + { + DbActionKind.RunCommand => "Run Command", + DbActionKind.SetFieldValue => "Set Field Value", + DbActionKind.ShowMessage => "Show Message", + DbActionKind.Stop => "Stop", + _ => kind.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor index e164118b..769d6b5a 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor @@ -71,10 +71,8 @@
- +
} @@ -83,10 +81,6 @@ {
@_argumentError
} - @if (!string.IsNullOrWhiteSpace(_actionSequenceError)) - { -
@_actionSequenceError
- } @@ -96,9 +90,7 @@ [Parameter] public EventCallback> EventBindingsChanged { get; set; } private readonly Dictionary _argumentText = []; - private readonly Dictionary _actionSequenceText = []; private string? _argumentError; - private string? _actionSequenceError; private static readonly ControlEventKind[] EventKinds = Enum.GetValues(); @@ -107,15 +99,10 @@ protected override void OnParametersSet() { for (int i = 0; i < EventBindings.Count; i++) - { _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); - _actionSequenceText.TryAdd(i, FormatActionSequence(EventBindings[i].ActionSequence)); - } foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) _argumentText.Remove(staleIndex); - foreach (int staleIndex in _actionSequenceText.Keys.Where(index => index >= EventBindings.Count).ToList()) - _actionSequenceText.Remove(staleIndex); } private async Task AddBinding() @@ -125,7 +112,6 @@ .Append(new ControlEventBinding(ControlEventKind.OnClick, commandName)) .ToList(); _argumentText[updated.Count - 1] = string.Empty; - _actionSequenceText[updated.Count - 1] = string.Empty; await EventBindingsChanged.InvokeAsync(updated); } @@ -137,7 +123,6 @@ updated.RemoveAt(index); RebuildArgumentText(updated); - RebuildActionSequenceText(updated); await EventBindingsChanged.InvokeAsync(updated); } @@ -178,27 +163,8 @@ } } - private async Task UpdateActionSequence(int index, ControlEventBinding binding, string text) - { - _actionSequenceText[index] = text; - _actionSequenceError = null; - - if (string.IsNullOrWhiteSpace(text)) - { - await ReplaceBinding(index, binding with { ActionSequence = null }); - return; - } - - try - { - DbActionSequence? actionSequence = JsonSerializer.Deserialize(text, JsonDefaults.Options); - await ReplaceBinding(index, binding with { ActionSequence = actionSequence }); - } - catch (JsonException ex) - { - _actionSequenceError = $"Invalid action sequence JSON: {ex.Message}"; - } - } + private Task UpdateActionSequence(int index, ControlEventBinding binding, DbActionSequence? actionSequence) + => ReplaceBinding(index, binding with { ActionSequence = actionSequence }); private async Task ReplaceBinding(int index, ControlEventBinding binding) { @@ -220,16 +186,6 @@ return text; } - private string GetActionSequenceText(int index, ControlEventBinding binding) - { - if (_actionSequenceText.TryGetValue(index, out string? text)) - return text; - - text = FormatActionSequence(binding.ActionSequence); - _actionSequenceText[index] = text; - return text; - } - private void RebuildArgumentText(IReadOnlyList updated) { _argumentText.Clear(); @@ -237,13 +193,6 @@ _argumentText[i] = FormatArguments(updated[i].Arguments); } - private void RebuildActionSequenceText(IReadOnlyList updated) - { - _actionSequenceText.Clear(); - for (int i = 0; i < updated.Count; i++) - _actionSequenceText[i] = FormatActionSequence(updated[i].ActionSequence); - } - private bool ShouldRenderMissingCommand(string commandName) => !string.IsNullOrWhiteSpace(commandName) && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); @@ -252,9 +201,4 @@ => arguments is null || arguments.Count == 0 ? string.Empty : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); - - private static string FormatActionSequence(DbActionSequence? actionSequence) - => actionSequence is null - ? string.Empty - : JsonSerializer.Serialize(actionSequence, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor index 16baaaef..691a8b5d 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor @@ -70,10 +70,8 @@
- +
} @@ -82,10 +80,6 @@ {
@_argumentError
} - @if (!string.IsNullOrWhiteSpace(_actionSequenceError)) - { -
@_actionSequenceError
- } @@ -95,9 +89,7 @@ [Parameter] public EventCallback> EventBindingsChanged { get; set; } private readonly Dictionary _argumentText = []; - private readonly Dictionary _actionSequenceText = []; private string? _argumentError; - private string? _actionSequenceError; private static readonly FormEventKind[] EventKinds = Enum.GetValues(); @@ -106,15 +98,10 @@ protected override void OnParametersSet() { for (int i = 0; i < EventBindings.Count; i++) - { _argumentText.TryAdd(i, FormatArguments(EventBindings[i].Arguments)); - _actionSequenceText.TryAdd(i, FormatActionSequence(EventBindings[i].ActionSequence)); - } foreach (int staleIndex in _argumentText.Keys.Where(index => index >= EventBindings.Count).ToList()) _argumentText.Remove(staleIndex); - foreach (int staleIndex in _actionSequenceText.Keys.Where(index => index >= EventBindings.Count).ToList()) - _actionSequenceText.Remove(staleIndex); } private async Task AddBinding() @@ -124,7 +111,6 @@ .Append(new FormEventBinding(FormEventKind.OnLoad, commandName)) .ToList(); _argumentText[updated.Count - 1] = string.Empty; - _actionSequenceText[updated.Count - 1] = string.Empty; await EventBindingsChanged.InvokeAsync(updated); } @@ -136,7 +122,6 @@ updated.RemoveAt(index); RebuildArgumentText(updated); - RebuildActionSequenceText(updated); await EventBindingsChanged.InvokeAsync(updated); } @@ -177,27 +162,8 @@ } } - private async Task UpdateActionSequence(int index, FormEventBinding binding, string text) - { - _actionSequenceText[index] = text; - _actionSequenceError = null; - - if (string.IsNullOrWhiteSpace(text)) - { - await ReplaceBinding(index, binding with { ActionSequence = null }); - return; - } - - try - { - DbActionSequence? actionSequence = JsonSerializer.Deserialize(text, JsonDefaults.Options); - await ReplaceBinding(index, binding with { ActionSequence = actionSequence }); - } - catch (JsonException ex) - { - _actionSequenceError = $"Invalid action sequence JSON: {ex.Message}"; - } - } + private Task UpdateActionSequence(int index, FormEventBinding binding, DbActionSequence? actionSequence) + => ReplaceBinding(index, binding with { ActionSequence = actionSequence }); private async Task ReplaceBinding(int index, FormEventBinding binding) { @@ -219,16 +185,6 @@ return text; } - private string GetActionSequenceText(int index, FormEventBinding binding) - { - if (_actionSequenceText.TryGetValue(index, out string? text)) - return text; - - text = FormatActionSequence(binding.ActionSequence); - _actionSequenceText[index] = text; - return text; - } - private void RebuildArgumentText(IReadOnlyList updated) { _argumentText.Clear(); @@ -236,13 +192,6 @@ _argumentText[i] = FormatArguments(updated[i].Arguments); } - private void RebuildActionSequenceText(IReadOnlyList updated) - { - _actionSequenceText.Clear(); - for (int i = 0; i < updated.Count; i++) - _actionSequenceText[i] = FormatActionSequence(updated[i].ActionSequence); - } - private bool ShouldRenderMissingCommand(string commandName) => !string.IsNullOrWhiteSpace(commandName) && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); @@ -251,9 +200,4 @@ => arguments is null || arguments.Count == 0 ? string.Empty : JsonSerializer.Serialize(arguments, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); - - private static string FormatActionSequence(DbActionSequence? actionSequence) - => actionSequence is null - ? string.Empty - : JsonSerializer.Serialize(actionSequence, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); } diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index ff62cf44..b948cbf7 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -96,8 +96,9 @@ properties, optional validation overrides, optional renderer hints, and optional Form and control event bindings can reference a trusted command name and can optionally include a `DbActionSequence`. Action sequences store declarative steps such as `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop`; they do -not store C# source or serialized delegates. The property inspector exposes the -sequence as JSON on form-level and selected-control event bindings. +not store C# source or serialized delegates. The property inspector exposes a +visual action-sequence editor on form-level and selected-control event bindings; +JSON editing is limited to optional command argument payloads. ## Build diff --git a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css index a46fcb56..786063d2 100644 --- a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css +++ b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css @@ -1493,6 +1493,31 @@ background: #d2e3fc; } +.ase-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ase-header { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.ase-step { + border: 1px solid #e4e4e4; + border-radius: 4px; + padding: 6px; + background: #fff; +} + +.ase-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + /* ===== Layers Panel ===== */ .layers-panel { flex: 1; diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs index e30337eb..c90c5d4a 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -1,5 +1,6 @@ using CSharpDB.Admin.Forms.Components.Designer; using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Tests.Components.Designer; @@ -31,6 +32,40 @@ public void ToFormDefinition_PreservesEventBindings() Assert.Equal("manual", binding.Arguments!["reason"]); } + [Fact] + public void ToFormDefinition_PreservesFormActionSequences() + { + var state = new DesignerState(); + var form = CreateForm() with + { + EventBindings = + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Ready"), + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditChange"), + ], + Name: "PrepareRecord")), + ], + }; + + state.LoadForm(form); + + FormDefinition saved = state.ToFormDefinition(); + + FormEventBinding binding = Assert.Single(saved.EventBindings!); + Assert.NotNull(binding.ActionSequence); + Assert.Equal("PrepareRecord", binding.ActionSequence!.Name); + Assert.Equal(DbActionKind.SetFieldValue, binding.ActionSequence.Steps[0].Kind); + Assert.Equal("Status", binding.ActionSequence.Steps[0].Target); + Assert.Equal("Ready", binding.ActionSequence.Steps[0].Value); + Assert.Equal(DbActionKind.RunCommand, binding.ActionSequence.Steps[1].Kind); + Assert.Equal("AuditChange", binding.ActionSequence.Steps[1].CommandName); + } + [Fact] public void UpdateEventBindings_ReplacesFormLevelBindings() { @@ -82,6 +117,42 @@ public void UpdateControlEventBindings_ReplacesSelectedControlBindings() Assert.False(binding.StopOnFailure); } + [Fact] + public void UpdateControlEventBindings_ReplacesActionSequences() + { + var state = new DesignerState(); + ControlDefinition button = new( + "ship", + "commandButton", + new Rect(0, 0, 120, 32), + null, + PropertyBag.Empty, + null); + state.LoadForm(CreateForm() with { Controls = [button] }); + + state.UpdateControlEventBindings( + "ship", + [ + new ControlEventBinding( + ControlEventKind.OnClick, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.ShowMessage, Message: "Queued."), + ], + Name: "NotifyClick")), + ]); + + FormDefinition saved = state.ToFormDefinition(); + + ControlEventBinding binding = Assert.Single(saved.Controls[0].EventBindings!); + Assert.NotNull(binding.ActionSequence); + Assert.Equal("NotifyClick", binding.ActionSequence!.Name); + DbActionStep step = Assert.Single(binding.ActionSequence.Steps); + Assert.Equal(DbActionKind.ShowMessage, step.Kind); + Assert.Equal("Queued.", step.Message); + } + private static FormDefinition CreateForm() => new( "customers-form", From 00ff4f1353930ccfd54aa55fe5196807e4c40f94 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 11:56:28 -0700 Subject: [PATCH 10/39] Add automation metadata import export polish --- RELEASE_NOTES.md | 30 ++- docs/admin-forms-access-parity/README.md | 3 +- docs/trusted-csharp-functions/README.md | 19 +- .../Models/FormDefinition.cs | 5 +- src/CSharpDB.Admin.Forms/README.md | 9 +- .../Services/DbFormRepository.cs | 9 +- .../Services/FormAutomationMetadata.cs | 93 +++++++ .../Models/ReportDefinition.cs | 5 +- src/CSharpDB.Admin.Reports/README.md | 7 +- .../Services/DbReportRepository.cs | 9 +- .../Services/ReportAutomationMetadata.cs | 44 +++ .../Models/PipelineAutomationMetadata.cs | 68 +++++ .../Models/PipelinePackageDefinition.cs | 1 + src/CSharpDB.Pipelines/README.md | 5 +- .../PipelinePackageSerializer.cs | 14 +- .../Validation/PipelinePackageValidator.cs | 53 ++++ .../DbAutomationMetadata.cs | 251 ++++++++++++++++++ .../Serialization/JsonRoundtripTests.cs | 59 ++++ .../Services/DbFormRepositoryTests.cs | 36 ++- .../Services/DbReportRepositoryTests.cs | 43 +++ .../PipelinePackageSerializerTests.cs | 23 ++ .../PipelinePackageValidatorTests.cs | 71 +++++ .../CSharpDB.Tests/AutomationMetadataTests.cs | 50 ++++ 23 files changed, 884 insertions(+), 23 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs create mode 100644 src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs create mode 100644 src/CSharpDB.Pipelines/Models/PipelineAutomationMetadata.cs create mode 100644 src/CSharpDB.Primitives/DbAutomationMetadata.cs create mode 100644 tests/CSharpDB.Tests/AutomationMetadataTests.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 350a3edb..e037605d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -25,7 +25,8 @@ calculated text, and pipeline filter/derive expressions. - Admin Forms formulas and Admin Reports calculated expressions can use the same registry while preserving existing arithmetic and aggregate behavior. - Pipeline filter and derived-column expressions can call registered functions; - package definitions continue to store function names and expressions only. + package definitions store expressions plus generated automation metadata, but + never C# function bodies. - Added the usage guide at `docs/trusted-csharp-functions/README.md`. ### Trusted Commands And Form Events @@ -79,11 +80,29 @@ calculated text, and pipeline filter/derive expressions. trusted host applications. - Pipeline packages can now include trusted command hooks for `OnRunStarted`, `OnBatchCompleted`, `OnRunSucceeded`, and `OnRunFailed`. Package JSON stores - hook names and arguments only; command bodies remain host-registered code. + hook names, arguments, and generated automation metadata only; command bodies + remain host-registered code. - Pipeline hook failures fail the run through `PipelineRunResult`; failure-hook errors are appended to the failed run summary instead of recursively dispatching more failure hooks. +### Stored Automation Metadata + +- Added shared `DbAutomationMetadata`, command references, and scalar-function + references so portable definitions can declare the trusted host callbacks + they expect without storing C# code. +- Admin Forms, Admin Reports, and pipeline packages now regenerate automation + metadata during repository save/load or package serialization/deserialization. + Older JSON without automation metadata is backfilled on read. +- Form metadata captures trusted form events, command buttons, selected-control + events, action-sequence `RunCommand` steps, and computed-formula scalar + functions. +- Report metadata captures preview lifecycle command bindings and calculated + text scalar functions. +- Pipeline package metadata captures command hooks and scalar functions used by + filter and derived-column expressions; package validation reports stale + automation manifests so packages can be re-exported. + ### Developer Experience - Added `samples/trusted-csharp-host`, a VS Code-ready C# host project for @@ -131,6 +150,9 @@ calculated text, and pipeline filter/derive expressions. - Added report-event dispatcher and preview lifecycle tests, pipeline hook serialization/validation/orchestrator tests, and shared command argument conversion tests. +- Added automation metadata tests covering manifest extraction, JSON + round-tripping, repository persistence/backfill, pipeline package + import/export, and stale package metadata validation. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: @@ -160,6 +182,10 @@ otherwise neutral to improved. - Passed with `0` warnings and `0` errors. - `dotnet test CSharpDB.slnx -c Release --no-build -m:1 -- RunConfiguration.DisableParallelization=true` - Non-parallel unit test run passed with `1,663` tests. +- Phase 5 local validation used `dotnet build CSharpDB.slnx --no-restore -m:1` + and `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,703` tests after adding + automation metadata coverage. - `dotnet pack` smoke for the release workflow packages with `-p:Version=3.6.0` - Produced `11` local packages: diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index 4c4c39c8..93ca0b3d 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -29,6 +29,7 @@ The current forms surface already includes: - declarative form action sequences for run-command, set-field, show-message, and stop steps - visual designer editing for form and selected-control action sequences +- generated automation metadata for export/import host callback requirements ## Added Review Findings @@ -104,7 +105,7 @@ Expected fix: | Feature | Status | Notes | | --- | --- | --- | | Command button control | Partial | Trusted command buttons can invoke host-registered C# commands and action-only click sequences; built-in navigation/save/delete actions remain future work. | -| Action model | Partial | Declarative action sequences support run-command, set-field, show-message, and stop steps with visual designer editing for form and selected-control events; open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, conditions, and loops remain future work. | +| Action model | Partial | Declarative action sequences support run-command, set-field, show-message, and stop steps with visual designer editing and generated automation metadata for form and selected-control events; open form, save, delete, navigate, apply filter, clear filter, run SQL/procedure, conditions, and loops remain future work. | | Event hooks | Partial | Form lifecycle events, command-button clicks, and selected control events can call trusted commands; additional Access-style events remain future work. | | Conditional UI rules | Planned | Add visible/enabled/read-only expressions for controls. | diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 3f3d5a77..783b228c 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -297,6 +297,7 @@ The practical rule is: - Embedded or direct client: register functions in `DatabaseOptions` or `DirectDatabaseOptions`. - Remote client: register functions where the daemon, API host, or application server opens the database. - Pipeline packages, report definitions, form definitions, procedures, and SQL text store function names and expressions only. They do not store C# function bodies. +- Admin Forms, Admin Reports, and pipeline packages also store generated `automation` metadata that lists required trusted commands and scalar functions by name, surface, and location. This is an import/export contract for hosts; it is not executable code. --- @@ -493,6 +494,12 @@ binding arguments, runtime event arguments, and step arguments, with later sources overriding earlier ones. Command metadata includes the Forms metadata plus `actionKind`, `actionStep`, and optional `actionSequence`. +When forms are saved through `DbFormRepository` or exported through +`FormAutomationMetadata.NormalizeForExport(...)`, the definition's `automation` +metadata is regenerated from form events, command buttons, selected-control +events, action-sequence `RunCommand` steps, and computed-control formulas. +Older form JSON without automation metadata is backfilled when it is loaded. + `SetFieldValue` can update mutable records in form lifecycle events such as `BeforeInsert` and `BeforeUpdate`, and it can update the current rendered record from control events or command-button clicks. It does not add built-in database @@ -559,6 +566,12 @@ Supported report events are: Command context arguments include render metrics such as `rowCount`, `loadedRowCount`, `rowTruncated`, `pageCount`, `isTruncated`, and `hasSchemaDrift` depending on the event. Static arguments configured on the binding override same-named runtime arguments. Metadata includes `surface = AdminReports`, `reportId`, `reportName`, `sourceKind`, `sourceName`, and `event`. +When reports are saved through `DbReportRepository` or exported through +`ReportAutomationMetadata.NormalizeForExport(...)`, the definition's +`automation` metadata is regenerated from report event bindings and calculated +text expressions. Older report JSON without automation metadata is backfilled +when it is loaded. + Register report commands through the reports service registration overload: ```csharp @@ -640,9 +653,9 @@ var package = new PipelinePackageDefinition await runner.RunPackageAsync(package); ``` -Pipeline package JSON stores only expressions such as `NormalizeStatus(status)`. The C# delegate must be registered by the process that runs the package. +Pipeline package JSON stores expressions such as `NormalizeStatus(status)` plus generated `automation` metadata listing the required scalar function names. The C# delegate must be registered by the process that runs the package. -Pipelines can also invoke trusted commands from run hooks. Hook definitions are serialized with the package, but they store only the hook event, command name, and optional static arguments: +Pipelines can also invoke trusted commands from run hooks. Hook definitions are serialized with the package, but they store only the hook event, command name, optional static arguments, and generated automation metadata: ```csharp var commands = DbCommandRegistry.Create(builder => @@ -700,6 +713,8 @@ Supported pipeline hook events are: Hook arguments include `runId`, `pipelineName`, `pipelineVersion`, `mode`, `event`, `status`, `rowsRead`, `rowsWritten`, `rowsRejected`, and `batchesCompleted`. Batch hooks also include `batchNumber`, `startingRowNumber`, and `batchRowCount`. Failure hooks include `errorSummary`. Metadata includes `surface = Pipelines`, `pipelineName`, `pipelineVersion`, `runId`, `mode`, and `event`. +`PipelinePackageSerializer` refreshes the `automation` manifest when packages are serialized, saved, deserialized, or loaded from disk. `PipelinePackageValidator` accepts older packages without a manifest, but if a manifest is present and no longer matches the package expressions/hooks, validation reports stale automation metadata so the package can be re-exported. + `Validate` mode does not invoke command hooks, so package validation stays side-effect free. Missing command registration or a failing hook with `StopOnFailure = true` fails the run normally. For `OnRunFailed`, hook failures are appended to the failed run's error summary instead of recursively dispatching more failure hooks. --- diff --git a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs index 7c2c0305..ce37e373 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Forms.Models; public sealed record FormDefinition( @@ -9,4 +11,5 @@ public sealed record FormDefinition( LayoutDefinition Layout, IReadOnlyList Controls, IReadOnlyDictionary? RendererHints = null, - IReadOnlyList? EventBindings = null); + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null); diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index b948cbf7..57a37f53 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -18,6 +18,7 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. - trusted command-backed form events and command buttons - trusted command-backed selected-control events - declarative action sequences for form and selected-control events +- generated automation metadata for import/export host callback requirements ## Main Components @@ -85,7 +86,8 @@ public sealed record FormDefinition( LayoutDefinition Layout, IReadOnlyList Controls, IReadOnlyDictionary? RendererHints = null, - IReadOnlyList? EventBindings = null); + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null); ``` Controls are stored as `ControlDefinition` records with geometry, binding, @@ -100,6 +102,11 @@ not store C# source or serialized delegates. The property inspector exposes a visual action-sequence editor on form-level and selected-control event bindings; JSON editing is limited to optional command argument payloads. +`DbFormRepository` regenerates `Automation` on save/load. The manifest records +trusted command and scalar-function names used by form events, command buttons, +selected-control events, action sequences, and computed formulas so exported +form JSON tells a host which callbacks it must register. + ## Build ```powershell diff --git a/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs b/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs index ea9913e3..72f0f579 100644 --- a/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs +++ b/src/CSharpDB.Admin.Forms/Services/DbFormRepository.cs @@ -167,30 +167,33 @@ private static FormDefinition DeserializeForm(Dictionary row) string json = row["definition_json"]?.ToString() ?? throw new InvalidOperationException("Stored form definition is missing JSON."); - return JsonSerializer.Deserialize(json, JsonDefaults.Options) + FormDefinition form = JsonSerializer.Deserialize(json, JsonDefaults.Options) ?? throw new InvalidOperationException("Stored form definition JSON could not be deserialized."); + return FormAutomationMetadata.NormalizeForExport(form); } private static FormDefinition NormalizeForCreate(FormDefinition form) { ValidateForPersistence(form); - return form with + FormDefinition stored = form with { FormId = string.IsNullOrWhiteSpace(form.FormId) ? Guid.NewGuid().ToString("N") : form.FormId, Name = string.IsNullOrWhiteSpace(form.Name) ? $"{form.TableName} Form" : form.Name.Trim(), DefinitionVersion = 1, }; + return FormAutomationMetadata.NormalizeForExport(stored); } private static FormDefinition NormalizeForUpdate(string formId, int expectedVersion, FormDefinition form) { ValidateForPersistence(form); - return form with + FormDefinition stored = form with { FormId = formId, Name = string.IsNullOrWhiteSpace(form.Name) ? $"{form.TableName} Form" : form.Name.Trim(), DefinitionVersion = expectedVersion + 1, }; + return FormAutomationMetadata.NormalizeForExport(stored); } private static void ValidateForPersistence(FormDefinition form) diff --git a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs new file mode 100644 index 00000000..c3759390 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs @@ -0,0 +1,93 @@ +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public static class FormAutomationMetadata +{ + private const string Surface = "admin.forms"; + private static readonly string[] IgnoredFormulaFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + + public static FormDefinition NormalizeForExport(FormDefinition form) + { + ArgumentNullException.ThrowIfNull(form); + + DbAutomationMetadata metadata = Build(form); + return form with { Automation = metadata.IsEmpty ? null : metadata }; + } + + public static DbAutomationMetadata Build(FormDefinition form) + { + ArgumentNullException.ThrowIfNull(form); + + var builder = new DbAutomationMetadataBuilder(); + foreach (FormEventBinding binding in form.EventBindings ?? []) + { + string bindingLocation = $"form.events.{binding.Event}"; + builder.AddCommand(binding.CommandName, Surface, bindingLocation); + AddActionSequence(builder, binding.ActionSequence, bindingLocation); + } + + foreach (ControlDefinition control in form.Controls) + { + AddCommandButton(builder, control); + AddComputedFormula(builder, control); + foreach (ControlEventBinding binding in control.EventBindings ?? []) + { + string bindingLocation = $"controls.{control.ControlId}.events.{binding.Event}"; + builder.AddCommand(binding.CommandName, Surface, bindingLocation); + AddActionSequence(builder, binding.ActionSequence, bindingLocation); + } + } + + return builder.Build(); + } + + private static void AddCommandButton(DbAutomationMetadataBuilder builder, ControlDefinition control) + { + if (!string.Equals(control.ControlType, "commandButton", StringComparison.OrdinalIgnoreCase)) + return; + + if (control.Props.Values.TryGetValue("commandName", out object? commandName)) + builder.AddCommand(commandName?.ToString(), Surface, $"controls.{control.ControlId}.commandButton.click"); + } + + private static void AddComputedFormula(DbAutomationMetadataBuilder builder, ControlDefinition control) + { + if (!string.Equals(control.ControlType, "computed", StringComparison.OrdinalIgnoreCase)) + return; + + if (!control.Props.Values.TryGetValue("formula", out object? formula) || formula is null) + return; + + AddScalarFunctions(builder, formula.ToString(), $"controls.{control.ControlId}.formula"); + } + + private static void AddActionSequence( + DbAutomationMetadataBuilder builder, + DbActionSequence? sequence, + string bindingLocation) + { + if (sequence is null) + return; + + string sequenceLocation = string.IsNullOrWhiteSpace(sequence.Name) + ? $"{bindingLocation}.actionSequence" + : $"{bindingLocation}.actionSequence.{sequence.Name}"; + for (int i = 0; i < sequence.Steps.Count; i++) + { + DbActionStep step = sequence.Steps[i]; + if (step.Kind == DbActionKind.RunCommand) + builder.AddCommand(step.CommandName, Surface, $"{sequenceLocation}.steps[{i}]"); + } + } + + private static void AddScalarFunctions(DbAutomationMetadataBuilder builder, string? expression, string location) + { + foreach (DbAutomationScalarFunctionCall call in + DbAutomationExpressionInspector.FindScalarFunctionCalls(expression, IgnoredFormulaFunctions)) + { + builder.AddScalarFunction(call.Name, call.Arity, Surface, location); + } + } +} diff --git a/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs b/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs index 9b292688..0a3d2aa1 100644 --- a/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs +++ b/src/CSharpDB.Admin.Reports/Models/ReportDefinition.cs @@ -1,3 +1,5 @@ +using CSharpDB.Primitives; + namespace CSharpDB.Admin.Reports.Models; public sealed record ReportDefinition( @@ -11,4 +13,5 @@ public sealed record ReportDefinition( IReadOnlyList Sorts, IReadOnlyList Bands, IReadOnlyDictionary? RendererHints = null, - IReadOnlyList? EventBindings = null); + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null); diff --git a/src/CSharpDB.Admin.Reports/README.md b/src/CSharpDB.Admin.Reports/README.md index 3dad3a4c..fcc2a075 100644 --- a/src/CSharpDB.Admin.Reports/README.md +++ b/src/CSharpDB.Admin.Reports/README.md @@ -17,6 +17,7 @@ This project is consumed by `CSharpDB.Admin`. It is not a standalone web host. controls - preview pagination and simple expression evaluation - trusted command-backed preview lifecycle events +- generated automation metadata for import/export host callback requirements ## Main Components @@ -88,7 +89,8 @@ public sealed record ReportDefinition( IReadOnlyList Sorts, IReadOnlyList Bands, IReadOnlyDictionary? RendererHints = null, - IReadOnlyList? EventBindings = null); + IReadOnlyList? EventBindings = null, + DbAutomationMetadata? Automation = null); ``` Report layout is band-based. Each `ReportBandDefinition` owns a list of @@ -97,6 +99,9 @@ Report layout is band-based. Each `ReportBandDefinition` owns a list of `EventBindings` can reference host-registered commands for `OnOpen`, `BeforeRender`, and `AfterRender`. Report JSON stores event names, command names, and optional arguments only; C# command bodies stay in the host process. +`DbReportRepository` regenerates `Automation` on save/load so exported report +JSON lists required trusted commands and scalar functions from event bindings +and calculated text expressions. ## Build diff --git a/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs b/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs index 39a3062a..f172a9e3 100644 --- a/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs +++ b/src/CSharpDB.Admin.Reports/Services/DbReportRepository.cs @@ -288,30 +288,33 @@ SELECT chunk_text private static ReportDefinition DeserializeReportJson(string json) { - return JsonSerializer.Deserialize(json, JsonDefaults.Options) + ReportDefinition report = JsonSerializer.Deserialize(json, JsonDefaults.Options) ?? throw new InvalidOperationException("Stored report definition JSON could not be deserialized."); + return ReportAutomationMetadata.NormalizeForExport(report); } private static ReportDefinition NormalizeForCreate(ReportDefinition report) { ValidateForPersistence(report); - return report with + ReportDefinition stored = report with { ReportId = string.IsNullOrWhiteSpace(report.ReportId) ? Guid.NewGuid().ToString("N") : report.ReportId, Name = string.IsNullOrWhiteSpace(report.Name) ? $"{report.Source.Name} Report" : report.Name.Trim(), DefinitionVersion = 1, }; + return ReportAutomationMetadata.NormalizeForExport(stored); } private static ReportDefinition NormalizeForUpdate(string reportId, int expectedVersion, ReportDefinition report) { ValidateForPersistence(report); - return report with + ReportDefinition stored = report with { ReportId = reportId, Name = string.IsNullOrWhiteSpace(report.Name) ? $"{report.Source.Name} Report" : report.Name.Trim(), DefinitionVersion = expectedVersion + 1, }; + return ReportAutomationMetadata.NormalizeForExport(stored); } private static void ValidateForPersistence(ReportDefinition report) diff --git a/src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs b/src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs new file mode 100644 index 00000000..9a2e3f5f --- /dev/null +++ b/src/CSharpDB.Admin.Reports/Services/ReportAutomationMetadata.cs @@ -0,0 +1,44 @@ +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Reports.Services; + +public static class ReportAutomationMetadata +{ + private const string Surface = "admin.reports"; + private static readonly string[] IgnoredFormulaFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + + public static ReportDefinition NormalizeForExport(ReportDefinition report) + { + ArgumentNullException.ThrowIfNull(report); + + DbAutomationMetadata metadata = Build(report); + return report with { Automation = metadata.IsEmpty ? null : metadata }; + } + + public static DbAutomationMetadata Build(ReportDefinition report) + { + ArgumentNullException.ThrowIfNull(report); + + var builder = new DbAutomationMetadataBuilder(); + foreach (ReportEventBinding binding in report.EventBindings ?? []) + builder.AddCommand(binding.CommandName, Surface, $"report.events.{binding.Event}"); + + foreach (ReportBandDefinition band in report.Bands) + { + foreach (ReportControlDefinition control in band.Controls) + AddScalarFunctions(builder, control.Expression, $"bands.{band.BandId}.controls.{control.ControlId}.expression"); + } + + return builder.Build(); + } + + private static void AddScalarFunctions(DbAutomationMetadataBuilder builder, string? expression, string location) + { + foreach (DbAutomationScalarFunctionCall call in + DbAutomationExpressionInspector.FindScalarFunctionCalls(expression, IgnoredFormulaFunctions)) + { + builder.AddScalarFunction(call.Name, call.Arity, Surface, location); + } + } +} diff --git a/src/CSharpDB.Pipelines/Models/PipelineAutomationMetadata.cs b/src/CSharpDB.Pipelines/Models/PipelineAutomationMetadata.cs new file mode 100644 index 00000000..181bc9af --- /dev/null +++ b/src/CSharpDB.Pipelines/Models/PipelineAutomationMetadata.cs @@ -0,0 +1,68 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Pipelines.Models; + +public static class PipelineAutomationMetadata +{ + private const string Surface = "pipelines"; + + public static PipelinePackageDefinition NormalizeForExport(PipelinePackageDefinition package) + { + ArgumentNullException.ThrowIfNull(package); + + DbAutomationMetadata metadata = Build(package); + return WithAutomation(package, metadata.IsEmpty ? null : metadata); + } + + public static DbAutomationMetadata Build(PipelinePackageDefinition package) + { + ArgumentNullException.ThrowIfNull(package); + + var builder = new DbAutomationMetadataBuilder(); + for (int i = 0; i < package.Hooks.Count; i++) + { + PipelineCommandHookDefinition hook = package.Hooks[i]; + builder.AddCommand(hook.CommandName, Surface, $"hooks[{i}].{hook.Event}"); + } + + for (int i = 0; i < package.Transforms.Count; i++) + { + PipelineTransformDefinition transform = package.Transforms[i]; + string transformLocation = $"transforms[{i}]"; + if (transform.Kind == PipelineTransformKind.Filter) + AddScalarFunctions(builder, transform.FilterExpression, $"{transformLocation}.filterExpression"); + + if (transform.Kind == PipelineTransformKind.Derive && transform.DerivedColumns is not null) + { + for (int columnIndex = 0; columnIndex < transform.DerivedColumns.Count; columnIndex++) + { + PipelineDerivedColumn column = transform.DerivedColumns[columnIndex]; + AddScalarFunctions(builder, column.Expression, $"{transformLocation}.derivedColumns[{columnIndex}].expression"); + } + } + } + + return builder.Build(); + } + + private static PipelinePackageDefinition WithAutomation(PipelinePackageDefinition package, DbAutomationMetadata? automation) + => new() + { + Name = package.Name, + Version = package.Version, + Description = package.Description, + Source = package.Source, + Transforms = package.Transforms, + Destination = package.Destination, + Options = package.Options, + Incremental = package.Incremental, + Hooks = package.Hooks, + Automation = automation, + }; + + private static void AddScalarFunctions(DbAutomationMetadataBuilder builder, string? expression, string location) + { + foreach (DbAutomationScalarFunctionCall call in DbAutomationExpressionInspector.FindScalarFunctionCalls(expression)) + builder.AddScalarFunction(call.Name, call.Arity, Surface, location); + } +} diff --git a/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs b/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs index 75dab8db..1186db89 100644 --- a/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs +++ b/src/CSharpDB.Pipelines/Models/PipelinePackageDefinition.cs @@ -13,6 +13,7 @@ public sealed class PipelinePackageDefinition public PipelineExecutionOptions Options { get; init; } = new(); public PipelineIncrementalOptions? Incremental { get; init; } public IReadOnlyList Hooks { get; init; } = []; + public DbAutomationMetadata? Automation { get; init; } } public enum PipelineCommandHookEvent diff --git a/src/CSharpDB.Pipelines/README.md b/src/CSharpDB.Pipelines/README.md index 77b9786f..de3ec152 100644 --- a/src/CSharpDB.Pipelines/README.md +++ b/src/CSharpDB.Pipelines/README.md @@ -16,7 +16,8 @@ The built-in runtime can validate packages, serialize them to JSON, execute them in batches, capture checkpoints, and report rejects and run metrics. Packages can also name trusted host commands for lifecycle hooks; command bodies are registered by the process that runs the pipeline and are not serialized into -the package. +the package. Package JSON includes generated automation metadata that lists the +trusted commands and scalar functions a host must register. Current boundary: - Built-in runtime components currently support CSV and JSON file sources/destinations @@ -33,6 +34,7 @@ Current boundary: - **Built-in transforms**: select, rename, cast, filter, derive, deduplicate - **Checkpointing hooks**: pluggable checkpoint store and run logger abstractions - **Trusted command hooks**: host-registered commands for run started, batch completed, run succeeded, and run failed events +- **Automation metadata**: generated import/export manifest for trusted command and scalar function names - **Batch metrics**: rows read/written/rejected plus batch counts ## Usage @@ -211,6 +213,7 @@ The output file contains the active customers only, with duplicate IDs removed: - Relative source file paths are searched from the current directory and app base directory; relative output paths are written relative to the current directory - `Derive` expressions are intentionally simple today: use a source column name or a literal such as `'csv'`, `123`, `true`, or `null` - Trusted command hooks are skipped in `Validate` mode. Missing command registration or a failing hook with `StopOnFailure = true` fails the run through `PipelineRunResult`. +- `PipelinePackageSerializer` regenerates `Automation` during save/load and string serialization. Validation accepts legacy packages without automation metadata, but reports stale manifests when present. ## Installation diff --git a/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs b/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs index 4de18169..f8488161 100644 --- a/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs +++ b/src/CSharpDB.Pipelines/Serialization/PipelinePackageSerializer.cs @@ -21,15 +21,16 @@ public static class PipelinePackageSerializer public static string Serialize(PipelinePackageDefinition package) { ArgumentNullException.ThrowIfNull(package); - return JsonSerializer.Serialize(package, s_options); + return JsonSerializer.Serialize(PipelineAutomationMetadata.NormalizeForExport(package), s_options); } public static PipelinePackageDefinition Deserialize(string json) { ArgumentException.ThrowIfNullOrWhiteSpace(json); - var package = JsonSerializer.Deserialize(json, s_options); - return package ?? throw new InvalidOperationException("Pipeline package JSON did not deserialize into a package definition."); + var package = JsonSerializer.Deserialize(json, s_options) + ?? throw new InvalidOperationException("Pipeline package JSON did not deserialize into a package definition."); + return PipelineAutomationMetadata.NormalizeForExport(package); } public static async Task LoadFromFileAsync(string path, CancellationToken ct = default) @@ -37,8 +38,9 @@ public static async Task LoadFromFileAsync(string pat ArgumentException.ThrowIfNullOrWhiteSpace(path); await using var stream = File.OpenRead(path); - var package = await JsonSerializer.DeserializeAsync(stream, s_options, ct); - return package ?? throw new InvalidOperationException($"Pipeline package file '{path}' did not deserialize into a package definition."); + var package = await JsonSerializer.DeserializeAsync(stream, s_options, ct) + ?? throw new InvalidOperationException($"Pipeline package file '{path}' did not deserialize into a package definition."); + return PipelineAutomationMetadata.NormalizeForExport(package); } public static async Task SaveToFileAsync(PipelinePackageDefinition package, string path, CancellationToken ct = default) @@ -53,6 +55,6 @@ public static async Task SaveToFileAsync(PipelinePackageDefinition package, stri } await using var stream = File.Create(path); - await JsonSerializer.SerializeAsync(stream, package, s_options, ct); + await JsonSerializer.SerializeAsync(stream, PipelineAutomationMetadata.NormalizeForExport(package), s_options, ct); } } diff --git a/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs b/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs index 07e56894..5efb19eb 100644 --- a/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs +++ b/src/CSharpDB.Pipelines/Validation/PipelinePackageValidator.cs @@ -1,4 +1,5 @@ using CSharpDB.Pipelines.Models; +using CSharpDB.Primitives; namespace CSharpDB.Pipelines.Validation; @@ -26,6 +27,7 @@ public static PipelineValidationResult Validate(PipelinePackageDefinition packag ValidateIncremental(package.Incremental, errors); ValidateTransforms(package.Transforms, errors); ValidateHooks(package.Hooks, errors); + ValidateAutomation(package, errors); return errors.Count == 0 ? PipelineValidationResult.Success @@ -246,6 +248,57 @@ private static void ValidateHooks(IReadOnlyList? } } + private static void ValidateAutomation(PipelinePackageDefinition package, List errors) + { + DbAutomationMetadata? automation = package.Automation; + if (automation is null) + return; + + if (automation.MetadataVersion != DbAutomationMetadata.CurrentMetadataVersion) + { + errors.Add(Error( + "pipeline.automation.version.unsupported", + "automation.metadataVersion", + $"Automation metadata version {automation.MetadataVersion} is not supported.")); + } + + DbAutomationMetadata expected = PipelineAutomationMetadata.Build(package); + HashSet expectedCommands = CommandKeys(expected.Commands); + HashSet actualCommands = CommandKeys(automation.Commands); + HashSet expectedFunctions = ScalarFunctionKeys(expected.ScalarFunctions); + HashSet actualFunctions = ScalarFunctionKeys(automation.ScalarFunctions); + + if (!expectedCommands.SetEquals(actualCommands)) + { + errors.Add(Error( + "pipeline.automation.commands.outOfDate", + "automation.commands", + "Automation command metadata is out of date. Re-export the package to refresh it.")); + } + + if (!expectedFunctions.SetEquals(actualFunctions)) + { + errors.Add(Error( + "pipeline.automation.scalarFunctions.outOfDate", + "automation.scalarFunctions", + "Automation scalar function metadata is out of date. Re-export the package to refresh it.")); + } + } + + private static HashSet CommandKeys(IReadOnlyList? commands) + => commands is null + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : commands + .Select(static command => $"{command.Name}|{command.Surface}|{command.Location}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + private static HashSet ScalarFunctionKeys(IReadOnlyList? functions) + => functions is null + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : functions + .Select(static function => $"{function.Name}|{function.Arity}|{function.Surface}|{function.Location}") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + private static void ValidateFunctionSyntax(string expression, string path, List errors) { bool inString = false; diff --git a/src/CSharpDB.Primitives/DbAutomationMetadata.cs b/src/CSharpDB.Primitives/DbAutomationMetadata.cs new file mode 100644 index 00000000..0c5326db --- /dev/null +++ b/src/CSharpDB.Primitives/DbAutomationMetadata.cs @@ -0,0 +1,251 @@ +namespace CSharpDB.Primitives; + +public sealed record DbAutomationMetadata( + int MetadataVersion = DbAutomationMetadata.CurrentMetadataVersion, + IReadOnlyList? Commands = null, + IReadOnlyList? ScalarFunctions = null) +{ + public const int CurrentMetadataVersion = 1; + + public bool IsEmpty => (Commands is null || Commands.Count == 0) + && (ScalarFunctions is null || ScalarFunctions.Count == 0); +} + +public sealed record DbAutomationCommandReference( + string Name, + string Surface, + string Location); + +public sealed record DbAutomationScalarFunctionReference( + string Name, + int Arity, + string Surface, + string Location); + +public sealed record DbAutomationScalarFunctionCall(string Name, int Arity); + +public sealed class DbAutomationMetadataBuilder +{ + private readonly List _commands = []; + private readonly List _scalarFunctions = []; + + public DbAutomationMetadataBuilder AddCommand(string? name, string surface, string location) + { + if (string.IsNullOrWhiteSpace(name)) + return this; + + ArgumentException.ThrowIfNullOrWhiteSpace(surface); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + _commands.Add(new DbAutomationCommandReference(name.Trim(), surface.Trim(), location.Trim())); + return this; + } + + public DbAutomationMetadataBuilder AddScalarFunction(string? name, int arity, string surface, string location) + { + if (string.IsNullOrWhiteSpace(name)) + return this; + + ArgumentOutOfRangeException.ThrowIfNegative(arity); + ArgumentException.ThrowIfNullOrWhiteSpace(surface); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + _scalarFunctions.Add(new DbAutomationScalarFunctionReference(name.Trim(), arity, surface.Trim(), location.Trim())); + return this; + } + + public DbAutomationMetadata Build() + => new( + DbAutomationMetadata.CurrentMetadataVersion, + SortCommands(_commands), + SortScalarFunctions(_scalarFunctions)); + + private static IReadOnlyList SortCommands(IEnumerable commands) + => commands + .GroupBy( + static command => $"{command.Name}|{command.Surface}|{command.Location}", + StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .OrderBy(static command => command.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static command => command.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static command => command.Location, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static IReadOnlyList SortScalarFunctions(IEnumerable functions) + => functions + .GroupBy( + static function => $"{function.Name}|{function.Arity}|{function.Surface}|{function.Location}", + StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .OrderBy(static function => function.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static function => function.Arity) + .ThenBy(static function => function.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static function => function.Location, StringComparer.OrdinalIgnoreCase) + .ToArray(); +} + +public static class DbAutomationExpressionInspector +{ + public static IReadOnlyList FindScalarFunctionCalls( + string? expression, + IEnumerable? ignoredNames = null) + { + if (string.IsNullOrWhiteSpace(expression)) + return []; + + HashSet ignored = ignoredNames is null + ? [] + : new HashSet(ignoredNames, StringComparer.OrdinalIgnoreCase); + var calls = new List(); + ReadOnlySpan input = expression.AsSpan(); + for (int i = 0; i < input.Length; i++) + { + char current = input[i]; + if (current is '\'' or '"') + { + i = SkipQuoted(input, i, current); + continue; + } + + if (current == '[') + { + i = SkipBracketed(input, i); + continue; + } + + if (!IsIdentifierStart(current)) + continue; + + int start = i; + i++; + while (i < input.Length && IsIdentifierPart(input[i])) + i++; + + string name = input[start..i].ToString(); + int cursor = i; + while (cursor < input.Length && char.IsWhiteSpace(input[cursor])) + cursor++; + + if (cursor >= input.Length || input[cursor] != '(') + { + i--; + continue; + } + + if (!ignored.Contains(name) && TryCountArguments(input, cursor, out int arity)) + calls.Add(new DbAutomationScalarFunctionCall(name, arity)); + + i--; + } + + return calls + .GroupBy( + static call => $"{call.Name}|{call.Arity}", + StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .OrderBy(static call => call.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static call => call.Arity) + .ToArray(); + } + + private static bool TryCountArguments(ReadOnlySpan input, int openParen, out int arity) + { + arity = 0; + int depth = 0; + bool sawArgument = false; + bool expectingArgument = true; + for (int i = openParen; i < input.Length; i++) + { + char current = input[i]; + if (current is '\'' or '"') + { + i = SkipQuoted(input, i, current); + sawArgument = true; + expectingArgument = false; + continue; + } + + if (current == '[') + { + i = SkipBracketed(input, i); + sawArgument = true; + expectingArgument = false; + continue; + } + + if (current == '(') + { + depth++; + if (depth > 1) + { + sawArgument = true; + expectingArgument = false; + } + + continue; + } + + if (current == ')') + { + depth--; + if (depth < 0) + return false; + + if (depth == 0) + { + if (expectingArgument && sawArgument) + return false; + + arity = sawArgument ? arity + 1 : 0; + return true; + } + + continue; + } + + if (current == ',' && depth == 1) + { + if (expectingArgument) + return false; + + arity++; + expectingArgument = true; + continue; + } + + if (depth == 1 && !char.IsWhiteSpace(current)) + { + sawArgument = true; + expectingArgument = false; + } + } + + return false; + } + + private static int SkipQuoted(ReadOnlySpan input, int start, char quote) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] == quote) + return i; + } + + return input.Length - 1; + } + + private static int SkipBracketed(ReadOnlySpan input, int start) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] == ']') + return i; + } + + return input.Length - 1; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + + private static bool IsIdentifierPart(char value) + => char.IsLetterOrDigit(value) || value == '_'; +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index 18d3877a..3738fe91 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Serialization; +using CSharpDB.Admin.Forms.Services; using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Tests.Serialization; @@ -201,6 +202,64 @@ public void FormEventBinding_WithActionSequence_RoundTrips() Assert.Equal("roundtrip", sequence.Steps[1].Arguments!["source"]); } + [Fact] + public void FormAutomationMetadata_NormalizeForExport_RoundTrips() + { + var form = new FormDefinition( + "f-automation", + "Automation Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, []), + [ + new ControlDefinition( + "ship", + "commandButton", + new Rect(0, 0, 120, 32), + null, + new PropertyBag(new Dictionary { ["commandName"] = "ShipOrder" }), + null), + new ControlDefinition( + "score", + "computed", + new Rect(0, 40, 120, 32), + null, + new PropertyBag(new Dictionary { ["formula"] = "=BoostScore(Score)" }), + null, + EventBindings: + [ + new ControlEventBinding( + ControlEventKind.OnChange, + "NormalizeScore", + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditScore"), + ], + Name: "ScoreActions")), + ]), + ], + EventBindings: + [ + new FormEventBinding(FormEventKind.BeforeInsert, "ValidateOrder"), + ]); + + FormDefinition normalized = FormAutomationMetadata.NormalizeForExport(form); + string json = JsonSerializer.Serialize(normalized, Options); + FormDefinition deserialized = JsonSerializer.Deserialize(json, Options)!; + + Assert.NotNull(deserialized.Automation); + Assert.Equal(DbAutomationMetadata.CurrentMetadataVersion, deserialized.Automation!.MetadataVersion); + Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "ShipOrder"); + Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "NormalizeScore"); + Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "AuditScore"); + Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "ValidateOrder"); + DbAutomationScalarFunctionReference function = Assert.Single(deserialized.Automation.ScalarFunctions!); + Assert.Equal("BoostScore", function.Name); + Assert.Equal(1, function.Arity); + Assert.Contains("\"automation\"", json); + } + [Fact] public void ControlDefinition_WithoutBinding_RoundTrips() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRepositoryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRepositoryTests.cs index e41638d7..9b896c32 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRepositoryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRepositoryTests.cs @@ -2,6 +2,7 @@ using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Services; using CSharpDB.Client.Models; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Tests.Services; @@ -45,6 +46,39 @@ public async Task CreateAsync_GeneratesFormIdAndNormalizesName() Assert.Equal(1, created.DefinitionVersion); } + [Fact] + public async Task CreateAsync_StoresGeneratedAutomationMetadata() + { + await using var db = await TestDatabaseScope.CreateAsync(); + var repository = new DbFormRepository(db.Client); + + FormDefinition created = await repository.CreateAsync(CreateForm("f-auto", "Orders", "Order Form", "sig:orders:v1") with + { + EventBindings = + [ + new FormEventBinding( + FormEventKind.OnLoad, + "LoadOrder", + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditOrderLoad"), + ], + Name: "LoadActions")), + ], + }); + FormDefinition loaded = (await repository.GetAsync(created.FormId))!; + + Assert.NotNull(created.Automation); + Assert.NotNull(loaded.Automation); + Assert.Contains(loaded.Automation!.Commands!, command => command.Name == "LoadOrder"); + Assert.Contains(loaded.Automation.Commands!, command => command.Name == "AuditOrderLoad"); + + IReadOnlyList> rows = await db.QueryRowsAsync( + "SELECT definition_json FROM __forms WHERE id = 'f-auto';"); + string json = Assert.Single(rows)["definition_json"]!.ToString()!; + Assert.Contains("\"automation\"", json); + } + [Fact] public async Task TryUpdateAsync_CorrectVersion_UpdatesAndIncrementsVersion() { @@ -146,7 +180,7 @@ public async Task PersistedFormSignature_CanBeComparedAgainstCurrentSchema() FormTableDefinition originalTable = (await provider.GetTableDefinitionAsync("Customers"))!; await repository.CreateAsync(CreateForm("f1", "Customers", "Customer Form", originalTable.SourceSchemaSignature)); - await db.Client.AddColumnAsync("Customers", "Email", DbType.Text, notNull: false, ct: TestContext.Current.CancellationToken); + await db.Client.AddColumnAsync("Customers", "Email", CSharpDB.Client.Models.DbType.Text, notNull: false, ct: TestContext.Current.CancellationToken); FormDefinition stored = (await repository.GetAsync("f1"))!; FormTableDefinition currentTable = (await provider.GetTableDefinitionAsync("Customers"))!; diff --git a/tests/CSharpDB.Admin.Reports.Tests/Services/DbReportRepositoryTests.cs b/tests/CSharpDB.Admin.Reports.Tests/Services/DbReportRepositoryTests.cs index 50962d8b..70c3a06e 100644 --- a/tests/CSharpDB.Admin.Reports.Tests/Services/DbReportRepositoryTests.cs +++ b/tests/CSharpDB.Admin.Reports.Tests/Services/DbReportRepositoryTests.cs @@ -2,6 +2,7 @@ using CSharpDB.Admin.Reports.Models; using CSharpDB.Admin.Reports.Services; using CSharpDB.Admin.Reports.Serialization; +using CSharpDB.Primitives; using System.Text.Json; namespace CSharpDB.Admin.Reports.Tests.Services; @@ -46,6 +47,48 @@ public async Task CreateAsync_GeneratesReportIdAndNormalizesName() Assert.Equal(1, created.DefinitionVersion); } + [Fact] + public async Task CreateAsync_StoresGeneratedAutomationMetadata() + { + await using var db = await TestDatabaseScope.CreateAsync(); + var repository = new DbReportRepository(db.Client); + + ReportDefinition created = await repository.CreateAsync(CreateReport("r-auto", ReportSourceKind.Table, "Orders", "Order Report", "sig:orders:v1") with + { + EventBindings = + [ + new ReportEventBinding(ReportEventKind.BeforeRender, "PrepareReport"), + ], + Bands = + [ + new ReportBandDefinition( + "detail", + ReportBandKind.Detail, + 28, + null, + [ + new ReportControlDefinition( + "total", + ReportControlType.CalculatedText, + "detail", + new Rect(0, 0, 120, 24), + null, + "=FormatTotal(Total)", + null, + new PropertyBag(new Dictionary())), + ]), + ], + }); + ReportDefinition loaded = (await repository.GetAsync(created.ReportId))!; + + Assert.NotNull(created.Automation); + Assert.NotNull(loaded.Automation); + Assert.Contains(loaded.Automation!.Commands!, command => command.Name == "PrepareReport"); + DbAutomationScalarFunctionReference function = Assert.Single(loaded.Automation.ScalarFunctions!); + Assert.Equal("FormatTotal", function.Name); + Assert.Equal(1, function.Arity); + } + [Fact] public async Task TryUpdateAsync_CorrectVersion_UpdatesAndIncrementsVersion() { diff --git a/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs index 6dd132f3..49a94e37 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelinePackageSerializerTests.cs @@ -18,6 +18,8 @@ public void Serialize_UsesCamelCaseAndEnumStrings() Assert.Contains("\"kind\": \"csvFile\"", json); Assert.Contains("\"targetType\": \"integer\"", json); Assert.Contains("\"event\": \"onRunSucceeded\"", json); + Assert.Contains("\"automation\"", json); + Assert.Contains("\"scalarFunctions\"", json); } [Fact] @@ -42,6 +44,10 @@ public void Deserialize_RoundTripsPackage() Assert.Equal("NotifyImport", hook.CommandName); Assert.Equal("ops", Assert.IsType(hook.Arguments!["channel"])); Assert.Equal(3, DbCommandArguments.FromObject(hook.Arguments["priority"]).AsInteger); + Assert.NotNull(clone.Automation); + Assert.Contains(clone.Automation!.Commands!, command => command.Name == "NotifyImport"); + Assert.Contains(clone.Automation.ScalarFunctions!, function => function.Name == "NormalizeStatus" && function.Arity == 1); + Assert.Contains(clone.Automation.ScalarFunctions!, function => function.Name == "Slugify" && function.Arity == 1); } [Fact] @@ -119,6 +125,23 @@ public async Task SaveToFileAsync_AndLoadFromFileAsync_RoundTripPackage() }, ], }, + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Filter, + FilterExpression = "NormalizeStatus(status) == 'active'", + }, + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn + { + Name = "slug", + Expression = "Slugify(name)", + }, + ], + }, ], Incremental = new PipelineIncrementalOptions { diff --git a/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs index 61960faa..e21c308a 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelinePackageValidatorTests.cs @@ -244,6 +244,77 @@ public void Validate_ReturnsError_WhenHookArgumentNameIsMissing() Assert.Contains(result.Errors, e => e.Code == "pipeline.hook.argument.name.required"); } + [Fact] + public void Validate_ReturnsSuccess_WhenAutomationMetadataIsCurrent() + { + var validPackage = CreateValidPackage(); + PipelinePackageDefinition package = PipelineAutomationMetadata.NormalizeForExport(new PipelinePackageDefinition + { + Name = validPackage.Name, + Version = validPackage.Version, + Source = validPackage.Source, + Destination = validPackage.Destination, + Options = validPackage.Options, + Transforms = + [ + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Filter, + FilterExpression = "NormalizeStatus(status) == 'active'", + }, + ], + Hooks = + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunSucceeded, + CommandName = "Notify", + }, + ], + }); + + PipelineValidationResult result = PipelinePackageValidator.Validate(package); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ReturnsError_WhenAutomationMetadataIsOutOfDate() + { + var validPackage = CreateValidPackage(); + var package = new PipelinePackageDefinition + { + Name = validPackage.Name, + Version = validPackage.Version, + Source = validPackage.Source, + Destination = validPackage.Destination, + Options = validPackage.Options, + Transforms = + [ + new PipelineTransformDefinition + { + Kind = PipelineTransformKind.Derive, + DerivedColumns = + [ + new PipelineDerivedColumn + { + Name = "slug", + Expression = "Slugify(name)", + }, + ], + }, + ], + Automation = new DbAutomationMetadata( + DbAutomationMetadata.CurrentMetadataVersion, + Commands: [], + ScalarFunctions: []), + }; + + PipelineValidationResult result = PipelinePackageValidator.Validate(package); + + Assert.Contains(result.Errors, e => e.Code == "pipeline.automation.scalarFunctions.outOfDate"); + } + [Fact] public void Validate_ReturnsMultipleErrors_ForCompoundInvalidPackage() { diff --git a/tests/CSharpDB.Tests/AutomationMetadataTests.cs b/tests/CSharpDB.Tests/AutomationMetadataTests.cs new file mode 100644 index 00000000..76e089b4 --- /dev/null +++ b/tests/CSharpDB.Tests/AutomationMetadataTests.cs @@ -0,0 +1,50 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class AutomationMetadataTests +{ + [Fact] + public void Builder_SortsAndDeduplicatesReferences() + { + var builder = new DbAutomationMetadataBuilder(); + + DbAutomationMetadata metadata = builder + .AddCommand("Notify", "pipelines", "hooks[1]") + .AddCommand(" notify ", "pipelines", "hooks[1]") + .AddScalarFunction("Slugify", 1, "pipelines", "transforms[0]") + .AddScalarFunction("slugify", 1, "pipelines", "transforms[0]") + .Build(); + + DbAutomationCommandReference command = Assert.Single(metadata.Commands!); + Assert.Equal("Notify", command.Name); + DbAutomationScalarFunctionReference function = Assert.Single(metadata.ScalarFunctions!); + Assert.Equal("Slugify", function.Name); + Assert.Equal(1, function.Arity); + } + + [Fact] + public void ExpressionInspector_FindsNestedScalarFunctionCalls() + { + IReadOnlyList calls = + DbAutomationExpressionInspector.FindScalarFunctionCalls( + "=Normalize(Slugify(Name), 'IgnoreMe(1)', [AlsoIgnore(2)])", + ignoredNames: ["SUM"]); + + Assert.Contains(calls, call => call.Name == "Normalize" && call.Arity == 3); + Assert.Contains(calls, call => call.Name == "Slugify" && call.Arity == 1); + Assert.DoesNotContain(calls, call => call.Name == "IgnoreMe"); + Assert.DoesNotContain(calls, call => call.Name == "AlsoIgnore"); + } + + [Fact] + public void ExpressionInspector_IgnoresConfiguredFunctionNames() + { + IReadOnlyList calls = + DbAutomationExpressionInspector.FindScalarFunctionCalls("=SUM(LineTotal) + Tax(LineTotal)", ["SUM"]); + + DbAutomationScalarFunctionCall call = Assert.Single(calls); + Assert.Equal("Tax", call.Name); + Assert.Equal(1, call.Arity); + } +} From 9a5eccbb5b31dcd40a98fa8e0b83d5705eb7c37d Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 12:40:57 -0700 Subject: [PATCH 11/39] Add async trusted command hardening --- RELEASE_NOTES.md | 15 +++++ docs/trusted-csharp-functions/README.md | 23 +++++-- .../Components/Designer/FormRenderer.razor | 14 +++++ src/CSharpDB.Admin.Forms/README.md | 18 +++++- src/CSharpDB.Admin.Reports/README.md | 21 +++++-- .../Services/DefaultReportEventDispatcher.cs | 4 ++ src/CSharpDB.Pipelines/README.md | 23 ++++--- .../Runtime/PipelineOrchestrator.cs | 4 ++ src/CSharpDB.Primitives/DbCommands.cs | 63 ++++++++++++++++++- src/CSharpDB.Primitives/README.md | 3 + .../DefaultFormEventDispatcherTests.cs | 29 +++++++++ .../DefaultReportEventDispatcherTests.cs | 28 +++++++++ .../PipelineOrchestratorTests.cs | 38 +++++++++++ .../TrustedCommandRegistryTests.cs | 55 +++++++++++++++- 14 files changed, 316 insertions(+), 22 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e037605d..f677c69c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -34,6 +34,12 @@ calculated text, and pipeline filter/derive expressions. - Added the shared `DbCommandRegistry`, `DbCommandRegistryBuilder`, `DbCommandDelegate`, `DbCommandContext`, `DbCommandResult`, and `DbCommandOptions` public model in `CSharpDB.Primitives`. +- `DbCommandOptions` now includes `Timeout` and `IsLongRunning`, and + `DbCommandRegistryBuilder.AddAsyncCommand(...)` registers `Task`-based host + callbacks without manual `ValueTask` wrapping. +- Command timeouts cancel the command invocation token and surface as command + failures through the existing Forms, Reports, and Pipelines dispatch paths; + external cancellation is still propagated as cancellation. - Admin Forms can now store form-level event bindings that reference trusted command names instead of storing C# source. - The Forms data-entry runtime dispatches `OnOpen`, `OnLoad`, `BeforeInsert`, @@ -85,6 +91,9 @@ calculated text, and pipeline filter/derive expressions. - Pipeline hook failures fail the run through `PipelineRunResult`; failure-hook errors are appended to the failed run summary instead of recursively dispatching more failure hooks. +- Admin Forms command buttons now refresh their executing/disabled state before + and after async command work, so long-running trusted commands give visible + runtime feedback in the form surface. ### Stored Automation Metadata @@ -153,6 +162,8 @@ calculated text, and pipeline filter/derive expressions. - Added automation metadata tests covering manifest extraction, JSON round-tripping, repository persistence/backfill, pipeline package import/export, and stale package metadata validation. +- Added async command and timeout coverage for the command registry, Admin + Forms dispatcher, Admin Reports dispatcher, and pipeline hook orchestration. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: @@ -186,6 +197,10 @@ otherwise neutral to improved. and `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` - Debug non-parallel unit test run passed with `1,703` tests after adding automation metadata coverage. +- Phase 6A async-command hardening validation used + `dotnet build CSharpDB.slnx --no-restore -m:1` and + `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,709` tests. - `dotnet pack` smoke for the release workflow packages with `-p:Version=3.6.0` - Produced `11` local packages: diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 783b228c..593c1b9b 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -1,4 +1,4 @@ -# Trusted C# Scalar Functions +# Trusted C# Functions And Commands CSharpDB can call host-registered C# scalar functions from SQL and the embedded expression surfaces that sit on top of the engine. This is the CSharpDB equivalent of an Access-style application function integration: the application owns the C# code, registers it while opening or hosting the database, and users call the function by name in database expressions. @@ -21,9 +21,12 @@ using CSharpDB.Primitives; builder.Services.AddCSharpDbAdminForms(commands => { - commands.AddCommand( + commands.AddAsyncCommand( "AuditCustomerChange", - new DbCommandOptions("Writes an application audit entry."), + new DbCommandOptions( + Description: "Writes an application audit entry.", + Timeout: TimeSpan.FromSeconds(10), + IsLongRunning: true), static async (context, ct) => { long customerId = context.Arguments["Id"].AsInteger; @@ -37,6 +40,17 @@ builder.Services.AddCSharpDbAdminForms(commands => Command names are case-insensitive identifiers. Duplicate command names are rejected during registration. +Use `AddCommand(...)` for synchronous or `ValueTask`-returning callbacks and +`AddAsyncCommand(...)` for `Task` callbacks. Every command +receives a `CancellationToken`; host code should pass it to I/O calls and stop +work when cancellation is requested. + +`DbCommandOptions.Timeout` is optional. When set, CSharpDB cancels the command +token if the callback does not finish in time and reports a timeout through the +same surface-specific failure path as other command errors. `IsLongRunning` is +metadata for hosts and UI surfaces; it does not move the command out of process +or run it on a separate scheduler. + --- ## What You Can Register @@ -742,7 +756,7 @@ For SQL write statements, a failing function aborts the statement. If the statem Admin Forms formulas intentionally return `null` for invalid formulas, unsupported function return types, missing functions, division by zero, or exceptions. Pipeline functions throw runtime errors unless the pipeline error mode handles the affected row. -Trusted command failures are surface-specific. Form before-events can cancel writes, report event failures fail preview rendering, and pipeline hook failures produce a failed `PipelineRunResult` unless the binding sets `StopOnFailure = false`. +Trusted command failures are surface-specific. Form before-events can cancel writes, report event failures fail preview rendering, and pipeline hook failures produce a failed `PipelineRunResult` unless the binding sets `StopOnFailure = false`. Timed-out commands are reported as command failures; caller-requested cancellation still propagates as cancellation instead of being converted to a failure message. Forms action-sequence failures follow the same binding-level `StopOnFailure` rule. Step-level `StopOnFailure = false` lets a later step continue after that @@ -759,6 +773,7 @@ For low overhead: - Prefer `NullPropagating = true` when a function naturally returns null for null input. - Avoid database calls, blocking I/O, sleeps, and long network calls inside delegates. +- For command callbacks that call application services, prefer `AddAsyncCommand(...)`, honor the provided cancellation token, and set a timeout that matches the user-facing workflow. - Keep delegates thread-safe. A function may be called by concurrent queries in the same host process. - Capture immutable services or thread-safe services in closures when application integration is needed. - Use `IsDeterministic = true` for accurate metadata, but do not rely on V1 to optimize from it. diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 37118a37..4b155543 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -298,6 +298,7 @@ } _executingCommandButtons.Add(control.ControlId); + await RefreshCommandButtonStateAsync(); try { if (!string.IsNullOrWhiteSpace(commandName)) @@ -334,6 +335,19 @@ finally { _executingCommandButtons.Remove(control.ControlId); + await RefreshCommandButtonStateAsync(); + } + } + + private async Task RefreshCommandButtonStateAsync() + { + try + { + await InvokeAsync(StateHasChanged); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("render handle", StringComparison.OrdinalIgnoreCase)) + { + // Private unit-test invocations can run before Blazor assigns a render handle. } } diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index 57a37f53..d996d301 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -48,10 +48,26 @@ Trusted command callbacks can be registered with the overload: ```csharp builder.Services.AddCSharpDbAdminForms(commands => { - commands.AddCommand("AuditFormOpen", context => DbCommandResult.Success()); + commands.AddAsyncCommand( + "AuditFormOpen", + new DbCommandOptions( + Description: "Writes a form audit entry.", + Timeout: TimeSpan.FromSeconds(5), + IsLongRunning: true), + async (context, ct) => + { + await WriteAuditAsync(context.Metadata["formName"], ct); + return DbCommandResult.Success(); + }); }); ``` +The Forms runtime passes the command cancellation token to trusted callbacks. +If a command timeout elapses, the runtime reports the timeout through the same +form-event failure path as other command errors. Command buttons refresh their +executing state around async callbacks so the clicked button is disabled while +the callback is in flight. + The extension registers: - `IFormRepository` diff --git a/src/CSharpDB.Admin.Reports/README.md b/src/CSharpDB.Admin.Reports/README.md index fcc2a075..feef71ca 100644 --- a/src/CSharpDB.Admin.Reports/README.md +++ b/src/CSharpDB.Admin.Reports/README.md @@ -48,14 +48,25 @@ using CSharpDB.Primitives; builder.Services.AddCSharpDbAdminReports(commands => { - commands.AddCommand("PublishReportRendered", static context => - { - long pageCount = context.Arguments["pageCount"].AsInteger; - return DbCommandResult.Success($"Rendered {pageCount} page(s)."); - }); + commands.AddAsyncCommand( + "PublishReportRendered", + new DbCommandOptions( + Description: "Publishes report render metrics.", + Timeout: TimeSpan.FromSeconds(5), + IsLongRunning: true), + static async (context, ct) => + { + long pageCount = context.Arguments["pageCount"].AsInteger; + await PublishReportMetricAsync(context.Metadata["reportName"], pageCount, ct); + return DbCommandResult.Success($"Rendered {pageCount} page(s)."); + }); }); ``` +Report event dispatch preserves caller cancellation. Command timeouts and other +callback exceptions are returned as failed preview results with the command name +included in the message. + The extension registers: - `IReportRepository` diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs index 1d1ee990..0d5903ff 100644 --- a/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs @@ -33,6 +33,10 @@ public async Task DispatchAsync( { result = await definition.InvokeAsync(arguments, metadata, ct); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { return ReportEventDispatchResult.Failure( diff --git a/src/CSharpDB.Pipelines/README.md b/src/CSharpDB.Pipelines/README.md index de3ec152..78a55a9f 100644 --- a/src/CSharpDB.Pipelines/README.md +++ b/src/CSharpDB.Pipelines/README.md @@ -156,13 +156,19 @@ var orchestrator = new PipelineOrchestrator( new NullPipelineRunLogger(), DbCommandRegistry.Create(commands => { - commands.AddCommand("NotifyPipeline", static context => - { - string pipelineName = context.Metadata["pipelineName"]; - long rowsWritten = context.Arguments["rowsWritten"].AsInteger; - Console.WriteLine($"{pipelineName}: {rowsWritten} row(s) written."); - return DbCommandResult.Success(); - }); + commands.AddAsyncCommand( + "NotifyPipeline", + new DbCommandOptions( + Description: "Publishes pipeline run metrics.", + Timeout: TimeSpan.FromSeconds(10), + IsLongRunning: true), + static async (context, ct) => + { + string pipelineName = context.Metadata["pipelineName"]; + long rowsWritten = context.Arguments["rowsWritten"].AsInteger; + await NotifyOpsAsync(pipelineName, rowsWritten, ct); + return DbCommandResult.Success(); + }); })); PipelineRunResult result = await orchestrator.ExecuteAsync(new PipelineRunRequest @@ -212,7 +218,8 @@ The output file contains the active customers only, with duplicate IDs removed: - Use `NullPipelineCheckpointStore` and `NullPipelineRunLogger` when you want a minimal in-process setup - Relative source file paths are searched from the current directory and app base directory; relative output paths are written relative to the current directory - `Derive` expressions are intentionally simple today: use a source column name or a literal such as `'csv'`, `123`, `true`, or `null` -- Trusted command hooks are skipped in `Validate` mode. Missing command registration or a failing hook with `StopOnFailure = true` fails the run through `PipelineRunResult`. +- Trusted command hooks are skipped in `Validate` mode. Missing command registration, a timed-out command, or a failing hook with `StopOnFailure = true` fails the run through `PipelineRunResult`. +- Pipeline command callbacks receive the run cancellation token; hosts should pass it to async I/O and set `DbCommandOptions.Timeout` for hooks that call external systems. - `PipelinePackageSerializer` regenerates `Automation` during save/load and string serialization. Validation accepts legacy packages without automation metadata, but reports stale manifests when present. ## Installation diff --git a/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs b/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs index 122e3b48..6deeb821 100644 --- a/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs +++ b/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs @@ -344,6 +344,10 @@ private async Task DispatchHooksAsync( { result = await definition.InvokeAsync(arguments, metadata, ct); } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } catch (Exception ex) { throw new InvalidOperationException( diff --git a/src/CSharpDB.Primitives/DbCommands.cs b/src/CSharpDB.Primitives/DbCommands.cs index ffa4af1f..9070d8e7 100644 --- a/src/CSharpDB.Primitives/DbCommands.cs +++ b/src/CSharpDB.Primitives/DbCommands.cs @@ -9,7 +9,10 @@ public sealed record DbCommandContext( IReadOnlyDictionary Arguments, IReadOnlyDictionary Metadata); -public sealed record DbCommandOptions(string? Description = null); +public sealed record DbCommandOptions( + string? Description = null, + TimeSpan? Timeout = null, + bool IsLongRunning = false); public sealed record DbCommandResult( bool Succeeded, @@ -53,9 +56,41 @@ public ValueTask InvokeAsync( Name, arguments ?? EmptyDbValueDictionary.Instance, metadata ?? EmptyStringDictionary.Instance); - return _invoke(context, ct); + return Options.Timeout is { } timeout + ? InvokeWithTimeoutAsync(context, timeout, ct) + : _invoke(context, ct); } + private async ValueTask InvokeWithTimeoutAsync( + DbCommandContext context, + TimeSpan timeout, + CancellationToken ct) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + + try + { + ValueTask invocation = _invoke(context, linkedCts.Token); + if (invocation.IsCompletedSuccessfully) + return invocation.Result; + + return await invocation.AsTask().WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + throw CreateTimeoutException(timeout, ex); + } + } + + private TimeoutException CreateTimeoutException(TimeSpan timeout, Exception inner) + => new($"Command '{Name}' timed out after {FormatTimeout(timeout)}.", inner); + + private static string FormatTimeout(TimeSpan timeout) + => timeout.TotalMilliseconds < 1000 + ? $"{timeout.TotalMilliseconds:0.###}ms" + : $"{timeout.TotalSeconds:0.###}s"; + private static class EmptyDbValueDictionary { public static readonly IReadOnlyDictionary Instance = @@ -130,6 +165,7 @@ public DbCommandRegistryBuilder AddCommand( DbCommandDelegate invoke) { string normalizedName = ValidateCommandName(name); + ValidateCommandOptions(options); ArgumentNullException.ThrowIfNull(invoke); if (_commands.ContainsKey(normalizedName)) @@ -144,6 +180,23 @@ public DbCommandRegistryBuilder AddCommand( return this; } + public DbCommandRegistryBuilder AddAsyncCommand( + string name, + DbCommandOptions? options, + Func> invoke) + { + ArgumentNullException.ThrowIfNull(invoke); + return AddCommand( + name, + options, + (context, ct) => new ValueTask(invoke(context, ct))); + } + + public DbCommandRegistryBuilder AddAsyncCommand( + string name, + Func> invoke) + => AddAsyncCommand(name, options: null, invoke); + public DbCommandRegistryBuilder AddCommand( string name, DbCommandDelegate invoke) @@ -192,6 +245,12 @@ private static string ValidateCommandName(string name) return trimmed; } + private static void ValidateCommandOptions(DbCommandOptions? options) + { + if (options?.Timeout is { } timeout && timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options), timeout, "Command timeout must be greater than zero."); + } + private static bool IsIdentifierStart(char value) => char.IsLetter(value) || value == '_'; } diff --git a/src/CSharpDB.Primitives/README.md b/src/CSharpDB.Primitives/README.md index ad5c9645..6b705df6 100644 --- a/src/CSharpDB.Primitives/README.md +++ b/src/CSharpDB.Primitives/README.md @@ -27,6 +27,9 @@ dotnet add package CSharpDB | `ColumnDefinition` | Column metadata: name, type, nullability, primary key flag, and identity flag | | `IndexSchema` | Index metadata: name, table, columns, uniqueness | | `TriggerSchema` | Trigger metadata: name, table, timing, event, and body SQL | +| `DbFunctionRegistry` | Host-registered trusted scalar functions for SQL and expression surfaces | +| `DbCommandRegistry` | Host-registered trusted commands for Forms, Reports, and pipeline automation surfaces | +| `DbCommandOptions` | Command description plus optional timeout and long-running metadata | | `CSharpDbException` | Typed exception with `ErrorCode` covering 15+ error conditions | ## Usage diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs index fecd7331..1c03db4e 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs @@ -118,6 +118,35 @@ public async Task DispatchAsync_ContinuesWhenStopOnFailureIsFalse() Assert.Equal(["reject", "after"], calls); } + [Fact] + public async Task DispatchAsync_ReturnsFailureWhenCommandTimesOut() + { + var commands = DbCommandRegistry.Create(builder => + { + builder.AddAsyncCommand( + "SlowAudit", + new DbCommandOptions(Timeout: TimeSpan.FromMilliseconds(10)), + static async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm([new FormEventBinding(FormEventKind.AfterUpdate, "SlowAudit")]); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.AfterUpdate, + new Dictionary { ["Id"] = 7L }, + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("SlowAudit", result.Message); + Assert.Contains("timed out", result.Message); + } + [Fact] public async Task DispatchAsync_ExecutesActionSequenceAndMutatesMutableRecord() { diff --git a/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs b/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs index 4e859dda..51137ae2 100644 --- a/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs +++ b/tests/CSharpDB.Admin.Reports.Tests/Services/DefaultReportEventDispatcherTests.cs @@ -63,6 +63,34 @@ public async Task DispatchAsync_StopsOnCommandFailureByDefault() Assert.Equal("Report rejected.", result.Message); } + [Fact] + public async Task DispatchAsync_ReturnsFailureWhenCommandTimesOut() + { + var commands = DbCommandRegistry.Create(builder => + { + builder.AddAsyncCommand( + "SlowReport", + new DbCommandOptions(Timeout: TimeSpan.FromMilliseconds(10)), + static async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return DbCommandResult.Success(); + }); + }); + var dispatcher = new DefaultReportEventDispatcher(commands); + ReportDefinition report = CreateReport([new ReportEventBinding(ReportEventKind.OnOpen, "SlowReport")]); + + ReportEventDispatchResult result = await dispatcher.DispatchAsync( + report, + CreateSource(), + ReportEventKind.OnOpen, + ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("SlowReport", result.Message); + Assert.Contains("timed out", result.Message); + } + private static ReportDefinition CreateReport(IReadOnlyList eventBindings) => new( "sales-report", diff --git a/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs b/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs index dcfe44fa..51f6ef2e 100644 --- a/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs +++ b/tests/CSharpDB.Pipelines.Tests/PipelineOrchestratorTests.cs @@ -372,6 +372,44 @@ public async Task ExecuteAsync_CommandHookFailureReturnsFailedRun() Assert.Contains("Hook rejected run.", result.ErrorSummary); } + [Fact] + public async Task ExecuteAsync_CommandHookTimeoutReturnsFailedRun() + { + CancellationToken ct = TestContext.Current.CancellationToken; + var commands = DbCommandRegistry.Create(builder => + builder.AddAsyncCommand( + "SlowHook", + new DbCommandOptions(Timeout: TimeSpan.FromMilliseconds(10)), + static async (_, commandCt) => + { + await Task.Delay(TimeSpan.FromSeconds(5), commandCt); + return DbCommandResult.Success(); + })); + var orchestrator = new PipelineOrchestrator( + new FakeComponentFactory(new FakeSource([CreateBatch(1, 1)]), new FakeDestination(), []), + new RecordingCheckpointStore(), + new RecordingRunLogger(), + commands); + + PipelineRunResult result = await orchestrator.ExecuteAsync(new PipelineRunRequest + { + Package = CreatePackage(hooks: + [ + new PipelineCommandHookDefinition + { + Event = PipelineCommandHookEvent.OnRunStarted, + CommandName = "SlowHook", + }, + ]), + Mode = PipelineExecutionMode.Run, + }, ct); + + Assert.Equal(PipelineRunStatus.Failed, result.Status); + Assert.Contains("Step: command-hook", result.ErrorSummary); + Assert.Contains("SlowHook", result.ErrorSummary); + Assert.Contains("timed out", result.ErrorSummary); + } + private static PipelinePackageDefinition CreatePackage( PipelineErrorMode errorMode = PipelineErrorMode.SkipBadRows, int maxRejects = 10, diff --git a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs index 614f4453..95411d2a 100644 --- a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs +++ b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs @@ -10,7 +10,7 @@ public async Task Registry_ValidatesNamesCollisionsAndMetadata() var registry = DbCommandRegistry.Create(commands => commands.AddCommand( "RecalculateInventory", - new DbCommandOptions("Rebuilds inventory summaries."), + new DbCommandOptions("Rebuilds inventory summaries.", IsLongRunning: true), static context => { Assert.Equal("RecalculateInventory", context.CommandName); @@ -21,6 +21,7 @@ public async Task Registry_ValidatesNamesCollisionsAndMetadata() Assert.True(registry.TryGetCommand("recalculateinventory", out DbCommandDefinition definition)); Assert.Equal("Rebuilds inventory summaries.", definition.Options.Description); + Assert.True(definition.Options.IsLongRunning); DbCommandResult result = await definition.InvokeAsync( new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -51,7 +52,7 @@ public async Task Registry_ValidatesNamesCollisionsAndMetadata() public async Task Registry_InvokesAsyncCommands() { var registry = DbCommandRegistry.Create(commands => - commands.AddCommand( + commands.AddAsyncCommand( "AsyncCommand", static async (context, ct) => { @@ -72,6 +73,56 @@ static async (context, ct) => Assert.Equal("AfterUpdate", result.Message); } + [Fact] + public async Task Registry_AppliesCommandTimeout() + { + var registry = DbCommandRegistry.Create(commands => + commands.AddAsyncCommand( + "SlowCommand", + new DbCommandOptions(Timeout: TimeSpan.FromMilliseconds(10)), + static async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return DbCommandResult.Success(); + })); + + Assert.True(registry.TryGetCommand("SlowCommand", out DbCommandDefinition definition)); + + TimeoutException ex = await Assert.ThrowsAsync(async () => + await definition.InvokeAsync(ct: TestContext.Current.CancellationToken)); + + Assert.Contains("SlowCommand", ex.Message); + Assert.Contains("timed out", ex.Message); + } + + [Fact] + public async Task Registry_DoesNotTreatDelegateTimeoutExceptionAsCommandTimeout() + { + var registry = DbCommandRegistry.Create(commands => + commands.AddAsyncCommand( + "ServiceCommand", + new DbCommandOptions(Timeout: TimeSpan.FromSeconds(5)), + static (_, _) => Task.FromException( + new TimeoutException("Downstream service timeout.")))); + + Assert.True(registry.TryGetCommand("ServiceCommand", out DbCommandDefinition definition)); + + TimeoutException ex = await Assert.ThrowsAsync(async () => + await definition.InvokeAsync(ct: TestContext.Current.CancellationToken)); + + Assert.Equal("Downstream service timeout.", ex.Message); + } + + [Fact] + public void Registry_RejectsNonPositiveCommandTimeout() + { + Assert.Throws(() => DbCommandRegistry.Create(commands => + commands.AddCommand( + "InvalidTimeout", + new DbCommandOptions(Timeout: TimeSpan.Zero), + static _ => DbCommandResult.Success()))); + } + [Fact] public void CommandArguments_ConvertObjectDictionariesAndLetConfiguredValuesOverrideRuntimeValues() { From 9ed9a4c73b23a007d7952efbf38867ae3785b879 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 14:37:59 -0700 Subject: [PATCH 12/39] Add built-in form actions --- RELEASE_NOTES.md | 12 +- docs/trusted-csharp-functions/README.md | 25 ++- .../Designer/ActionSequenceEditor.razor | 34 +++- .../Components/Designer/FormRenderer.razor | 4 +- .../Pages/DataEntry.razor | 149 ++++++++++++++++++ src/CSharpDB.Admin.Forms/README.md | 10 +- .../Services/FormActionSequenceExecutor.cs | 19 +++ src/CSharpDB.Primitives/DbActions.cs | 7 + .../FormRendererCommandButtonTests.cs | 40 +++++ .../Pages/DataEntryTests.cs | 119 ++++++++++++++ .../Serialization/JsonRoundtripTests.cs | 7 +- 11 files changed, 413 insertions(+), 13 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f677c69c..2ad00c5f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -68,9 +68,13 @@ calculated text, and pipeline filter/derive expressions. `SetFieldValue`, `ShowMessage`, and `Stop` steps for Admin Forms automation. Form and control event bindings can now be command-only, action-sequence-only, or a command followed by an action sequence. +- Added built-in rendered-form actions for `NewRecord`, `SaveRecord`, + `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, `NextRecord`, and + `GoToRecord`, so command buttons and control events can drive common form + workflows without host C# callbacks. - The form-event and selected-control event editors now include a visual action-sequence editor for adding, ordering, removing, and configuring - `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop` steps. + command, field, message, stop, and built-in record actions. - The action-sequence editor uses registered-command pickers when commands are available, preserves missing command names for portable form metadata, and keeps JSON editing limited to optional argument payloads. @@ -164,6 +168,8 @@ calculated text, and pipeline filter/derive expressions. import/export, and stale package metadata validation. - Added async command and timeout coverage for the command registry, Admin Forms dispatcher, Admin Reports dispatcher, and pipeline hook orchestration. +- Added Forms built-in action tests covering rendered command-button dispatch, + next/previous/go-to navigation, and create/save/refresh/delete workflows. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: @@ -201,6 +207,10 @@ otherwise neutral to improved. `dotnet build CSharpDB.slnx --no-restore -m:1` and `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` - Debug non-parallel unit test run passed with `1,709` tests. +- Phase 6B built-in form action validation used + `dotnet build CSharpDB.slnx --no-restore -m:1` and + `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,712` tests. - `dotnet pack` smoke for the release workflow packages with `-p:Version=3.6.0` - Produced `11` local packages: diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 593c1b9b..b722ea84 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -464,7 +464,7 @@ var shipButton = existingButton with }; ``` -The initial action set is intentionally small: +The action set is intentionally small and form-focused: | Action | Behavior | | --- | --- | @@ -472,6 +472,13 @@ The initial action set is intentionally small: | `SetFieldValue` | Updates a target field in the current mutable form record. | | `ShowMessage` | Sends a message when the current Forms surface provides a command/message callback. | | `Stop` | Ends the current sequence successfully. | +| `NewRecord` | Starts a new record in the rendered form. | +| `SaveRecord` | Saves the current rendered record through the normal form save path. | +| `DeleteRecord` | Deletes the current persisted rendered record through the normal form delete path. | +| `RefreshRecords` | Reloads the current record/page while preserving the current primary key when possible. | +| `PreviousRecord` | Moves the rendered form to the previous record. | +| `NextRecord` | Moves the rendered form to the next record. | +| `GoToRecord` | Navigates to a primary-key value from `Value`, `Arguments["value"]`, `Arguments["recordId"]`, `Arguments["primaryKey"]`, or the field named by `Target`. | Action sequences can be attached to form lifecycle bindings or selected-control bindings. A binding can contain only a command, only an action sequence, or a @@ -498,7 +505,7 @@ var form = existingForm with The Admin Forms property inspector exposes action sequences with a visual editor on form-level and selected-control event bindings. Designers can add a -sequence, name it, add `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop` +sequence, name it, add command, field, message, stop, and built-in record steps, reorder or remove steps, choose registered commands when available, and toggle per-step `StopOnFailure`. JSON editing remains only for optional binding or `RunCommand` step argument payloads. @@ -516,13 +523,17 @@ Older form JSON without automation metadata is backfilled when it is loaded. `SetFieldValue` can update mutable records in form lifecycle events such as `BeforeInsert` and `BeforeUpdate`, and it can update the current rendered record -from control events or command-button clicks. It does not add built-in database -operations by itself; use `RunCommand` for host-owned work. +from control events or command-button clicks. + +Built-in record actions require a rendered Admin Forms data-entry runtime. +They are intended for command buttons and selected-control events. Headless +form lifecycle dispatch can still run `SetFieldValue`, `ShowMessage`, `Stop`, +and `RunCommand`, but it reports a failure if a sequence asks for rendered-form +navigation or save/delete actions. V1 action sequences do not include conditions, loops, stored C# source, -database-owned plugins, built-in navigation actions, built-in save/delete -actions, direct SQL/procedure execution actions, or remote delegate -serialization. +database-owned plugins, direct SQL/procedure execution actions, or remote +delegate serialization. --- diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor index 497e4541..e06108b7 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor @@ -95,6 +95,23 @@ break; + case DbActionKind.GoToRecord: +
+
+ + +
+
+ + +
+
+ break; case DbActionKind.ShowMessage: case DbActionKind.Stop:
@@ -125,6 +142,13 @@
+ + + + + + +
@@ -209,7 +233,7 @@ DbActionStep updated = CreateDefaultStep(kind) with { - Arguments = step.Arguments, + Arguments = kind == DbActionKind.RunCommand ? step.Arguments : null, StopOnFailure = step.StopOnFailure, }; return ReplaceStep(index, updated); @@ -275,6 +299,7 @@ { DbActionKind.RunCommand => new DbActionStep(kind, CommandName: RegisteredCommands.FirstOrDefault()?.Name), DbActionKind.SetFieldValue => new DbActionStep(kind, Target: string.Empty, Value: string.Empty), + DbActionKind.GoToRecord => new DbActionStep(kind, Value: string.Empty), DbActionKind.ShowMessage => new DbActionStep(kind, Message: string.Empty), DbActionKind.Stop => new DbActionStep(kind), _ => new DbActionStep(kind), @@ -322,6 +347,13 @@ DbActionKind.SetFieldValue => "Set Field Value", DbActionKind.ShowMessage => "Show Message", DbActionKind.Stop => "Stop", + DbActionKind.NewRecord => "New Record", + DbActionKind.SaveRecord => "Save Record", + DbActionKind.DeleteRecord => "Delete Record", + DbActionKind.RefreshRecords => "Refresh Records", + DbActionKind.PreviousRecord => "Previous Record", + DbActionKind.NextRecord => "Next Record", + DbActionKind.GoToRecord => "Go To Record", _ => kind.ToString(), }; } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 4b155543..2dcafc6d 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -223,6 +223,7 @@ [Parameter] public IReadOnlyDictionary? ChildFormTableDefinitions { get; set; } [Parameter] public EventCallback OnChildRowsChanged { get; set; } [Parameter] public EventCallback OnCommandError { get; set; } + [Parameter] public Func>? OnBuiltInAction { get; set; } private readonly HashSet _executingCommandButtons = []; @@ -427,7 +428,8 @@ runtimeArguments, metadata, setFieldValue: SetActionFieldValueAsync, - showMessage: ReportCommandErrorAsync); + showMessage: ReportCommandErrorAsync, + executeBuiltInFormAction: OnBuiltInAction); if (!actionResult.Succeeded && binding.StopOnFailure) { diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index 18dc8ca4..d8c2dfa6 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -1,5 +1,6 @@ @using System.Globalization @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Primitives @implements IDisposable @inject IFormRepository FormRepository @inject IFormRecordService RecordService @@ -91,6 +92,7 @@ ValidationErrors="_validationErrors" ChildFormTableDefinitions="_childTableDefs" OnCommandError="OnCommandError" + OnBuiltInAction="ExecuteBuiltInFormActionAsync" OnChildRowsChanged="OnChildRowsChanged" />
} @@ -418,6 +420,7 @@ if (!SupportsWriteOperations) return; + _error = null; ExitFocusedNavigation(); _currentRecord = new Dictionary(StringComparer.OrdinalIgnoreCase); _isNew = true; @@ -631,6 +634,152 @@ private Task PrintRecord() => JS.InvokeVoidAsync("window.print").AsTask(); + private async Task ExecuteBuiltInFormActionAsync(DbActionStep step, CancellationToken ct) + { + try + { + return await ExecuteBuiltInFormActionCoreAsync(step, ct); + } + finally + { + await RefreshDataEntryStateAsync(); + } + } + + private async Task ExecuteBuiltInFormActionCoreAsync(DbActionStep step, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + _error = null; + + switch (step.Kind) + { + case DbActionKind.NewRecord: + if (!SupportsWriteOperations) + return FormEventDispatchResult.Failure("NewRecord action requires a writable form source."); + + NewRecord(); + return FormEventDispatchResult.Success(); + + case DbActionKind.SaveRecord: + if (!SupportsWriteOperations) + return FormEventDispatchResult.Failure("SaveRecord action requires a writable form source."); + + await SaveRecord(); + return ToActionResult("SaveRecord"); + + case DbActionKind.DeleteRecord: + if (!SupportsWriteOperations) + return FormEventDispatchResult.Failure("DeleteRecord action requires a writable form source."); + + if (!HasPersistedCurrentRecord) + return FormEventDispatchResult.Failure("DeleteRecord action requires a persisted current record."); + + await DeleteRecord(); + return ToActionResult("DeleteRecord"); + + case DbActionKind.RefreshRecords: + await RefreshCurrentRecordsAsync(); + return ToActionResult("RefreshRecords"); + + case DbActionKind.PreviousRecord: + if (!CanGoPreviousRecord) + return FormEventDispatchResult.Failure("PreviousRecord action cannot move before the first record."); + + await PrevRecord(); + return ToActionResult("PreviousRecord"); + + case DbActionKind.NextRecord: + if (!CanGoNextRecord) + return FormEventDispatchResult.Failure("NextRecord action cannot move past the last record."); + + await NextRecord(); + return ToActionResult("NextRecord"); + + case DbActionKind.GoToRecord: + return await ExecuteGoToRecordActionAsync(step); + + default: + return FormEventDispatchResult.Failure($"Unsupported built-in form action '{step.Kind}'."); + } + } + + private async Task RefreshDataEntryStateAsync() + { + try + { + await InvokeAsync(StateHasChanged); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("render handle", StringComparison.OrdinalIgnoreCase)) + { + // Private unit-test invocations can run before Blazor assigns a render handle. + } + } + + private FormEventDispatchResult ToActionResult(string actionName) + => string.IsNullOrWhiteSpace(_error) + ? FormEventDispatchResult.Success() + : FormEventDispatchResult.Failure($"{actionName} action failed: {_error}"); + + private async Task ExecuteGoToRecordActionAsync(DbActionStep step) + { + if (_table is null) + return FormEventDispatchResult.Failure("GoToRecord action requires a loaded form source."); + + FormFieldDefinition? primaryKeyField = GetPrimaryKeyField(); + if (primaryKeyField is null) + return FormEventDispatchResult.Failure("GoToRecord action requires a form source with a single primary key column."); + + object? requestedValue = ReadActionValue(step); + if (requestedValue is null && !string.IsNullOrWhiteSpace(step.Target)) + TryGetFieldValue(_currentRecord, step.Target, out requestedValue); + + if (requestedValue is null) + return FormEventDispatchResult.Failure("GoToRecord action requires a record value."); + + string rawValue = requestedValue.ToString() ?? string.Empty; + if (!TryParseFieldValue(primaryKeyField, rawValue, out object? parsedValue, out string? validationError)) + return FormEventDispatchResult.Failure(validationError ?? $"'{rawValue}' is not a valid {GetPrimaryKeyLabel()} value."); + + if (parsedValue is null) + return FormEventDispatchResult.Failure($"'{rawValue}' is not a valid {GetPrimaryKeyLabel()} value."); + + ClearSearchState(clearPendingValue: true); + await NavigateToRecordAsync(parsedValue); + return ToActionResult("GoToRecord"); + } + + private async Task RefreshCurrentRecordsAsync() + { + if (_table is null) + return; + + object? currentPk = TryGetPrimaryKeyValue(_currentRecord, out object? pkValue) ? pkValue : null; + if (currentPk is not null) + { + await NavigateToRecordAsync(currentPk); + return; + } + + await LoadRecordPageAsync(_page, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + } + + private static object? ReadActionValue(DbActionStep step) + { + if (step.Value is not null) + return step.Value; + + if (step.Arguments is null) + return null; + + if (step.Arguments.TryGetValue("value", out object? value)) + return value; + + if (step.Arguments.TryGetValue("recordId", out object? recordId)) + return recordId; + + return step.Arguments.TryGetValue("primaryKey", out object? primaryKey) ? primaryKey : null; + } + private async Task DispatchFormEventAsync( FormEventKind eventKind, IReadOnlyDictionary? record = null) diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index d996d301..ef9a803e 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -113,11 +113,17 @@ properties, optional validation overrides, optional renderer hints, and optional Form and control event bindings can reference a trusted command name and can optionally include a `DbActionSequence`. Action sequences store declarative -steps such as `RunCommand`, `SetFieldValue`, `ShowMessage`, and `Stop`; they do -not store C# source or serialized delegates. The property inspector exposes a +steps such as `RunCommand`, `SetFieldValue`, `ShowMessage`, `Stop`, `NewRecord`, +`SaveRecord`, `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, `NextRecord`, +and `GoToRecord`; they do not store C# source or serialized delegates. The +property inspector exposes a visual action-sequence editor on form-level and selected-control event bindings; JSON editing is limited to optional command argument payloads. +The built-in record actions run only in the rendered Forms data-entry runtime. +Headless form event dispatch can still run command, field, message, and stop +steps, but navigation and save/delete actions require a rendered form instance. + `DbFormRepository` regenerates `Automation` on save/load. The manifest records trusted command and scalar-function names used by form events, command buttons, selected-control events, action sequences, and computed formulas so exported diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs index cb7912e8..2080d44a 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -16,6 +16,7 @@ public static async Task ExecuteAsync( IReadOnlyDictionary metadata, Func? setFieldValue = null, Func? showMessage = null, + Func>? executeBuiltInFormAction = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(sequence); @@ -38,6 +39,7 @@ public static async Task ExecuteAsync( metadata, setFieldValue, showMessage, + executeBuiltInFormAction, ct); if (!result.Succeeded && step.StopOnFailure) @@ -64,6 +66,7 @@ private static async Task ExecuteStepAsync( IReadOnlyDictionary metadata, Func? setFieldValue, Func? showMessage, + Func>? executeBuiltInFormAction, CancellationToken ct) { try @@ -74,6 +77,13 @@ private static async Task ExecuteStepAsync( DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), + DbActionKind.NewRecord or + DbActionKind.SaveRecord or + DbActionKind.DeleteRecord or + DbActionKind.RefreshRecords or + DbActionKind.PreviousRecord or + DbActionKind.NextRecord or + DbActionKind.GoToRecord => await ExecuteBuiltInFormActionAsync(step, executeBuiltInFormAction, ct), _ => FormEventDispatchResult.Failure($"Unsupported form action kind '{step.Kind}'."), }; } @@ -88,6 +98,15 @@ private static async Task ExecuteStepAsync( } } + private static Task ExecuteBuiltInFormActionAsync( + DbActionStep step, + Func>? executeBuiltInFormAction, + CancellationToken ct) + => executeBuiltInFormAction is null + ? Task.FromResult(FormEventDispatchResult.Failure( + $"Form action '{step.Kind}' requires a rendered form runtime.")) + : executeBuiltInFormAction(step, ct); + private static async Task RunCommandAsync( DbActionSequence sequence, DbActionStep step, diff --git a/src/CSharpDB.Primitives/DbActions.cs b/src/CSharpDB.Primitives/DbActions.cs index 570365d3..ee9eebcc 100644 --- a/src/CSharpDB.Primitives/DbActions.cs +++ b/src/CSharpDB.Primitives/DbActions.cs @@ -6,6 +6,13 @@ public enum DbActionKind SetFieldValue, ShowMessage, Stop, + NewRecord, + SaveRecord, + DeleteRecord, + RefreshRecords, + PreviousRecord, + NextRecord, + GoToRecord, } public sealed record DbActionSequence( diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs index de57e7ca..2a1ff4ad 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Components.Designer; using CSharpDB.Admin.Forms.Models; using CSharpDB.Primitives; @@ -175,6 +176,45 @@ public async Task CommandButton_ExecutesOnClickActionSequenceWhenNoDirectCommand Assert.Equal("ShipButtonActions", captured.Metadata["actionSequence"]); } + [Fact] + public async Task CommandButton_ExecutesBuiltInFormAction() + { + DbActionStep? captured = null; + string? error = null; + ControlDefinition button = new( + "button1", + "commandButton", + new Rect(10, 20, 120, 34), + null, + new PropertyBag(new Dictionary { ["text"] = "Next" }), + null, + EventBindings: + [ + new ControlEventBinding( + ControlEventKind.OnClick, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.NextRecord), + ])), + ]); + var renderer = CreateRenderer(DbCommandRegistry.Empty, CreateForm(button), message => error = message); + SetProperty( + renderer, + nameof(FormRenderer.OnBuiltInAction), + new Func>((step, _) => + { + captured = step; + return Task.FromResult(FormEventDispatchResult.Success()); + })); + + await InvokeNonPublicAsync(renderer, "InvokeCommandButtonAsync", button); + + Assert.Null(error); + Assert.NotNull(captured); + Assert.Equal(DbActionKind.NextRecord, captured!.Kind); + } + private static FormRenderer CreateRenderer( DbCommandRegistry commands, FormDefinition form, diff --git a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs index 1c745241..091ef921 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs @@ -299,6 +299,116 @@ Name TEXT NOT NULL Assert.Equal(33L, ReadCurrentRecord(component)["Id"]); } + [Fact] + public async Task BuiltInFormActions_NavigateRecordsAndGoToRecord() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Events ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + INSERT INTO Events VALUES (1, 'Event 1'); + INSERT INTO Events VALUES (2, 'Event 2'); + INSERT INTO Events VALUES (3, 'Event 3'); + """); + + DataEntry component = await CreateComponentAsync( + form: CreateForm("events-form", "Events"), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: new DbFormRecordService(db.Client)); + + FormEventDispatchResult result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.NextRecord), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(2L, ReadCurrentRecord(component)["Id"]); + + result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.PreviousRecord), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(1L, ReadCurrentRecord(component)["Id"]); + + result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.GoToRecord, Value: 3L), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(3L, ReadCurrentRecord(component)["Id"]); + } + + [Fact] + public async Task BuiltInFormActions_CreateSaveRefreshAndDelete() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + INSERT INTO Products VALUES (1, 'Widget'); + """); + + DataEntry component = await CreateComponentAsync( + form: CreateForm("products-form", "Products"), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: new DbFormRecordService(db.Client)); + + FormEventDispatchResult result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.NewRecord), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.True(GetField(component, "_isNew")); + + Dictionary current = ReadCurrentRecord(component); + current["Id"] = 2L; + current["Name"] = "Gadget"; + SetField(component, "_dirty", true); + + result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.SaveRecord), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal(2L, ReadCurrentRecord(component)["Id"]); + Assert.Equal(2, (await db.QueryRowsAsync("SELECT * FROM Products")).Count); + + await db.ExecuteAsync("UPDATE Products SET Name = 'Gadget Pro' WHERE Id = 2"); + result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.RefreshRecords), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal("Gadget Pro", ReadCurrentRecord(component)["Name"]); + + result = await InvokeNonPublicAsync( + component, + "ExecuteBuiltInFormActionAsync", + new DbActionStep(DbActionKind.DeleteRecord), + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Single(await db.QueryRowsAsync("SELECT * FROM Products")); + } + [Fact] public async Task ViewBackedForm_LoadsAndSearchesInReadOnlyMode() { @@ -412,6 +522,15 @@ private static async Task InvokeNonPublicAsync(object instance, string methodNam await task; } + private static async Task InvokeNonPublicAsync(object instance, string methodName, params object?[]? args) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + var task = (Task?)method.Invoke(instance, args) + ?? throw new InvalidOperationException($"Method '{methodName}' did not return a task."); + return await task; + } + private sealed class StaticFormRepository(FormDefinition form) : IFormRepository { public Task GetAsync(string formId) diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index 3738fe91..3eabf256 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -184,6 +184,8 @@ public void FormEventBinding_WithActionSequence_RoundTrips() DbActionKind.RunCommand, CommandName: "AuditAction", Arguments: new Dictionary { ["source"] = "roundtrip" }), + new DbActionStep(DbActionKind.GoToRecord, Value: 123L), + new DbActionStep(DbActionKind.SaveRecord), ], Name: "LoadActions")), ]); @@ -193,13 +195,16 @@ public void FormEventBinding_WithActionSequence_RoundTrips() DbActionSequence sequence = deserialized.EventBindings![0].ActionSequence!; Assert.Equal("LoadActions", sequence.Name); - Assert.Equal(2, sequence.Steps.Count); + Assert.Equal(4, sequence.Steps.Count); Assert.Equal(DbActionKind.SetFieldValue, sequence.Steps[0].Kind); Assert.Equal("Status", sequence.Steps[0].Target); Assert.Equal("Ready", sequence.Steps[0].Value?.ToString()); Assert.Equal(DbActionKind.RunCommand, sequence.Steps[1].Kind); Assert.Equal("AuditAction", sequence.Steps[1].CommandName); Assert.Equal("roundtrip", sequence.Steps[1].Arguments!["source"]); + Assert.Equal(DbActionKind.GoToRecord, sequence.Steps[2].Kind); + Assert.Equal("123", sequence.Steps[2].Value?.ToString()); + Assert.Equal(DbActionKind.SaveRecord, sequence.Steps[3].Kind); } [Fact] From 8062053bc4535b1e4663d831131c67e1cdfa48ab Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Tue, 28 Apr 2026 17:02:12 -0700 Subject: [PATCH 13/39] Add conditional form actions --- RELEASE_NOTES.md | 13 +- docs/trusted-csharp-functions/README.md | 34 +- .../Designer/ActionSequenceEditor.razor | 12 + src/CSharpDB.Admin.Forms/README.md | 5 + .../Services/FormActionConditionEvaluator.cs | 307 ++++++++++++++++++ .../Services/FormActionSequenceExecutor.cs | 19 ++ src/CSharpDB.Primitives/DbActions.cs | 3 +- .../FormRendererCommandButtonTests.cs | 38 +++ .../Serialization/JsonRoundtripTests.cs | 4 +- .../DefaultFormEventDispatcherTests.cs | 80 +++++ 10 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Services/FormActionConditionEvaluator.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2ad00c5f..92e1f7c7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -72,9 +72,13 @@ calculated text, and pipeline filter/derive expressions. `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, `NextRecord`, and `GoToRecord`, so command buttons and control events can drive common form workflows without host C# callbacks. +- Action sequence steps can now include a simple condition such as + `Status = 'Ready'`, `Amount > 0`, or `IsActive`; false conditions skip that + step, while malformed conditions fail through the normal step failure path. - The form-event and selected-control event editors now include a visual action-sequence editor for adding, ordering, removing, and configuring - command, field, message, stop, and built-in record actions. + command, field, message, stop, built-in record actions, and per-step + conditions. - The action-sequence editor uses registered-command pickers when commands are available, preserves missing command names for portable form metadata, and keeps JSON editing limited to optional argument payloads. @@ -170,6 +174,9 @@ calculated text, and pipeline filter/derive expressions. Forms dispatcher, Admin Reports dispatcher, and pipeline hook orchestration. - Added Forms built-in action tests covering rendered command-button dispatch, next/previous/go-to navigation, and create/save/refresh/delete workflows. +- Added conditional action tests for skip/run behavior, condition failure, + rendered built-in action skipping, metadata propagation, and JSON + round-tripping. - Same-machine affected benchmark comparison against the pre-feature HEAD baseline showed no material regression in the main write/query guardrails: @@ -211,6 +218,10 @@ otherwise neutral to improved. `dotnet build CSharpDB.slnx --no-restore -m:1` and `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` - Debug non-parallel unit test run passed with `1,712` tests. +- Phase 6C conditional form action validation used + `dotnet build CSharpDB.slnx --no-restore -m:1` and + `dotnet test CSharpDB.slnx --no-build -m:1 -- RunConfiguration.DisableParallelization=true` + - Debug non-parallel unit test run passed with `1,715` tests. - `dotnet pack` smoke for the release workflow packages with `-p:Version=3.6.0` - Produced `11` local packages: diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index b722ea84..5423ff9f 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -451,6 +451,7 @@ var shipButton = existingButton with new DbActionStep( DbActionKind.RunCommand, CommandName: "AuditOrderStatus", + Condition: "Status = 'Shipped'", Arguments: new Dictionary { ["source"] = "ship-button", @@ -507,13 +508,34 @@ The Admin Forms property inspector exposes action sequences with a visual editor on form-level and selected-control event bindings. Designers can add a sequence, name it, add command, field, message, stop, and built-in record steps, reorder or remove steps, choose registered commands when available, and -toggle per-step `StopOnFailure`. JSON editing remains only for optional binding -or `RunCommand` step argument payloads. +set per-step conditions and `StopOnFailure`. JSON editing remains only for +optional binding or `RunCommand` step argument payloads. For `RunCommand`, command arguments are built from current record fields, binding arguments, runtime event arguments, and step arguments, with later sources overriding earlier ones. Command metadata includes the Forms metadata -plus `actionKind`, `actionStep`, and optional `actionSequence`. +plus `actionKind`, `actionStep`, optional `actionSequence`, and optional +`actionCondition`. + +Every action step can include a `Condition`. Empty conditions run the step. +False conditions skip only that step. Malformed conditions fail through the +normal step failure path, so `StopOnFailure = false` can allow a later step to +continue. + +Supported condition syntax is intentionally small: + +| Syntax | Example | +| --- | --- | +| Truthy value | `IsActive` | +| Equality | `Status = 'Ready'` or `[Status] == "Ready"` | +| Inequality | `Status <> 'Closed'` or `Status != 'Closed'` | +| Numeric comparison | `Amount > 0`, `Quantity <= 10` | +| Null comparison | `ClosedAt = null` | + +Condition values are resolved from current record fields, binding arguments, +runtime event arguments, and step arguments using the same later-wins order as +command arguments. A leading `=` is accepted for macro-style conditions, for +example `=Status = 'Ready'`. When forms are saved through `DbFormRepository` or exported through `FormAutomationMetadata.NormalizeForExport(...)`, the definition's `automation` @@ -531,9 +553,9 @@ form lifecycle dispatch can still run `SetFieldValue`, `ShowMessage`, `Stop`, and `RunCommand`, but it reports a failure if a sequence asks for rendered-form navigation or save/delete actions. -V1 action sequences do not include conditions, loops, stored C# source, -database-owned plugins, direct SQL/procedure execution actions, or remote -delegate serialization. +V1 action sequences do not include loops, stored C# source, database-owned +plugins, direct SQL/procedure execution actions, or remote delegate +serialization. --- diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor index e06108b7..41906604 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor @@ -123,6 +123,14 @@ break; } +
+ + +
+
break; + case DbActionKind.RunActionSequence: +
+ + @if (AvailableActionSequences.Count > 0) + { + + } + else + { + + } +
+
+ + +
+ break; case DbActionKind.SetFieldValue:
@@ -149,6 +181,7 @@
+ @@ -166,6 +199,7 @@ @code { [Parameter] public DbActionSequence? ActionSequence { get; set; } [Parameter] public EventCallback ActionSequenceChanged { get; set; } + [Parameter] public IReadOnlyList AvailableActionSequences { get; set; } = []; private readonly Dictionary _argumentText = []; private string? _argumentError; @@ -241,7 +275,8 @@ DbActionStep updated = CreateDefaultStep(kind) with { - Arguments = kind == DbActionKind.RunCommand ? step.Arguments : null, + Arguments = kind is DbActionKind.RunCommand or DbActionKind.RunActionSequence ? step.Arguments : null, + SequenceName = kind == DbActionKind.RunActionSequence ? step.SequenceName : null, StopOnFailure = step.StopOnFailure, Condition = step.Condition, }; @@ -251,6 +286,9 @@ private Task UpdateCommandName(int index, DbActionStep step, string? commandName) => ReplaceStep(index, step with { CommandName = string.IsNullOrWhiteSpace(commandName) ? null : commandName.Trim() }); + private Task UpdateSequenceName(int index, DbActionStep step, string? sequenceName) + => ReplaceStep(index, step with { SequenceName = string.IsNullOrWhiteSpace(sequenceName) ? null : sequenceName.Trim() }); + private Task UpdateTarget(int index, DbActionStep step, string? target) => ReplaceStep(index, step with { Target = string.IsNullOrWhiteSpace(target) ? null : target.Trim() }); @@ -310,6 +348,7 @@ => kind switch { DbActionKind.RunCommand => new DbActionStep(kind, CommandName: RegisteredCommands.FirstOrDefault()?.Name), + DbActionKind.RunActionSequence => new DbActionStep(kind, SequenceName: FirstAvailableActionSequenceName()), DbActionKind.SetFieldValue => new DbActionStep(kind, Target: string.Empty, Value: string.Empty), DbActionKind.GoToRecord => new DbActionStep(kind, Value: string.Empty), DbActionKind.ShowMessage => new DbActionStep(kind, Message: string.Empty), @@ -338,6 +377,13 @@ => !string.IsNullOrWhiteSpace(commandName) && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); + private bool ShouldRenderMissingActionSequence(string? sequenceName) + => !string.IsNullOrWhiteSpace(sequenceName) + && AvailableActionSequences.All(sequence => !string.Equals(sequence.Name, sequenceName, StringComparison.OrdinalIgnoreCase)); + + private string? FirstAvailableActionSequenceName() + => AvailableActionSequences.FirstOrDefault(sequence => !string.IsNullOrWhiteSpace(sequence.Name))?.Name; + private static string FormatArguments(IReadOnlyDictionary? arguments) => arguments is null || arguments.Count == 0 ? string.Empty @@ -356,6 +402,7 @@ => kind switch { DbActionKind.RunCommand => "Run Command", + DbActionKind.RunActionSequence => "Run Action Sequence", DbActionKind.SetFieldValue => "Set Field Value", DbActionKind.ShowMessage => "Show Message", DbActionKind.Stop => "Stop", diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor index 769d6b5a..2f74b955 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlEventBindingsEditor.razor @@ -72,6 +72,7 @@
@@ -87,6 +88,7 @@ @code { [Parameter, EditorRequired] public IReadOnlyList EventBindings { get; set; } = []; + [Parameter] public IReadOnlyList ActionSequences { get; set; } = []; [Parameter] public EventCallback> EventBindingsChanged { get; set; } private readonly Dictionary _argumentText = []; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index a88fda1f..9248ede8 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -1,4 +1,5 @@ using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Components.Designer; @@ -6,6 +7,7 @@ public class DesignerState { private readonly List _controls = []; private readonly List _eventBindings = []; + private readonly List _actionSequences = []; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); @@ -18,6 +20,7 @@ public class DesignerState public IReadOnlyList Controls => _controls; public IReadOnlyList EventBindings => _eventBindings; + public IReadOnlyList ActionSequences => _actionSequences; public HashSet SelectedIds { get; } = []; // Active tool from toolbox (null = select mode) @@ -62,6 +65,8 @@ public void LoadForm(FormDefinition form) _controls.AddRange(form.Controls); _eventBindings.Clear(); _eventBindings.AddRange(form.EventBindings ?? []); + _actionSequences.Clear(); + _actionSequences.AddRange(form.ActionSequences ?? []); _undoStack.Clear(); _redoStack.Clear(); SelectedIds.Clear(); @@ -86,7 +91,7 @@ public FormDefinition ToFormDefinition() { return new FormDefinition( FormId, FormName, TableName, DefinitionVersion, SourceSchemaSignature, - Layout, _controls.ToList(), EventBindings: _eventBindings.ToList()); + Layout, _controls.ToList(), EventBindings: _eventBindings.ToList(), ActionSequences: _actionSequences.ToList()); } public void UpdateEventBindings(IReadOnlyList bindings) @@ -96,6 +101,13 @@ public void UpdateEventBindings(IReadOnlyList bindings) NotifyChanged(); } + public void UpdateActionSequences(IReadOnlyList sequences) + { + _actionSequences.Clear(); + _actionSequences.AddRange(sequences); + NotifyChanged(); + } + public void UpdateControlEventBindings(string controlId, IReadOnlyList bindings) { var idx = _controls.FindIndex(c => c.ControlId == controlId); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormActionSequencesEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormActionSequencesEditor.razor new file mode 100644 index 00000000..8b87ace5 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormActionSequencesEditor.razor @@ -0,0 +1,65 @@ +@using CSharpDB.Primitives + +
+ @if (ActionSequences.Count == 0) + { +
No reusable action sequences
+ } + + @for (int i = 0; i < ActionSequences.Count; i++) + { + var idx = i; + var sequence = ActionSequences[idx]; +
+ +
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList ActionSequences { get; set; } = []; + [Parameter] public EventCallback> ActionSequencesChanged { get; set; } + + private async Task AddSequence() + { + var updated = ActionSequences + .Append(new DbActionSequence([], Name: NextSequenceName())) + .ToList(); + await ActionSequencesChanged.InvokeAsync(updated); + } + + private async Task UpdateSequence(int index, DbActionSequence? sequence) + { + var updated = ActionSequences.ToList(); + if (index < 0 || index >= updated.Count) + return; + + if (sequence is null) + updated.RemoveAt(index); + else + updated[index] = sequence; + + await ActionSequencesChanged.InvokeAsync(updated); + } + + private string NextSequenceName() + { + const string prefix = "Sequence"; + HashSet existing = ActionSequences + .Select(sequence => sequence.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Select(name => name!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + for (int i = 1; ; i++) + { + string candidate = $"{prefix}{i}"; + if (!existing.Contains(candidate)) + return candidate; + } + } +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor index 691a8b5d..c1f14137 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormEventBindingsEditor.razor @@ -71,6 +71,7 @@
@@ -86,6 +87,7 @@ @code { [Parameter, EditorRequired] public IReadOnlyList EventBindings { get; set; } = []; + [Parameter] public IReadOnlyList ActionSequences { get; set; } = []; [Parameter] public EventCallback> EventBindingsChanged { get; set; } private readonly Dictionary _argumentText = []; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 2dcafc6d..8025c17e 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -427,6 +427,7 @@ binding.Arguments, runtimeArguments, metadata, + reusableSequences: Form.ActionSequences, setFieldValue: SetActionFieldValueAsync, showMessage: ReportCommandErrorAsync, executeBuiltInFormAction: OnBuiltInAction); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index c1f07777..9614c695 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -26,8 +26,14 @@
+
+ + +
} else { @@ -60,6 +66,7 @@
@@ -817,6 +824,12 @@ return Task.CompletedTask; } + private Task OnActionSequencesChanged(IReadOnlyList sequences) + { + State.UpdateActionSequences(sequences); + return Task.CompletedTask; + } + private Task OnControlEventBindingsChanged(IReadOnlyList bindings) { if (_selected is not null) diff --git a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs index ce37e373..5f0ce75f 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs @@ -12,4 +12,5 @@ public sealed record FormDefinition( IReadOnlyList Controls, IReadOnlyDictionary? RendererHints = null, IReadOnlyList? EventBindings = null, - DbAutomationMetadata? Automation = null); + DbAutomationMetadata? Automation = null, + IReadOnlyList? ActionSequences = null); diff --git a/src/CSharpDB.Admin.Forms/README.md b/src/CSharpDB.Admin.Forms/README.md index 18ffda03..79126d1c 100644 --- a/src/CSharpDB.Admin.Forms/README.md +++ b/src/CSharpDB.Admin.Forms/README.md @@ -103,7 +103,8 @@ public sealed record FormDefinition( IReadOnlyList Controls, IReadOnlyDictionary? RendererHints = null, IReadOnlyList? EventBindings = null, - DbAutomationMetadata? Automation = null); + DbAutomationMetadata? Automation = null, + IReadOnlyList? ActionSequences = null); ``` Controls are stored as `ControlDefinition` records with geometry, binding, @@ -112,13 +113,16 @@ properties, optional validation overrides, optional renderer hints, and optional `OnChange`, `OnGotFocus`, and `OnLostFocus`. Form and control event bindings can reference a trusted command name and can -optionally include a `DbActionSequence`. Action sequences store declarative -steps such as `RunCommand`, `SetFieldValue`, `ShowMessage`, `Stop`, `NewRecord`, -`SaveRecord`, `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, `NextRecord`, -and `GoToRecord`; they do not store C# source or serialized delegates. The -property inspector exposes a -visual action-sequence editor on form-level and selected-control event bindings; -JSON editing is limited to optional command argument payloads. +optionally include a `DbActionSequence`. Forms can also store reusable named +action sequences in `ActionSequences`, and event/button sequences can invoke +them with `RunActionSequence`. Action sequences store declarative steps such as +`RunCommand`, `RunActionSequence`, `SetFieldValue`, `ShowMessage`, `Stop`, +`NewRecord`, `SaveRecord`, `DeleteRecord`, `RefreshRecords`, `PreviousRecord`, +`NextRecord`, and `GoToRecord`; they do not store C# source or serialized +delegates. The property inspector exposes a visual action-sequence editor on +form-level and selected-control event bindings plus a reusable action library +when editing form properties. JSON editing is limited to optional command or +nested-sequence argument payloads. The built-in record actions run only in the rendered Forms data-entry runtime. Headless form event dispatch can still run command, field, message, and stop @@ -131,8 +135,9 @@ conditions fail through the normal action failure path and honor `DbFormRepository` regenerates `Automation` on save/load. The manifest records trusted command and scalar-function names used by form events, command buttons, -selected-control events, action sequences, and computed formulas so exported -form JSON tells a host which callbacks it must register. +selected-control events, reusable action sequences, action sequences, and +computed formulas so exported form JSON tells a host which callbacks it must +register. ## Build diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs index f50bbbad..eb5b5f0f 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -72,6 +72,7 @@ public async Task DispatchAsync( binding.Arguments, runtimeArguments: null, metadata, + reusableSequences: form.ActionSequences, ct: ct); if (!actionResult.Succeeded && binding.StopOnFailure) diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs index 83001dc6..95b8d671 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -7,6 +7,8 @@ namespace CSharpDB.Admin.Forms.Services; internal static class FormActionSequenceExecutor { + private const int MaxNestedActionSequenceDepth = 8; + public static async Task ExecuteAsync( DbActionSequence sequence, DbCommandRegistry commands, @@ -14,6 +16,7 @@ public static async Task ExecuteAsync( IReadOnlyDictionary? bindingArguments, IReadOnlyDictionary? runtimeArguments, IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences = null, Func? setFieldValue = null, Func? showMessage = null, Func>? executeBuiltInFormAction = null, @@ -23,6 +26,35 @@ public static async Task ExecuteAsync( ArgumentNullException.ThrowIfNull(commands); ArgumentNullException.ThrowIfNull(metadata); + return await ExecuteCoreAsync( + sequence, + commands, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + ct, + depth: 0); + } + + private static async Task ExecuteCoreAsync( + DbActionSequence sequence, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + CancellationToken ct, + int depth) + { IReadOnlyList steps = sequence.Steps ?? []; string? lastMessage = null; for (int i = 0; i < steps.Count; i++) @@ -37,10 +69,12 @@ public static async Task ExecuteAsync( bindingArguments, runtimeArguments, metadata, + reusableSequences, setFieldValue, showMessage, executeBuiltInFormAction, - ct); + ct, + depth); if (!result.Succeeded && step.StopOnFailure) return result; @@ -64,10 +98,12 @@ private static async Task ExecuteStepAsync( IReadOnlyDictionary? bindingArguments, IReadOnlyDictionary? runtimeArguments, IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, Func? setFieldValue, Func? showMessage, Func>? executeBuiltInFormAction, - CancellationToken ct) + CancellationToken ct, + int depth) { try { @@ -93,6 +129,19 @@ private static async Task ExecuteStepAsync( DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), + DbActionKind.RunActionSequence => await RunActionSequenceAsync( + step, + commands, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + ct, + depth), DbActionKind.NewRecord or DbActionKind.SaveRecord or DbActionKind.DeleteRecord or @@ -169,6 +218,57 @@ private static async Task RunCommandAsync( } } + private static async Task RunActionSequenceAsync( + DbActionStep step, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + CancellationToken ct, + int depth) + { + string? sequenceName = ReadSequenceName(step); + if (string.IsNullOrWhiteSpace(sequenceName)) + return FormEventDispatchResult.Failure("RunActionSequence action requires a sequence name."); + + if (depth >= MaxNestedActionSequenceDepth) + return FormEventDispatchResult.Failure( + $"Action sequence nesting limit exceeded while running '{sequenceName}'."); + + IReadOnlyList matches = (reusableSequences ?? []) + .Where(sequence => string.Equals(sequence.Name, sequenceName, StringComparison.OrdinalIgnoreCase)) + .Take(2) + .ToList(); + + if (matches.Count == 0) + return FormEventDispatchResult.Failure($"Unknown form action sequence '{sequenceName}'."); + + if (matches.Count > 1) + return FormEventDispatchResult.Failure($"Form action sequence name '{sequenceName}' is ambiguous."); + + IReadOnlyDictionary? nestedRuntimeArguments = + MergeRuntimeArguments(runtimeArguments, step.Arguments); + + return await ExecuteCoreAsync( + matches[0], + commands, + record, + bindingArguments, + nestedRuntimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + ct, + depth + 1); + } + private static async Task SetFieldValueAsync( DbActionStep step, IReadOnlyDictionary? record, @@ -233,9 +333,50 @@ private static Dictionary BuildStepMetadata( if (!string.IsNullOrWhiteSpace(step.Condition)) result["actionCondition"] = step.Condition; + string? sequenceName = ReadSequenceName(step); + if (!string.IsNullOrWhiteSpace(sequenceName)) + result["actionSequenceTarget"] = sequenceName; + return result; } + private static string? ReadSequenceName(DbActionStep step) + { + if (!string.IsNullOrWhiteSpace(step.SequenceName)) + return step.SequenceName.Trim(); + + if (!string.IsNullOrWhiteSpace(step.Target)) + return step.Target.Trim(); + + if (step.Arguments is null) + return null; + + foreach (string key in new[] { "sequenceName", "sequence", "name" }) + { + if (step.Arguments.TryGetValue(key, out object? value)) + return NormalizeValue(value)?.ToString(); + } + + return null; + } + + private static IReadOnlyDictionary? MergeRuntimeArguments( + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary? stepArguments) + { + if (stepArguments is null || stepArguments.Count == 0) + return runtimeArguments; + + var merged = runtimeArguments is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(runtimeArguments, StringComparer.OrdinalIgnoreCase); + + foreach ((string key, object? value) in stepArguments) + merged[key] = NormalizeValue(value); + + return merged; + } + private static string? ReadMessage(DbActionStep step) { if (!string.IsNullOrWhiteSpace(step.Message)) diff --git a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs index c3759390..7e7d669d 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs @@ -28,6 +28,14 @@ public static DbAutomationMetadata Build(FormDefinition form) AddActionSequence(builder, binding.ActionSequence, bindingLocation); } + foreach (DbActionSequence sequence in form.ActionSequences ?? []) + { + string sequenceLocation = string.IsNullOrWhiteSpace(sequence.Name) + ? "form.actionSequences.unnamed" + : $"form.actionSequences.{sequence.Name}"; + AddActionSequence(builder, sequence, sequenceLocation); + } + foreach (ControlDefinition control in form.Controls) { AddCommandButton(builder, control); diff --git a/src/CSharpDB.Primitives/DbActions.cs b/src/CSharpDB.Primitives/DbActions.cs index c11078ae..eee0d3a2 100644 --- a/src/CSharpDB.Primitives/DbActions.cs +++ b/src/CSharpDB.Primitives/DbActions.cs @@ -13,6 +13,7 @@ public enum DbActionKind PreviousRecord, NextRecord, GoToRecord, + RunActionSequence, } public sealed record DbActionSequence( @@ -27,4 +28,5 @@ public sealed record DbActionStep( string? Message = null, IReadOnlyDictionary? Arguments = null, bool StopOnFailure = true, - string? Condition = null); + string? Condition = null, + string? SequenceName = null); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs index c90c5d4a..400ef28a 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -66,6 +66,74 @@ public void ToFormDefinition_PreservesFormActionSequences() Assert.Equal("AuditChange", binding.ActionSequence.Steps[1].CommandName); } + [Fact] + public void ToFormDefinition_PreservesReusableActionSequences() + { + var state = new DesignerState(); + var form = CreateForm() with + { + EventBindings = + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "PrepareRecord"), + ])), + ], + ActionSequences = + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Ready"), + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditPrepared"), + ], + Name: "PrepareRecord"), + ], + }; + + state.LoadForm(form); + + FormDefinition saved = state.ToFormDefinition(); + + DbActionSequence reusable = Assert.Single(saved.ActionSequences!); + Assert.Equal("PrepareRecord", reusable.Name); + Assert.Equal(DbActionKind.SetFieldValue, reusable.Steps[0].Kind); + Assert.Equal("Status", reusable.Steps[0].Target); + Assert.Equal("Ready", reusable.Steps[0].Value); + Assert.Equal(DbActionKind.RunCommand, reusable.Steps[1].Kind); + Assert.Equal("AuditPrepared", reusable.Steps[1].CommandName); + + FormEventBinding binding = Assert.Single(saved.EventBindings!); + Assert.Equal(DbActionKind.RunActionSequence, binding.ActionSequence!.Steps[0].Kind); + Assert.Equal("PrepareRecord", binding.ActionSequence.Steps[0].SequenceName); + } + + [Fact] + public void UpdateActionSequences_ReplacesReusableActionSequences() + { + var state = new DesignerState(); + state.LoadForm(CreateForm()); + + state.UpdateActionSequences( + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.ShowMessage, Message: "Ready."), + ], + Name: "NotifyReady"), + ]); + + FormDefinition saved = state.ToFormDefinition(); + + DbActionSequence sequence = Assert.Single(saved.ActionSequences!); + Assert.Equal("NotifyReady", sequence.Name); + DbActionStep step = Assert.Single(sequence.Steps); + Assert.Equal(DbActionKind.ShowMessage, step.Kind); + Assert.Equal("Ready.", step.Message); + } + [Fact] public void UpdateEventBindings_ReplacesFormLevelBindings() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs index a3965563..3fdbd903 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -176,6 +176,63 @@ public async Task CommandButton_ExecutesOnClickActionSequenceWhenNoDirectCommand Assert.Equal("ShipButtonActions", captured.Metadata["actionSequence"]); } + [Fact] + public async Task CommandButton_ExecutesReusableActionSequence() + { + DbCommandContext? captured = null; + string? error = null; + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditReusableAction", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + ControlDefinition button = new( + "button1", + "commandButton", + new Rect(10, 20, 120, 34), + null, + new PropertyBag(new Dictionary { ["text"] = "Ship" }), + null, + EventBindings: + [ + new ControlEventBinding( + ControlEventKind.OnClick, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.RunActionSequence, + SequenceName: "ReusableShip", + Arguments: new Dictionary { ["source"] = "button" }), + ])), + ]); + FormDefinition form = CreateForm( + button, + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Shipped"), + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditReusableAction"), + ], + Name: "ReusableShip"), + ]); + var renderer = CreateRenderer(commands, form, message => error = message); + + await InvokeNonPublicAsync(renderer, "InvokeCommandButtonAsync", button); + + var record = (Dictionary)GetProperty(renderer, nameof(FormRenderer.Record))!; + Assert.Null(error); + Assert.Equal("Shipped", record["Status"]); + Assert.NotNull(captured); + Assert.Equal("AuditReusableAction", captured!.CommandName); + Assert.Equal("button", captured.Arguments["source"].AsText); + Assert.Equal("ReusableShip", captured.Metadata["actionSequence"]); + } + [Fact] public async Task CommandButton_ExecutesBuiltInFormAction() { @@ -290,7 +347,9 @@ private static ControlDefinition CreateCommandButton(string commandName) }), null); - private static FormDefinition CreateForm(ControlDefinition button) + private static FormDefinition CreateForm( + ControlDefinition button, + IReadOnlyList? actionSequences = null) => new( "orders-form", "Orders", @@ -298,7 +357,8 @@ private static FormDefinition CreateForm(ControlDefinition button) 1, "sig:orders", new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), - [button]); + [button], + ActionSequences: actionSequences); private static void SetProperty(object instance, string propertyName, object? value) { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index 84758487..cf741fe1 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -187,8 +187,17 @@ public void FormEventBinding_WithActionSequence_RoundTrips() Condition: "Status = 'Ready'"), new DbActionStep(DbActionKind.GoToRecord, Value: 123L), new DbActionStep(DbActionKind.SaveRecord), + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "ReusableShip"), ], Name: "LoadActions")), + ], + ActionSequences: + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditReusableShip"), + ], + Name: "ReusableShip"), ]); string json = JsonSerializer.Serialize(form, Options); @@ -196,7 +205,7 @@ public void FormEventBinding_WithActionSequence_RoundTrips() DbActionSequence sequence = deserialized.EventBindings![0].ActionSequence!; Assert.Equal("LoadActions", sequence.Name); - Assert.Equal(4, sequence.Steps.Count); + Assert.Equal(5, sequence.Steps.Count); Assert.Equal(DbActionKind.SetFieldValue, sequence.Steps[0].Kind); Assert.Equal("Status", sequence.Steps[0].Target); Assert.Equal("Ready", sequence.Steps[0].Value?.ToString()); @@ -207,6 +216,11 @@ public void FormEventBinding_WithActionSequence_RoundTrips() Assert.Equal(DbActionKind.GoToRecord, sequence.Steps[2].Kind); Assert.Equal("123", sequence.Steps[2].Value?.ToString()); Assert.Equal(DbActionKind.SaveRecord, sequence.Steps[3].Kind); + Assert.Equal(DbActionKind.RunActionSequence, sequence.Steps[4].Kind); + Assert.Equal("ReusableShip", sequence.Steps[4].SequenceName); + DbActionSequence reusable = Assert.Single(deserialized.ActionSequences!); + Assert.Equal("ReusableShip", reusable.Name); + Assert.Equal("AuditReusableShip", reusable.Steps[0].CommandName); } [Fact] @@ -249,6 +263,14 @@ public void FormAutomationMetadata_NormalizeForExport_RoundTrips() EventBindings: [ new FormEventBinding(FormEventKind.BeforeInsert, "ValidateOrder"), + ], + ActionSequences: + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunCommand, CommandName: "ReusableOrderAudit"), + ], + Name: "ReusableOrderActions"), ]); FormDefinition normalized = FormAutomationMetadata.NormalizeForExport(form); @@ -261,6 +283,7 @@ public void FormAutomationMetadata_NormalizeForExport_RoundTrips() Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "NormalizeScore"); Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "AuditScore"); Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "ValidateOrder"); + Assert.Contains(deserialized.Automation.Commands!, command => command.Name == "ReusableOrderAudit"); DbAutomationScalarFunctionReference function = Assert.Single(deserialized.Automation.ScalarFunctions!); Assert.Equal("BoostScore", function.Name); Assert.Equal(1, function.Arity); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs index 018fe777..73befade 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs @@ -246,6 +246,122 @@ public async Task DispatchAsync_ActionSequenceConditionsSkipAndRunSteps() Assert.Equal("Status = 'Ready'", captured.Metadata["actionCondition"]); } + [Fact] + public async Task DispatchAsync_RunActionSequenceInvokesReusableSequence() + { + DbCommandContext? captured = null; + var commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("AuditPrepared", context => + { + captured = context; + return DbCommandResult.Success(); + }); + }); + + var dispatcher = new DefaultFormEventDispatcher(commands); + var form = CreateForm( + [ + new FormEventBinding( + FormEventKind.BeforeUpdate, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.RunActionSequence, + SequenceName: "PrepareOrder", + Arguments: new Dictionary { ["source"] = "event-binding" }), + ], + Name: "BeforeUpdateActions")), + ], + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.SetFieldValue, Target: "Status", Value: "Ready"), + new DbActionStep(DbActionKind.RunCommand, CommandName: "AuditPrepared"), + ], + Name: "PrepareOrder"), + ]); + var record = new Dictionary { ["Id"] = 7L, ["Status"] = "Draft" }; + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.BeforeUpdate, + record, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal("Ready", record["Status"]); + Assert.NotNull(captured); + Assert.Equal("AuditPrepared", captured!.CommandName); + Assert.Equal("Ready", captured.Arguments["Status"].AsText); + Assert.Equal("event-binding", captured.Arguments["source"].AsText); + Assert.Equal("PrepareOrder", captured.Metadata["actionSequence"]); + Assert.Equal("RunCommand", captured.Metadata["actionKind"]); + Assert.Equal("1", captured.Metadata["actionStep"]); + } + + [Fact] + public async Task DispatchAsync_RunActionSequenceFailsForMissingReusableSequence() + { + var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty); + var form = CreateForm([ + new FormEventBinding( + FormEventKind.BeforeUpdate, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "MissingSequence"), + ])), + ]); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.BeforeUpdate, + new Dictionary { ["Id"] = 7L }, + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("Unknown form action sequence 'MissingSequence'", result.Message); + } + + [Fact] + public async Task DispatchAsync_RunActionSequenceStopsRecursiveLoops() + { + var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty); + var form = CreateForm( + [ + new FormEventBinding( + FormEventKind.BeforeUpdate, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "A"), + ])), + ], + [ + new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "B"), + ], + Name: "A"), + new DbActionSequence( + [ + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "A"), + ], + Name: "B"), + ]); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.BeforeUpdate, + new Dictionary { ["Id"] = 7L }, + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("nesting limit", result.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task DispatchAsync_ActionSequenceConditionFailureStopsByDefault() { @@ -298,7 +414,9 @@ [new DbActionStep(DbActionKind.RunCommand, CommandName: "MissingCommand")])), Assert.Contains("Unknown form command 'MissingCommand'", result.Message); } - private static FormDefinition CreateForm(IReadOnlyList eventBindings) + private static FormDefinition CreateForm( + IReadOnlyList eventBindings, + IReadOnlyList? actionSequences = null) => new( "customers-form", "Customers Form", @@ -307,5 +425,6 @@ private static FormDefinition CreateForm(IReadOnlyList eventBi SourceSchemaSignature: "customers:v1", Layout: new LayoutDefinition("absolute", 8, SnapToGrid: false, []), Controls: [], - EventBindings: eventBindings); + EventBindings: eventBindings, + ActionSequences: actionSequences); } From 25f9bd5886258daa5bbcdc41dfcb12a43df42389 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 15:24:59 -0700 Subject: [PATCH 15/39] Fix admin form designer stylesheet cache bust --- src/CSharpDB.Admin/Components/App.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CSharpDB.Admin/Components/App.razor b/src/CSharpDB.Admin/Components/App.razor index f8aa1a15..23e4caf1 100644 --- a/src/CSharpDB.Admin/Components/App.razor +++ b/src/CSharpDB.Admin/Components/App.razor @@ -12,7 +12,7 @@ - + From 34b455cb3a0e545fee3b66746b3a8c79061e0826 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 16:00:23 -0700 Subject: [PATCH 16/39] Fix child tabs editor dark theme --- .../wwwroot/css/designer.css | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css index 786063d2..31cefbb4 100644 --- a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css +++ b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css @@ -1683,6 +1683,15 @@ .data-entry-layout { background: var(--fd-bg-primary); } +.tce-tab-entry, +.tce-tab-body { + background: var(--fd-bg-secondary); + color: var(--fd-text); +} + +.tce-child-tabs { + border-left-color: var(--fd-border-light); +} .designer-toolbar, .de-toolbar, @@ -1787,6 +1796,33 @@ color: var(--fd-text); border-color: var(--fd-border); } +.tce-field input[type="checkbox"] { + accent-color: var(--fd-accent); +} + +.tce-field select option { + background: var(--fd-bg-elevated); + color: var(--fd-text); +} + +.tce-toggle { + color: var(--fd-text-secondary); +} + +.tce-toggle:hover { + color: var(--fd-text); + background: var(--fd-bg-hover); +} + +.tce-btn-add { + background: var(--fd-accent-soft); + border-color: color-mix(in srgb, var(--fd-accent) 42%, var(--fd-border)); + color: var(--fd-accent); +} + +.tce-btn-add:hover { + background: color-mix(in srgb, var(--fd-accent-soft) 72%, var(--fd-bg-hover)); +} .designer-toolbar button:hover, .de-btn:hover, From fbedbaf75136f120a103036e984ebb25cda5dfe1 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 16:41:47 -0700 Subject: [PATCH 17/39] Integrate admin UI mockup updates --- .../Components/Designer/ChildDataGrid.razor | 161 +- .../Components/Designer/DesignCanvas.razor | 9 +- .../Components/Designer/DesignerState.cs | 22 + .../Components/Designer/FormRenderer.razor | 196 +- .../Designer/PropertyInspector.razor | 169 +- .../Components/Designer/Toolbox.razor | 2 +- src/CSharpDB.Admin.Forms/Pages/Designer.razor | 37 +- .../wwwroot/css/designer.css | 484 ++++- .../Components/Layout/CommandPalette.razor | 257 +++ .../Components/Layout/MainLayout.razor | 17 +- .../Components/Layout/NavMenu.razor | 492 ++++- .../Components/Layout/TitleBar.razor | 35 +- .../Components/Shared/DataGrid.razor | 244 ++- .../Components/Tabs/DataTab.razor | 858 ++++++++- .../Components/Tabs/QueryTab.razor | 292 ++- .../Components/Tabs/StorageTab.razor | 62 +- .../Components/Tabs/WelcomeTab.razor | 311 +++- .../Models/DataGridFilterSummary.cs | 7 + src/CSharpDB.Admin/wwwroot/css/app.css | 1583 +++++++++++++++++ src/CSharpDB.Admin/wwwroot/js/interop.js | 23 + .../Components/Designer/ChildDataGridTests.cs | 134 +- .../Components/Designer/DesignerStateTests.cs | 36 + 22 files changed, 5120 insertions(+), 311 deletions(-) create mode 100644 src/CSharpDB.Admin/Components/Layout/CommandPalette.razor create mode 100644 src/CSharpDB.Admin/Models/DataGridFilterSummary.cs diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor index cbc87210..33cdcdfa 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor @@ -4,7 +4,7 @@
- @ChildTableName (@_rows.Count) + @GetTitleText() @if (AllowAdd) { @@ -76,14 +76,35 @@ @if (_rows.Count == 0) { - - No child records. + + @(IsStandalone ? "No records." : "No child records.") }
+ @if (IsStandalone) + { +
+
+ +
+
+ + + Page @_pageNumber of @TotalPages + + +
+
@GetVisibleRangeText()
+
+ } }
@@ -91,6 +112,7 @@ [Parameter, EditorRequired] public string ChildTableName { get; set; } = ""; [Parameter, EditorRequired] public string ForeignKeyField { get; set; } = ""; [Parameter, EditorRequired] public object? ParentKeyValue { get; set; } + [Parameter] public bool IsStandalone { get; set; } [Parameter] public List VisibleColumns { get; set; } = []; [Parameter] public bool AllowAdd { get; set; } = true; [Parameter] public bool AllowEdit { get; set; } = true; @@ -111,6 +133,12 @@ private string? _lastChildTableName; private string? _lastForeignKeyField; private string? _lastChildTableDefinitionName; + private bool _lastIsStandalone; + private int _pageNumber = 1; + private int _pageSize = 25; + private int _totalCount; + + private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalCount / (double)_pageSize)); protected override async Task OnParametersSetAsync() { @@ -118,13 +146,18 @@ bool childTableChanged = !string.Equals(_lastChildTableName, ChildTableName, StringComparison.OrdinalIgnoreCase); bool foreignKeyChanged = !string.Equals(_lastForeignKeyField, ForeignKeyField, StringComparison.OrdinalIgnoreCase); bool definitionChanged = !string.Equals(_lastChildTableDefinitionName, ChildFormTableDefinition?.TableName, StringComparison.OrdinalIgnoreCase); + bool modeChanged = _lastIsStandalone != IsStandalone; - if (parentChanged || childTableChanged || foreignKeyChanged || definitionChanged) + if (parentChanged || childTableChanged || foreignKeyChanged || definitionChanged || modeChanged) { + if (childTableChanged || definitionChanged || modeChanged) + _pageNumber = 1; + _lastParentKeyValue = ParentKeyValue; _lastChildTableName = ChildTableName; _lastForeignKeyField = ForeignKeyField; _lastChildTableDefinitionName = ChildFormTableDefinition?.TableName; + _lastIsStandalone = IsStandalone; await LoadChildRecords(); } } @@ -143,15 +176,31 @@ if (ChildFormTableDefinition is null) { _rows = []; + _totalCount = 0; return; } + if (IsStandalone) + { + FormRecordPage page = await RecordService.ListRecordPageAsync(ChildFormTableDefinition, _pageNumber, _pageSize); + _pageNumber = page.PageNumber; + _pageSize = page.PageSize; + _totalCount = page.TotalCount; + _rows = page.Records.ToList(); + return; + } + + if (string.IsNullOrWhiteSpace(ForeignKeyField)) + throw new InvalidOperationException("The related DataGrid foreign key field is not configured."); + _rows = await RecordService.ListFilteredRecordsAsync(ChildFormTableDefinition, ForeignKeyField, ParentKeyValue); + _totalCount = _rows.Count; } catch (Exception ex) { _error = $"Failed to load: {ex.Message}"; _rows = []; + _totalCount = 0; } finally { @@ -168,12 +217,27 @@ if (ChildFormTableDefinition is null) throw new InvalidOperationException($"The child table '{ChildTableName}' is not available."); - var newRow = new Dictionary + var newRow = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!IsStandalone) { - [ForeignKeyField] = ParentKeyValue - }; + if (string.IsNullOrWhiteSpace(ForeignKeyField)) + throw new InvalidOperationException("The related DataGrid foreign key field is not configured."); + + newRow[ForeignKeyField] = ParentKeyValue; + } + var created = await RecordService.CreateRecordAsync(ChildFormTableDefinition, newRow); - _rows.Add(created); + if (IsStandalone) + { + _pageNumber = GetTotalPages(_totalCount + 1, _pageSize); + await LoadChildRecords(); + } + else + { + _rows.Add(created); + _totalCount = _rows.Count; + } + if (OnRowsChanged.HasDelegate) await OnRowsChanged.InvokeAsync(); } @@ -242,19 +306,31 @@ throw new InvalidOperationException($"The child table '{ChildTableName}' is not available."); await RecordService.DeleteRecordAsync(ChildFormTableDefinition, pk); - _rows.RemoveAt(rowIdx); - if (OnRowsChanged.HasDelegate) - await OnRowsChanged.InvokeAsync(); - if (_editingRow == rowIdx) StopEditing(); - if (_selectedRowIndex == rowIdx) + if (IsStandalone) { - _selectedRowIndex = -1; - await OnRowSelected.InvokeAsync(null); + if (_rows.Count == 1 && _pageNumber > 1) + _pageNumber--; + + await LoadChildRecords(); } - else if (_selectedRowIndex > rowIdx) + else { - _selectedRowIndex--; + _rows.RemoveAt(rowIdx); + _totalCount = _rows.Count; + if (_editingRow == rowIdx) StopEditing(); + if (_selectedRowIndex == rowIdx) + { + _selectedRowIndex = -1; + await OnRowSelected.InvokeAsync(null); + } + else if (_selectedRowIndex > rowIdx) + { + _selectedRowIndex--; + } } + + if (OnRowsChanged.HasDelegate) + await OnRowsChanged.InvokeAsync(); } catch (Exception ex) { @@ -318,4 +394,55 @@ } return fieldName; } + + private int GetEmptyColumnSpan() + => Math.Max(1, VisibleColumns.Count + (AllowDelete ? 1 : 0)); + + private async Task GoToPageAsync(int pageNumber) + { + if (!IsStandalone || _saving) + return; + + int targetPage = Math.Clamp(pageNumber, 1, TotalPages); + if (targetPage == _pageNumber && _rows.Count > 0) + return; + + _pageNumber = targetPage; + await LoadChildRecords(); + } + + private async Task OnPageSizeChanged(ChangeEventArgs e) + { + if (!IsStandalone || _saving) + return; + + if (!int.TryParse(e.Value?.ToString(), out int pageSize) || pageSize < 1) + return; + + pageSize = Math.Clamp(pageSize, 1, 500); + if (pageSize == _pageSize) + return; + + _pageSize = pageSize; + _pageNumber = 1; + await LoadChildRecords(); + } + + private string GetTitleText() + => IsStandalone + ? $"{ChildTableName} ({_totalCount} rows)" + : $"{ChildTableName} ({_rows.Count})"; + + private string GetVisibleRangeText() + { + if (_totalCount == 0 || _rows.Count == 0) + return "0 rows"; + + int first = ((_pageNumber - 1) * _pageSize) + 1; + int last = first + _rows.Count - 1; + return $"{first}-{last} of {_totalCount}"; + } + + private static int GetTotalPages(int totalCount, int pageSize) + => Math.Max(1, (int)Math.Ceiling(totalCount / (double)pageSize)); } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor index f78c830f..4a6d96b0 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor @@ -90,11 +90,17 @@ break; case "datagrid": var childTbl = GetProp(c, "childTable", ""); + var dataGridMode = GetProp(c, "dataGridMode", "related"); + var dataGridLabel = string.IsNullOrEmpty(childTbl) + ? "DataGrid (not configured)" + : string.Equals(dataGridMode, "standalone", StringComparison.OrdinalIgnoreCase) + ? $"Table Grid: {childTbl}" + : $"Related Grid: {childTbl}"; var dgCols = GetListProp(c, "visibleColumns");
- @(string.IsNullOrEmpty(childTbl) ? "DataGrid (not configured)" : $"DataGrid: {childTbl}") + @dataGridLabel
@@ -266,6 +272,7 @@ props["text"] = "Checkbox"; if (controlType == "datagrid") { + props["dataGridMode"] = "standalone"; props["childTable"] = ""; props["foreignKeyField"] = ""; props["parentKeyField"] = ""; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 9248ede8..8e52cc93 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -53,6 +53,28 @@ public class DesignerState public double GridSize => Layout.GridSize; public bool SnapToGrid => Layout.SnapToGrid; + public void SetLayoutMode(string layoutMode) + { + if (string.IsNullOrWhiteSpace(layoutMode) || string.Equals(Layout.LayoutMode, layoutMode, StringComparison.OrdinalIgnoreCase)) + return; + + Layout = Layout with { LayoutMode = layoutMode }; + NotifyChanged(); + } + + public void SetFormName(string? formName) + { + string normalized = string.IsNullOrWhiteSpace(formName) + ? "Untitled Form" + : formName.Trim(); + + if (string.Equals(FormName, normalized, StringComparison.Ordinal)) + return; + + FormName = normalized; + NotifyChanged(); + } + public double Snap(double v) { if (!SnapToGrid) return v; diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 8025c17e..c20f8caa 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -1,8 +1,10 @@ +@using System.Globalization +@using System.Text.Json @using CSharpDB.Admin.Forms.Models @using CSharpDB.Primitives @inject DbCommandRegistry Commands -
+
@foreach (var control in Form.Controls) { var c = control; @@ -11,7 +13,7 @@ var hasError = fieldName is not null && ValidationErrors?.ContainsKey(fieldName) == true; var errorMsg = hasError ? ValidationErrors![fieldName!] : null; var inputClass = hasError ? "fr-input fr-field-error" : "fr-input"; -
+
@switch (c.ControlType) { case "label": @@ -154,28 +156,45 @@ break; case "datagrid": + var dgMode = GetProp(c, "dataGridMode", "related"); + var dgIsStandalone = string.Equals(dgMode, "standalone", StringComparison.OrdinalIgnoreCase); var dgChildTable = GetProp(c, "childTable", ""); var dgFkField = GetProp(c, "foreignKeyField", ""); var dgPkField = GetProp(c, "parentKeyField", ""); - var dgColumns = GetListProp(c, "visibleColumns"); + var dgTableDefinition = ChildFormTableDefinitions?.GetValueOrDefault(dgChildTable); + var dgColumns = GetDataGridVisibleColumns(c, dgTableDefinition); var dgAllowAdd = GetBoolProp(c, "allowAdd"); var dgAllowEdit = GetBoolProp(c, "allowEdit"); var dgAllowDelete = GetBoolProp(c, "allowDelete"); var parentPkValue = GetFieldObjectValue(dgPkField); - @if (!string.IsNullOrEmpty(dgChildTable) && !string.IsNullOrEmpty(dgFkField) && parentPkValue is not null) + @if (!string.IsNullOrEmpty(dgChildTable) && dgIsStandalone) + { + + } + else if (!string.IsNullOrEmpty(dgChildTable) && !string.IsNullOrEmpty(dgFkField) && parentPkValue is not null) { } - else if (parentPkValue is null && !string.IsNullOrEmpty(dgChildTable)) + else if (!dgIsStandalone && parentPkValue is null && !string.IsNullOrEmpty(dgChildTable)) {
Save the parent record first to see child records.
} @@ -226,6 +245,160 @@ [Parameter] public Func>? OnBuiltInAction { get; set; } private readonly HashSet _executingCommandButtons = []; + private const string LayoutModeElastic = "elastic"; + private const double DesktopCanvasWidth = 1200; + private const double TabletCanvasWidth = 768; + + private bool IsElasticLayout + => string.Equals(Form.Layout.LayoutMode, LayoutModeElastic, StringComparison.OrdinalIgnoreCase); + + private string GetRendererClass() + => IsElasticLayout + ? "form-renderer form-renderer-elastic" + : "form-renderer form-renderer-fixed"; + + private string GetRendererStyle() + => FormattableString.Invariant($"--fr-canvas-height: {GetCanvasHeight()}px;"); + + private string GetControlClass(ControlDefinition c) + { + List classes = ["fr-control", $"fr-control-{c.ControlType}"]; + + if (!IsVisibleAtBreakpoint(c, "mobile")) + classes.Add("fr-hidden-mobile"); + if (!IsVisibleAtBreakpoint(c, "tablet")) + classes.Add("fr-hidden-tablet"); + + return string.Join(" ", classes); + } + + private string GetControlStyle(ControlDefinition c) + { + Rect desktop = c.Rect; + Rect tablet = GetEffectiveRect(c, "tablet"); + int desktopSpan = GetGridSpan(desktop, 12, DesktopCanvasWidth); + int tabletSpan = GetGridSpan(tablet, 8, TabletCanvasWidth); + + return FormattableString.Invariant( + $"--fr-left: {desktop.X}px; --fr-top: {desktop.Y}px; --fr-width: {desktop.Width}px; --fr-height: {desktop.Height}px; --fr-stack-order: {GetStackOrder(c)}; --fr-grid-span: {desktopSpan}; --fr-tablet-span: {tabletSpan};"); + } + + private double GetCanvasHeight() + { + double bottom = Form.Controls.Count == 0 + ? 500 + : Form.Controls.Max(control => control.Rect.Y + control.Rect.Height) + 24; + return Math.Max(500, bottom); + } + + private int GetStackOrder(ControlDefinition c) + { + var ordered = Form.Controls + .Select((control, index) => new + { + Control = control, + Index = index, + Rect = GetEffectiveRect(control, "mobile"), + }) + .OrderBy(item => item.Rect.Y) + .ThenBy(item => item.Rect.X) + .ThenBy(item => item.Index) + .ToList(); + + int order = ordered.FindIndex(item => string.Equals(item.Control.ControlId, c.ControlId, StringComparison.Ordinal)); + return order < 0 ? 0 : order; + } + + private static int GetGridSpan(Rect rect, int columns, double canvasWidth) + { + if (canvasWidth <= 0) + return columns; + + int span = (int)Math.Ceiling(Math.Max(1, rect.Width) / canvasWidth * columns); + return Math.Clamp(span, 1, columns); + } + + private static Rect GetEffectiveRect(ControlDefinition c, string breakpoint) + { + if (string.Equals(breakpoint, "desktop", StringComparison.OrdinalIgnoreCase) || c.RendererHints is null) + return c.Rect; + + string key = $"bp:{breakpoint}"; + if (!c.RendererHints.TryGetValue(key, out object? hintObj) || hintObj is null) + return c.Rect; + + if (hintObj is JsonElement json && json.ValueKind == JsonValueKind.Object) + { + double x = GetJsonDouble(json, "x", c.Rect.X); + double y = GetJsonDouble(json, "y", c.Rect.Y); + double width = GetJsonDouble(json, "width", c.Rect.Width); + double height = GetJsonDouble(json, "height", c.Rect.Height); + return new Rect(x, y, width, height); + } + + if (hintObj is IReadOnlyDictionary dict) + { + double x = GetDictionaryDouble(dict, "x", c.Rect.X); + double y = GetDictionaryDouble(dict, "y", c.Rect.Y); + double width = GetDictionaryDouble(dict, "width", c.Rect.Width); + double height = GetDictionaryDouble(dict, "height", c.Rect.Height); + return new Rect(x, y, width, height); + } + + return c.Rect; + } + + private static bool IsVisibleAtBreakpoint(ControlDefinition c, string breakpoint) + { + if (string.Equals(breakpoint, "desktop", StringComparison.OrdinalIgnoreCase) || c.RendererHints is null) + return true; + + string key = $"bp:{breakpoint}"; + if (!c.RendererHints.TryGetValue(key, out object? hintObj) || hintObj is null) + return true; + + if (hintObj is JsonElement json && json.ValueKind == JsonValueKind.Object) + return !json.TryGetProperty("visible", out JsonElement visible) || visible.GetBoolean(); + + if (hintObj is IReadOnlyDictionary dict && dict.TryGetValue("visible", out object? value)) + return ReadBoolean(value, fallback: true); + + return true; + } + + private static double GetJsonDouble(JsonElement json, string propertyName, double fallback) + => json.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out double value) + ? value + : fallback; + + private static double GetDictionaryDouble(IReadOnlyDictionary dict, string key, double fallback) + { + if (!dict.TryGetValue(key, out object? value) || value is null) + return fallback; + + return value switch + { + double d => d, + float f => f, + decimal m => (double)m, + int i => i, + long l => l, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetDouble(out double d) => d, + _ when double.TryParse(value.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed, + _ => fallback, + }; + } + + private static bool ReadBoolean(object? value, bool fallback) + { + return value switch + { + bool b => b, + JsonElement json when json.ValueKind is JsonValueKind.True or JsonValueKind.False => json.GetBoolean(), + _ when bool.TryParse(value?.ToString(), out bool parsed) => parsed, + _ => fallback, + }; + } private string GetFieldValue(string? fieldName) { @@ -522,6 +695,17 @@ return []; } + private static List GetDataGridVisibleColumns(ControlDefinition control, FormTableDefinition? tableDefinition) + { + List configuredColumns = GetListProp(control, "visibleColumns"); + if (configuredColumns.Count > 0 || tableDefinition is null) + return configuredColumns; + + return tableDefinition.Fields + .Select(field => field.Name) + .ToList(); + } + private static bool GetBoolProp(ControlDefinition c, string key) { if (c.Props.Values.TryGetValue(key, out var val) && val is bool b) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index 9614c695..46843f76 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -23,6 +23,23 @@

Form properties

}
+ @if (State.SelectedIds.Count <= 1) + { +
+ +
+ + +
+
+ +
@(string.IsNullOrWhiteSpace(State.TableName) ? "No source selected" : State.TableName)
+
+
+ }
- + +
+ + +
+ + @foreach (var fk in fks) + { + var fkLabel = $"{fk.Name} ({string.Join(", ", fk.LocalFields)})"; + + } + +
+ } + else + { +
+ No FK referencing @State.TableName. Use manual mapping below. +
+ } +
- - + + @foreach (var field in _childTableDef.Fields) { - var fkLabel = $"{fk.Name} ({string.Join(", ", fk.LocalFields)})"; - + }
- } - else - { -
- No FK referencing @State.TableName. Use manual mapping below. -
- } - -
- - -
-
- - + + @if (_sourceTableDef is not null) { - + @foreach (var field in _sourceTableDef.Fields) + { + + } } - } - -
+ +
+ }
@@ -447,6 +474,7 @@ private const string PropMaxLength = "maxLength"; private const string PropReadOnly = "readOnly"; private const string PropTabIndex = "tabIndex"; + private const string PropDataGridMode = "dataGridMode"; private const string PropChildTable = "childTable"; private const string PropForeignKeyName = "foreignKeyName"; private const string PropForeignKeyField = "foreignKeyField"; @@ -574,6 +602,11 @@ State.UpdateControlProp(_selected.ControlId, key, value); } + private void OnFormNameChanged(ChangeEventArgs e) + { + State.SetFormName(e.Value?.ToString()); + } + private void OnTypeChanged(ChangeEventArgs e) { if (_selected is null || e.Value is not string newType) return; @@ -630,6 +663,36 @@ // ===== DataGrid Configuration Methods ===== + private async Task OnDataGridModeChanged(ChangeEventArgs e) + { + if (_selected is null || e.Value is not string mode) + return; + + mode = string.Equals(mode, "standalone", StringComparison.OrdinalIgnoreCase) + ? "standalone" + : "related"; + OnPropChanged(PropDataGridMode, mode); + + if (string.Equals(mode, "standalone", StringComparison.OrdinalIgnoreCase)) + { + OnPropChanged(PropForeignKeyField, ""); + OnPropChanged(PropParentKeyField, ""); + OnPropChanged(PropForeignKeyName, ""); + return; + } + + if (_childTableDef is not null) + { + var matchingFks = _childTableDef.ForeignKeys + .Where(fk => string.Equals(fk.ReferencedTable, State.TableName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingFks.Count == 1) + AutoSelectForeignKey(matchingFks[0]); + } + + await Task.CompletedTask; + } + private async Task OnChildTableChanged(ChangeEventArgs e) { if (_selected is null || e.Value is not string tableName) return; @@ -646,18 +709,21 @@ if (_childTableDef is not null) { - // Auto-select FK if exactly one references the parent table - var matchingFks = _childTableDef.ForeignKeys - .Where(fk => string.Equals(fk.ReferencedTable, State.TableName, StringComparison.OrdinalIgnoreCase)) - .ToList(); - if (matchingFks.Count == 1) + if (!IsStandaloneDataGrid()) { - AutoSelectForeignKey(matchingFks[0]); + // Auto-select FK if exactly one references the parent table + var matchingFks = _childTableDef.ForeignKeys + .Where(fk => string.Equals(fk.ReferencedTable, State.TableName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (matchingFks.Count == 1) + { + AutoSelectForeignKey(matchingFks[0]); + } } - // Auto-select all non-PK columns as visible + // Related grids keep PKs hidden by default; standalone grids show the mapped table as-is. var allCols = _childTableDef.Fields - .Where(f => !_childTableDef.PrimaryKey.Contains(f.Name)) + .Where(f => IsStandaloneDataGrid() || !_childTableDef.PrimaryKey.Contains(f.Name)) .Select(f => (object?)f.Name) .ToArray(); OnPropChanged(PropVisibleColumns, allCols); @@ -809,6 +875,9 @@ } } + private bool IsStandaloneDataGrid() + => string.Equals(GetProp(PropDataGridMode, "related"), "standalone", StringComparison.OrdinalIgnoreCase); + private bool ShouldRenderMissingCommand(string commandName) => !string.IsNullOrWhiteSpace(commandName) && RegisteredCommands.All(command => !string.Equals(command.Name, commandName, StringComparison.OrdinalIgnoreCase)); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor index e36a665e..3510a2d3 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor @@ -75,7 +75,7 @@
Data
+
+ + +
+
+ } + } +
+
+
+} + +@code { + [Parameter] public bool Visible { get; set; } + [Parameter] public EventCallback VisibleChanged { get; set; } + + private readonly List _items = []; + private string _query = string.Empty; + private bool _loading; + private bool _loaded; + + private IReadOnlyList FilteredItems + { + get + { + if (string.IsNullOrWhiteSpace(_query)) + return _items.Take(32).ToList(); + + string query = _query.Trim(); + return _items + .Where(item => item.Title.Contains(query, StringComparison.OrdinalIgnoreCase) + || item.Subtitle.Contains(query, StringComparison.OrdinalIgnoreCase) + || item.Kind.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Take(32) + .ToList(); + } + } + + protected override async Task OnParametersSetAsync() + { + if (Visible && !_loaded && !_loading) + await LoadItemsAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (Visible) + { + try { await JS.InvokeVoidAsync("editorInterop.focus", "admin-command-palette-search"); } + catch { } + } + } + + private async Task LoadItemsAsync() + { + _loading = true; + try + { + _items.Clear(); + AddCoreCommands(); + + IReadOnlyCollection tables = await DbClient.GetTableNamesAsync(); + foreach (string tableName in tables.Where(static name => !name.StartsWith("_", StringComparison.Ordinal)).OrderBy(name => name)) + { + string captured = tableName; + _items.Add(new PaletteItem( + "Table", + captured, + "Open table data", + "bi-table", + "icon-table", + () => { TabManager.OpenTableTab(captured); return Task.CompletedTask; })); + _items.Add(new PaletteItem( + "Schema", + $"Design {captured}", + "Open table schema", + "bi-file-earmark-code", + "icon-system", + () => { TabManager.OpenTableSchemaTab(captured); return Task.CompletedTask; })); + } + + IReadOnlyCollection views = await DbClient.GetViewNamesAsync(); + foreach (string viewName in views.OrderBy(name => name)) + { + string captured = viewName; + _items.Add(new PaletteItem( + "View", + captured, + "Open view data", + "bi-eye", + "icon-view", + () => { TabManager.OpenViewTab(captured); return Task.CompletedTask; })); + } + + foreach (ProcedureDefinition procedure in (await DbClient.GetProceduresAsync()).OrderBy(procedure => procedure.Name)) + { + string captured = procedure.Name; + _items.Add(new PaletteItem( + "Procedure", + captured, + procedure.Description ?? "Open procedure", + "bi-gear-wide-connected", + "icon-trigger", + () => { TabManager.OpenProcedureTab(captured); return Task.CompletedTask; })); + } + + foreach (FormDefinition form in (await FormRepository.ListAsync()).OrderBy(form => form.Name, StringComparer.OrdinalIgnoreCase)) + { + FormDefinition captured = form; + _items.Add(new PaletteItem( + "Form", + captured.Name, + $"Design form for {captured.TableName}", + "bi-ui-checks-grid", + "icon-form", + () => { TabManager.OpenFormDesignerTab(captured.FormId, title: captured.Name); return Task.CompletedTask; })); + _items.Add(new PaletteItem( + "Form", + $"Open {captured.Name}", + $"Data entry for {captured.TableName}", + "bi-file-earmark-text", + "icon-form", + () => { TabManager.OpenFormEntryTab(captured.FormId, captured.Name); return Task.CompletedTask; })); + } + + foreach (ReportDefinition report in (await ReportRepository.ListAsync()).OrderBy(report => report.Name, StringComparer.OrdinalIgnoreCase)) + { + ReportDefinition captured = report; + _items.Add(new PaletteItem( + "Report", + captured.Name, + $"Design report from {captured.Source.Name}", + "bi-file-earmark-richtext", + "icon-report", + () => { TabManager.OpenReportDesignerTab(captured.ReportId, title: captured.Name); return Task.CompletedTask; })); + _items.Add(new PaletteItem( + "Report", + $"Preview {captured.Name}", + $"Render report from {captured.Source.Name}", + "bi-printer", + "icon-report", + () => { TabManager.OpenReportPreviewTab(captured.ReportId, captured.Name); return Task.CompletedTask; })); + } + + _loaded = true; + } + catch + { + _items.Clear(); + AddCoreCommands(); + _loaded = true; + } + finally + { + _loading = false; + } + } + + private void AddCoreCommands() + { + _items.Add(new PaletteItem("Command", "New Query", "Open a SQL editor", "bi-terminal", "icon-system", () => { TabManager.OpenQueryTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Table", "Open table designer", "bi-table", "icon-table", () => { TabManager.OpenTableDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Form", "Open form designer", "bi-ui-checks-grid", "icon-form", () => { TabManager.OpenFormDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Report", "Open report designer", "bi-file-earmark-richtext", "icon-report", () => { TabManager.OpenReportDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Procedure", "Open procedure editor", "bi-gear-wide-connected", "icon-trigger", () => { TabManager.OpenNewProcedureTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Pipeline", "Open pipeline builder", "bi-diagram-3", "icon-view", () => { TabManager.OpenPipelineTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "Storage Inspector", "Open storage diagnostics", "bi-hdd-stack", "icon-system", () => { TabManager.OpenStorageTab(); return Task.CompletedTask; })); + } + + private Task OnQueryChanged(ChangeEventArgs e) + { + _query = e.Value?.ToString() ?? string.Empty; + return Task.CompletedTask; + } + + private async Task HandleKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Escape") + await CloseAsync(); + } + + private async Task ExecuteAsync(PaletteItem item) + { + await item.Execute(); + await CloseAsync(); + } + + private async Task CloseAsync() + { + _query = string.Empty; + await VisibleChanged.InvokeAsync(false); + } + + private void OnChanged() + { + _loaded = false; + if (Visible) + _ = InvokeAsync(LoadItemsAsync); + } + + protected override void OnInitialized() + { + Changes.Changed += OnChanged; + } + + public void Dispose() + { + Changes.Changed -= OnChanged; + } + + private sealed record PaletteItem( + string Kind, + string Title, + string Subtitle, + string Icon, + string IconClass, + Func Execute); + +} diff --git a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor index f7ade104..54a700ba 100644 --- a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor +++ b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor @@ -6,7 +6,9 @@ @implements IDisposable
- +
@if (_sidebarVisible) { @@ -82,9 +84,11 @@ + @code { private bool _sidebarVisible = true; + private bool _commandPaletteVisible; private DotNetObjectReference? _dotNetRef; protected override async Task OnAfterRenderAsync(bool firstRender) @@ -112,6 +116,9 @@ case "ToggleTheme": await Theme.ToggleAsync(); break; + case "OpenCommandPalette": + OpenCommandPalette(); + break; case "CloseTab": if (TabManager.ActiveTab?.Closable == true) TabManager.CloseTab(TabManager.ActiveTab.Id); @@ -129,11 +136,17 @@ StateHasChanged(); } + private void OpenCommandPalette() + { + _commandPaletteVisible = true; + StateHasChanged(); + } + private async Task ShowShortcuts() { await Modal.ConfirmAsync( "Keyboard Shortcuts", - "Ctrl+N \u2014 New query tab\nCtrl+B \u2014 Toggle sidebar\nCtrl+Enter \u2014 Run query\nCtrl+Shift+L \u2014 Toggle theme\nCtrl+W \u2014 Close tab\nDouble-click \u2014 Edit cell", + "Ctrl+K - Command palette\nCtrl+N - New query tab\nCtrl+B - Toggle sidebar\nCtrl+Enter - Run query\nCtrl+Shift+L - Toggle theme\nCtrl+W - Close tab\nDouble-click - Edit cell", "OK"); } diff --git a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor index d84b254b..58ab4be2 100644 --- a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor +++ b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor @@ -12,18 +12,81 @@
-
- -
+
+
+ +
-
+
+ +
+
+ + + + +
+ @if (_elapsed is not null) + { + @_elapsed.Value.TotalMilliseconds.ToString("F1") ms + } + @if (_loading) + { + Running + } +
+
-
- @if (_activeQuerySql is not null && !_loading) - { - - } - else - { -
- @if (_loading) +
+ @if (_resultView == QueryResultView.Results) { -
-
-
- } - else if (_error is not null) - { -
- @_error -
- } - else if (_resultColumns is not null && _resultRows is not null) - { - - - - - @foreach (var col in _resultColumns) - { - - } - - - - @for (int r = 0; r < _resultRows.Count; r++) + @if (_activeQuerySql is not null && !_loading) + { + + } + else + { +
+ @if (_loading) { -
- - @foreach (var val in _resultRows[r]) - { - -
#@col
@(r + 1) - @if (val is null) - { - NULL - } - else if (val is byte[] bytes) - { - [@bytes.Length bytes] - } - else +
+
+
+ } + else if (_error is not null) + { +
+ @_error +
+ } + else if (_resultColumns is not null && _resultRows is not null) + { + + + + + @foreach (var col in _resultColumns) { - @val.ToString() + } - - } - + + + + @for (int r = 0; r < _resultRows.Count; r++) + { + + + @foreach (var val in _resultRows[r]) + { + + } + + } + +
#@col
@(r + 1) + @if (val is null) + { + NULL + } + else if (val is byte[] bytes) + { + [@bytes.Length bytes] + } + else + { + @val.ToString() + } +
+ } + else if (_nonQueryMessage is not null) + { +
+ @_nonQueryMessage +
+ } + else + { +
+ +

Run a query to see results

+
} -
+
+ } } - else if (_nonQueryMessage is not null) + else if (_resultView == QueryResultView.Messages) { -
- @_nonQueryMessage +
+

Messages

+

@GetMessageText()

+
+ } + else if (_resultView == QueryResultView.Stats) + { +
+
Elapsed@(_elapsed?.TotalMilliseconds.ToString("F1") ?? "-") ms
+
Rows@GetResultSummary()
+
Mode@(_activeQuerySql is not null ? "Paged" : "Direct")
+
Status@(_error is null ? "Ready" : "Error")
} else { -
- -

Run a query to see results

+
+

Plan

+

Planner output is not generated automatically from this panel. Use the stats shortcuts above for persisted table and column statistics before comparing plans.

}
- } +
+ +
} else @@ -216,8 +295,10 @@ @code { private enum QueryMode { Sql, Designer } + private enum QueryResultView { Results, Messages, Stats, Plan } private QueryMode _mode = QueryMode.Sql; + private QueryResultView _resultView = QueryResultView.Results; private void SetMode(QueryMode mode) { @@ -277,6 +358,9 @@ private bool _editorHeightInitialized; private ElementReference _queryTabRef; private DotNetObjectReference? _dotNetRef; + private readonly List _queryHistory = []; + + private sealed record QueryHistoryItem(string Title, string Summary, string ElapsedText, string Sql); protected override async Task OnInitializedAsync() { @@ -337,12 +421,15 @@ if (string.IsNullOrWhiteSpace(_sqlText)) return; _loading = true; + _resultView = QueryResultView.Results; + string? executedSql = null; ResetResultState(resetActiveQuery: true); StateHasChanged(); try { string trimmedSql = _sqlText.Trim(); + executedSql = trimmedSql; if (TryParseExecuteCommand(trimmedSql, out var procedureName, out var args, out var parseError)) { if (parseError is not null) @@ -380,6 +467,8 @@ _loading = false; // Save SQL text back to tab state for preservation if (Tab is not null) Tab.SqlText = _sqlText; + if (executedSql is not null) + AddQueryHistory(executedSql); StateHasChanged(); } } @@ -605,6 +694,59 @@ Tab.SqlText = _sqlText; } + private void SetResultView(QueryResultView view) => _resultView = view; + + private string GetResultViewClass(QueryResultView view) + => _resultView == view ? "active" : string.Empty; + + private string GetResultBadge() + { + if (_activeQuerySql is not null) + return _queryResultsStatus.VisibleRows > 0 ? _queryResultsStatus.VisibleRows.ToString(CultureInfo.InvariantCulture) : string.Empty; + + return _resultRows is { Count: > 0 } rows ? rows.Count.ToString(CultureInfo.InvariantCulture) : string.Empty; + } + + private string GetMessageText() + { + if (_error is not null) + return _error; + + if (_nonQueryMessage is not null) + return _nonQueryMessage; + + if (_loading) + return "Query is running."; + + if (_elapsed is not null) + return "Query completed."; + + return "No messages yet."; + } + + private void AddQueryHistory(string sql) + { + string normalized = sql.ReplaceLineEndings(" ").Trim(); + if (normalized.Length > 86) + normalized = normalized[..83] + "..."; + + string summary = _error is not null + ? "Error" + : _nonQueryMessage + ?? (_activeQuerySql is not null ? "Paged results" : GetResultSummary()); + + string elapsedText = _elapsed is null + ? "pending" + : $"{_elapsed.Value.TotalMilliseconds:F1} ms"; + + _queryHistory.RemoveAll(item => string.Equals(item.Sql, sql, StringComparison.Ordinal)); + _queryHistory.Insert(0, new QueryHistoryItem(normalized, summary, elapsedText, sql)); + if (_queryHistory.Count > 12) + _queryHistory.RemoveRange(12, _queryHistory.Count - 12); + } + + private void ClearHistory() => _queryHistory.Clear(); + private async Task RunSqlWithoutPagingAsync(string sql) { var result = await DbClient.ExecuteSqlAsync(sql); diff --git a/src/CSharpDB.Admin/Components/Tabs/StorageTab.razor b/src/CSharpDB.Admin/Components/Tabs/StorageTab.razor index 29e2944d..cec394a6 100644 --- a/src/CSharpDB.Admin/Components/Tabs/StorageTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/StorageTab.razor @@ -25,6 +25,33 @@
@(_dbReport?.DatabasePath ?? DbClient.DataSource)
+
+ + +
@if (_loading) {
@@ -33,9 +60,16 @@ } else { -
+
+
+
File@FormatBytes(_maintenanceReport?.SpaceUsage.DatabaseFileBytes ?? 0)
+
Pages@(_dbReport?.Header.PhysicalPageCount.ToString() ?? "-")
+
Page size@(_dbReport?.Header.PageSize.ToString() ?? "-")
+
Issues@_combinedIssues.Count
+
+
-
+

Database Header

@if (_dbReport is not null) { @@ -55,7 +89,7 @@ }
-
+

WAL

@if (_walReport is not null) { @@ -77,7 +111,7 @@
-
+

Space Usage

@if (_maintenanceReport is not null) { @@ -95,7 +129,7 @@ }
-
+

Fragmentation

@if (_maintenanceReport is not null) { @@ -111,7 +145,7 @@
-
+

Maintenance

@@ -336,7 +370,7 @@
-
+

Page Type Histogram

@if (_dbReport?.PageTypeHistogram is not null && _dbReport.PageTypeHistogram.Count > 0) { @@ -364,7 +398,7 @@ }
-
+

Index Checks

@if (_indexReport?.Indexes is not null && _indexReport.Indexes.Count > 0) { @@ -402,7 +436,7 @@ }
-
+

Integrity Issues

@if (_combinedIssues.Count > 0) { @@ -438,7 +472,7 @@ }
-
+

Page Drill-Down

@if (_pageReport is null) { @@ -488,6 +522,8 @@
} +
+
@code { @@ -535,6 +571,12 @@ private IReadOnlyList _availableIndexes = []; private readonly List _combinedIssues = new(); + private string IndexIssueBadge + => _indexReport?.Indexes is null + ? "-" + : _indexReport.Indexes.All(index => index.RootPageValid && index.TableExists && index.ColumnsExistInTable && index.RootTreeReachable) + ? "OK" + : "Check"; protected override async Task OnInitializedAsync() { diff --git a/src/CSharpDB.Admin/Components/Tabs/WelcomeTab.razor b/src/CSharpDB.Admin/Components/Tabs/WelcomeTab.razor index bf9c203a..2b15265f 100644 --- a/src/CSharpDB.Admin/Components/Tabs/WelcomeTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/WelcomeTab.razor @@ -1,140 +1,303 @@ @inject ICSharpDbClient DbClient +@inject IFormRepository FormRepository +@inject IReportRepository ReportRepository @inject DatabaseChangeService Changes @inject TabManagerService TabManager @implements IDisposable -
-
- CSharpDB Studio -

Welcome to CSharpDB Studio

-

Select a table from the sidebar to browse data, or open a new query tab.

- -
-
- Ctrl+N New query tab -
-
- Ctrl+B Toggle sidebar -
-
- Ctrl+Enter Run query -
-
- Ctrl+Shift+L Toggle theme -
+
+
+
+
@DbClient.DataSource
+

Dashboard

-
+
+ + +
+ - @if (_tableNames is not null && _tableNames.Count > 0) + @if (_loading) { -
-
-

Tables

- -
-
- @foreach (var name in _tableNames) +
+ } + else + { +
+ + + + + +
+ +
+
+
+
+

Top Tables

+ @TopTablesScopeText +
+ +
+ @if (_userTables.Count == 0) { - + } + } +
+ +
+
+

Quick Actions

+
+ + + + +
+
+ +
+

Recent Tabs

+ @if (RecentTabs.Count == 0) + { +

No recent tabs yet.

+ } + else + { + @foreach (TabDescriptor tab in RecentTabs) { - @count rows + } - - } + } +
+ +
+

Health

+
ConnectionConnected
+
Database@DatabaseName
+
Objects@(_userTables.Count + _viewCount + _procedureCount)
+
Forms/Reports@(_formCount + _reportCount)
+
}
@code { - private IReadOnlyCollection? _tableNames; - private readonly Dictionary _rowCounts = new(); - private CancellationTokenSource? _countLoadCts; + private const int RowCountLimit = 12; + private const int TopTablesDisplayLimit = 8; + + private readonly Dictionary _rowCounts = new(StringComparer.OrdinalIgnoreCase); + private IReadOnlyList _userTables = []; + private IReadOnlyList _views = []; + private int _viewCount; + private int _procedureCount; + private int _formCount; + private int _reportCount; + private bool _loading; private bool _loadingRowCounts; + private string DatabaseName => Path.GetFileName(DbClient.DataSource) is { Length: > 0 } name ? name : DbClient.DataSource; + + private IReadOnlyList RecentTabs + => TabManager.Tabs + .Where(tab => tab.Kind != TabKind.Welcome) + .Reverse() + .Take(5) + .ToList(); + + private IReadOnlyList TopTables + => _userTables + .OrderByDescending(name => _rowCounts.TryGetValue(name, out int count) ? count : -1) + .ThenBy(name => name, StringComparer.OrdinalIgnoreCase) + .Take(TopTablesDisplayLimit) + .ToList(); + + private int MaxCountedRows + => _rowCounts.Values.Count == 0 ? 0 : _rowCounts.Values.Max(); + + private string TopTablesScopeText + { + get + { + if (_userTables.Count == 0) + return "No user tables"; + + int displayed = Math.Min(TopTablesDisplayLimit, _userTables.Count); + int countLimit = Math.Min(RowCountLimit, _userTables.Count); + return $"Showing {displayed:n0} of {_userTables.Count:n0} tables; row counts loaded for {_rowCounts.Count:n0} of {countLimit:n0}."; + } + } + protected override async Task OnInitializedAsync() { Changes.Changed += OnChanged; - await LoadTablesAsync(); + await LoadDashboardAsync(); } private async void OnChanged() { - await LoadTablesAsync(); + await LoadDashboardAsync(); await InvokeAsync(StateHasChanged); } - private async Task LoadTablesAsync() + private async Task LoadDashboardAsync() { - CancelCountLoad(); - + _loading = true; try { - _tableNames = (await DbClient.GetTableNamesAsync()) - .Where(n => !n.StartsWith("_", StringComparison.Ordinal)) - .OrderBy(n => n).ToList(); + IReadOnlyCollection tables = await DbClient.GetTableNamesAsync(); + _userTables = tables + .Where(static name => !name.StartsWith("_", StringComparison.Ordinal)) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); _rowCounts.Clear(); - _loadingRowCounts = false; + _views = (await DbClient.GetViewNamesAsync()).OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList(); + _viewCount = _views.Count; + _procedureCount = (await DbClient.GetProceduresAsync()).Count; + _formCount = (await FormRepository.ListAsync()).Count; + _reportCount = (await ReportRepository.ListAsync()).Count; + await LoadRowCountsAsync(); + } + catch + { + _userTables = []; + _rowCounts.Clear(); + _views = []; + _viewCount = 0; + _procedureCount = 0; + _formCount = 0; + _reportCount = 0; + } + finally + { + _loading = false; } - catch { /* ignore */ } } private async Task LoadRowCountsAsync() { - if (_loadingRowCounts || _tableNames is not { Count: > 0 } tableNames) + if (_loadingRowCounts) return; - CancelCountLoad(); - _countLoadCts = new CancellationTokenSource(); _loadingRowCounts = true; - try { - foreach (var name in tableNames) + foreach (string tableName in _userTables.Take(RowCountLimit)) { try { - _rowCounts[name] = await DbClient.GetRowCountAsync(name, _countLoadCts.Token); - await InvokeAsync(StateHasChanged); - } - catch (OperationCanceledException) - { - break; + _rowCounts[tableName] = await DbClient.GetRowCountAsync(tableName); } catch { - // Ignore per-table count failures so one bad object does not block the rest. } } } finally { _loadingRowCounts = false; - await InvokeAsync(StateHasChanged); } } - private void CancelCountLoad() + private void OpenFirstTable() + { + if (_userTables.Count > 0) + TabManager.OpenTableTab(_userTables[0]); + } + + private void OpenFirstView() { - _countLoadCts?.Cancel(); - _countLoadCts?.Dispose(); - _countLoadCts = null; + if (_views.Count > 0) + TabManager.OpenViewTab(_views[0]); } + private string FormatRows(string tableName) + => _rowCounts.TryGetValue(tableName, out int count) + ? $"{count:n0} rows" + : "not counted"; + + private double GetRowMeterWidth(string tableName) + { + if (!_rowCounts.TryGetValue(tableName, out int count) || MaxCountedRows <= 0) + return 0; + + return Math.Max(4, count * 100d / MaxCountedRows); + } + + private string GetRowMeterTitle(string tableName) + => _rowCounts.TryGetValue(tableName, out int count) && MaxCountedRows > 0 + ? $"{count:n0} of {MaxCountedRows:n0} rows among shown tables" + : "Row count not available"; + public void Dispose() { Changes.Changed -= OnChanged; - CancelCountLoad(); } } diff --git a/src/CSharpDB.Admin/Models/DataGridFilterSummary.cs b/src/CSharpDB.Admin/Models/DataGridFilterSummary.cs new file mode 100644 index 00000000..bfedeb12 --- /dev/null +++ b/src/CSharpDB.Admin/Models/DataGridFilterSummary.cs @@ -0,0 +1,7 @@ +namespace CSharpDB.Admin.Models; + +public sealed record DataGridFilterSummary( + int ColumnIndex, + string ColumnName, + DataGridFilterMatchMode MatchMode, + string Value); diff --git a/src/CSharpDB.Admin/wwwroot/css/app.css b/src/CSharpDB.Admin/wwwroot/css/app.css index 558035a6..d3e054c9 100644 --- a/src/CSharpDB.Admin/wwwroot/css/app.css +++ b/src/CSharpDB.Admin/wwwroot/css/app.css @@ -811,6 +811,23 @@ body { .data-grid th.sortable { cursor: pointer; } .data-grid th.sortable:hover { color: var(--text-primary); background: var(--bg-hover); } +.data-grid th.sorted { + color: var(--text-primary); + box-shadow: inset 0 2px 0 var(--accent-blue); +} + +.data-grid .col-icon { + width: 13px; + margin-right: 6px; + color: var(--text-muted); + font-size: 10px; +} + +.data-grid .col-icon.key, +.data-grid .col-icon.date { color: var(--accent-yellow); } +.data-grid .col-icon.text { color: var(--accent-cyan); } +.data-grid .col-icon.int { color: var(--accent-orange); } +.data-grid .col-icon.blob { color: var(--accent-magenta); } .col-name { margin-right: 6px; } @@ -884,6 +901,26 @@ body { } .pk-val { color: var(--accent-yellow); font-weight: 600; } +.data-id-cell { color: var(--accent-blue); } +.data-number-cell { text-align: right; color: var(--accent-orange); } +.data-date-cell { color: var(--accent-yellow); } +.data-null-cell { color: var(--text-muted); } + +.pill-status { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 8px; + border-radius: 999px; + font-family: var(--font-ui); + font-size: 10.5px; + line-height: 1; +} + +.pill-status.active { background: rgba(158, 206, 106, 0.12); color: var(--accent-green); } +.pill-status.pending { background: rgba(224, 175, 104, 0.12); color: var(--accent-yellow); } +.pill-status.inactive { background: rgba(120, 130, 169, 0.12); color: var(--text-muted); } +.pill-status.neutral { background: var(--bg-tertiary); color: var(--text-secondary); } /* Row states */ .new-row { background: rgba(158,206,106,0.08) !important; border-left: 3px solid var(--accent-green); } @@ -3255,3 +3292,1549 @@ body { letter-spacing: 0.3px; margin-bottom: 8px; } + +/* ═══════════════════════════════════════════════════ + ADMIN UI MOCKUP INTEGRATION + ═══════════════════════════════════════════════════ */ + +.db-switcher, +.palette-launcher { + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; + white-space: nowrap; +} + +.db-switcher { + padding: 0 10px; + color: var(--text-primary); +} + +.db-switcher .bi-caret-down-fill { + color: var(--text-muted); + font-size: 9px; +} + +.palette-launcher { + min-width: 240px; + max-width: 340px; + padding: 0 8px 0 10px; +} + +.palette-launcher span:not(.kbd) { + overflow: hidden; + text-overflow: ellipsis; +} + +.palette-launcher .kbd { + margin-left: auto; + flex-shrink: 0; +} + +.db-switcher:hover, +.palette-launcher:hover { + border-color: var(--border-light); + color: var(--text-primary); + background: var(--bg-hover); +} + +.titlebar-toolbar { + overflow: hidden; +} + +.icon-form { color: var(--accent-teal); } +.icon-report { color: var(--accent-yellow); } +.icon-pin { color: var(--accent-yellow); } +.icon-recent { color: var(--accent-cyan); } + +.command-palette-backdrop { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + justify-content: center; + align-items: flex-start; + padding-top: 12vh; + background: rgba(0, 0, 0, 0.46); +} + +.command-palette { + width: min(720px, calc(100vw - 32px)); + max-height: min(620px, calc(100vh - 96px)); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-elevated); + border: 1px solid var(--border-light); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-popup); +} + +.command-palette-search { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.command-palette-search i { + color: var(--text-muted); + font-size: 16px; +} + +.command-palette-search input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-family: var(--font-ui); + font-size: 15px; +} + +.command-palette-body { + padding: 6px; + overflow-y: auto; +} + +.command-palette-item { + width: 100%; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + text-align: left; + font-family: var(--font-ui); + cursor: pointer; +} + +.command-palette-item:hover { + background: var(--bg-hover); +} + +.command-palette-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} + +.command-palette-copy { + min-width: 0; + display: flex; + flex-direction: column; +} + +.command-palette-copy strong, +.command-palette-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.command-palette-copy strong { + font-size: 13px; + font-weight: 600; +} + +.command-palette-copy small, +.command-palette-kind, +.command-palette-empty { + color: var(--text-muted); + font-size: 11px; +} + +.command-palette-kind { + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-tertiary); +} + +.command-palette-empty { + padding: 28px 16px; + text-align: center; +} + +.sidebar-header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.sidebar-clear-btn { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--sidebar-text-muted); + cursor: pointer; +} + +.sidebar-clear-btn:hover { + color: var(--sidebar-text); + background: var(--sidebar-item-hover-bg); +} + +.sidebar-filter-chips { + display: flex; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--sidebar-border); +} + +.sidebar-filter-chips button { + flex: 1; + min-width: 0; + height: 24px; + border: 1px solid var(--sidebar-btn-border); + border-radius: 999px; + background: transparent; + color: var(--sidebar-text-muted); + font-size: 11px; + font-family: var(--font-ui); + cursor: pointer; +} + +.sidebar-filter-chips button:hover, +.sidebar-filter-chips button.active { + color: var(--sidebar-text); + background: var(--sidebar-active-bg); + border-color: var(--sidebar-btn-hover-border); +} + +.tree-group.utility-group { + margin: 4px 0 8px; +} + +.tree-group-header.compact { + padding-top: 5px; + padding-bottom: 5px; + font-size: 11px; +} + +.tree-item.command-item { + width: 100%; + border-top: 0; + border-right: 0; + border-bottom: 0; + background: transparent; + font-family: var(--font-ui); + text-align: left; +} + +.tree-item.table-tree-item { + display: grid; + grid-template-columns: 16px 16px minmax(0, 1fr); + gap: 8px; + padding-left: 14px; +} + +.tree-disclosure { + width: 16px; + height: 18px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--sidebar-text-muted); + cursor: pointer; + padding: 0; +} + +.tree-disclosure:hover { + color: var(--sidebar-text); + background: var(--sidebar-item-hover-bg); +} + +.tree-disclosure i { + font-size: 9px; +} + +.tree-child-group { + margin-left: 22px; + padding: 2px 0 5px; + border-left: 2px solid var(--sidebar-border); + background: rgba(255, 255, 255, 0.015); +} + +.tree-child-head { + padding: 5px 12px 2px 26px; + color: var(--sidebar-text-muted); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.7px; +} + +.tree-child-item, +.tree-child-more { + width: 100%; + min-height: 24px; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: var(--sidebar-text-secondary); + cursor: pointer; + font-family: var(--font-ui); + text-align: left; +} + +.tree-child-item { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) 48px; + align-items: center; + gap: 7px; + padding: 3px 12px 3px 26px; + font-size: 11.5px; +} + +.tree-child-item:hover, +.tree-child-more:hover { + color: var(--sidebar-text); + background: var(--sidebar-item-hover-bg); +} + +.tree-child-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-child-type { + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--accent-cyan); + font-family: var(--font-mono); + font-size: 10px; + opacity: 0.9; +} + +.tree-child-more, +.tree-child-state { + display: block; + padding: 4px 12px 4px 42px; + color: var(--sidebar-text-muted); + font-size: 11px; +} + +.tree-child-more { + color: var(--accent-blue); +} + +.icon-column-key { color: var(--accent-yellow); } +.icon-column-number { color: var(--accent-green); } +.icon-column-text { color: var(--accent-cyan); } +.icon-column-blob, +.icon-column-generic { color: var(--accent-magenta); } + +.item-pin { + margin-left: auto; + color: var(--accent-yellow); + font-size: 10px; + opacity: 0.75; +} + +.dashboard-tab { + height: 100%; + overflow: auto; + padding: 22px; +} + +.dashboard-header, +.object-header { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 16px; +} + +.dashboard-header h1, +.object-title h2 { + margin: 2px 0 0; + color: var(--text-primary); + font-size: 22px; + font-weight: 700; + letter-spacing: 0; +} + +.object-title h2 { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; +} + +.dashboard-crumb, +.object-crumb { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 12px; + min-width: 0; +} + +.dashboard-crumb { + display: block; + max-width: 720px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-actions, +.object-facts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.object-facts span { + display: inline-flex; + flex-direction: column; + gap: 1px; + min-width: 88px; + padding: 7px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.object-facts strong { + color: var(--text-primary); + font-size: 13px; + text-transform: none; + letter-spacing: 0; +} + +.dashboard-stats { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.dashboard-stat, +.dashboard-panel, +.schema-summary-card, +.heavy-summary-strip > div { + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); +} + +.dashboard-stat { + min-height: 92px; + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + color: var(--text-secondary); + text-align: left; + font-family: var(--font-ui); + cursor: pointer; +} + +.dashboard-stat:hover, +.dashboard-panel button:hover, +.schema-summary-card:hover { + border-color: var(--border-light); + background: var(--bg-hover); +} + +.dashboard-stat .label, +.dashboard-panel header h3 { + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.45px; +} + +.dashboard-stat strong { + color: var(--text-primary); + font-size: 26px; + line-height: 1; +} + +.dashboard-stat .meta { + color: var(--text-muted); + font-size: 11px; +} + +.dashboard-stat i { + position: absolute; + right: 12px; + bottom: 10px; + font-size: 18px; + opacity: 0.78; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.8fr); + gap: 14px; +} + +.dashboard-panel { + padding: 12px; + margin-bottom: 12px; +} + +.dashboard-panel header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.dashboard-panel-title { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.dashboard-panel-title span { + color: var(--text-muted); + font-size: 11px; + line-height: 1.3; +} + +.panel-link { + border: 0; + background: transparent; + color: var(--accent-blue); + font-size: 12px; + cursor: pointer; +} + +.dashboard-table-row, +.dashboard-recent-row, +.dashboard-actions-grid button { + width: 100%; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-primary); + font-family: var(--font-ui); + cursor: pointer; +} + +.dashboard-table-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 120px 20px; + gap: 12px; + align-items: center; + padding: 9px 8px; +} + +.dashboard-table-row .table-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-table-row .row-count, +.dashboard-empty { + color: var(--text-muted); + font-size: 12px; +} + +.row-meter { + height: 8px; + display: block; + overflow: hidden; + border-radius: 999px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); +} + +.row-meter span { + display: block; + height: 100%; + min-width: 0; + border-radius: inherit; + background: var(--accent-blue); + opacity: 0.86; +} + +.dashboard-actions-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.dashboard-actions-grid button { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + grid-template-rows: auto auto; + gap: 2px 8px; + align-items: center; + padding: 10px; + text-align: left; + background: var(--bg-tertiary); +} + +.dashboard-actions-grid i { + grid-row: 1 / span 2; + font-size: 17px; +} + +.dashboard-actions-grid small, +.dashboard-recent-row small { + color: var(--text-muted); + font-size: 11px; +} + +.dashboard-recent-row { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding: 7px 8px; + text-align: left; +} + +.dashboard-recent-row span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-health-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 6px 0; + border-top: 1px solid var(--border-color); + color: var(--text-secondary); +} + +.dashboard-health-row:first-of-type { + border-top: 0; +} + +.dashboard-health-row strong.ok { + color: var(--accent-green); +} + +.object-header { + padding: 14px 16px 0; + margin-bottom: 0; +} + +.data-toolbar { + min-height: 48px; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + flex-shrink: 0; + overflow-x: auto; +} + +.data-crumb { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 190px; + color: var(--text-muted); + font-size: 12px; +} + +.data-crumb button { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + border: 0; + background: transparent; + color: var(--text-muted); + font: inherit; + cursor: pointer; +} + +.data-crumb button:hover { color: var(--text-primary); } + +.data-crumb strong { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-weight: 700; +} + +.data-view-switcher { + display: inline-flex; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + flex-shrink: 0; +} + +.data-view-switcher button { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 11px; + border: 0; + border-right: 1px solid var(--border-color); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 11px; + cursor: pointer; +} + +.data-view-switcher button:last-child { border-right: 0; } + +.data-view-switcher button:hover, +.data-view-switcher button.active { + color: var(--text-primary); + background: var(--bg-hover); +} + +.data-view-switcher button.active { + color: var(--accent-blue); + font-weight: 700; +} + +.data-view-switcher button span { + color: var(--text-muted); + font-size: 10px; +} + +.data-toolbar-group { + display: inline-flex; + align-items: center; + gap: 4px; + padding-left: 10px; + border-left: 1px solid var(--border-color); + flex-shrink: 0; +} + +.data-toolbar-btn { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-family: var(--font-ui); + font-size: 11px; + cursor: pointer; + white-space: nowrap; +} + +.data-toolbar-btn:hover:not(:disabled), +.data-toolbar-btn.active { + color: var(--text-primary); + border-color: var(--border-color); + background: var(--bg-hover); +} + +.data-toolbar-btn.primary { + color: #0a0b13; + border-color: var(--accent-blue); + background: var(--accent-blue); + font-weight: 700; +} + +.data-toolbar-btn.danger { + color: var(--accent-red); +} + +.data-toolbar-btn.danger:hover:not(:disabled) { + border-color: rgba(247, 118, 142, 0.35); + background: rgba(247, 118, 142, 0.12); +} + +.data-toolbar-btn:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.data-pager { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; + flex-shrink: 0; +} + +.data-pager span { + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.data-pager button { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: pointer; +} + +.data-pager button:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--border-light); +} + +.data-pager button:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.data-filter-bar { + min-height: 36px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); + flex-wrap: wrap; + flex-shrink: 0; +} + +.data-filter-label { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.55px; +} + +.data-filter-empty { + color: var(--text-muted); + font-size: 11px; +} + +.data-filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 24px; + padding: 2px 4px 2px 10px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 11.5px; +} + +.data-filter-chip .col { + color: var(--accent-blue); + font-weight: 700; +} + +.data-filter-chip .op { + color: var(--text-muted); + font-family: var(--font-mono); +} + +.data-filter-chip .val { + color: var(--accent-green); + font-family: var(--font-mono); +} + +.data-filter-chip button { + width: 18px; + height: 18px; + display: grid; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.data-filter-chip button:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.data-add-filter { + min-height: 24px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 10px; + border: 1px dashed var(--border-color); + border-radius: 999px; + background: transparent; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; +} + +.data-add-filter:hover { + color: var(--text-primary); + border-color: var(--border-light); +} + +.data-filter-summary { + margin-left: auto; + color: var(--text-muted); + font-size: 11px; +} + +.data-bulk-bar { + position: sticky; + bottom: 0; + z-index: 5; + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 8px 16px; + border-top: 1px solid var(--border-light); + background: var(--bg-elevated); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.28); + color: var(--text-secondary); + font-size: 12px; + flex-shrink: 0; +} + +.data-bulk-bar .count { + padding: 2px 9px; + border-radius: 999px; + background: var(--accent-blue); + color: #0a0b13; + font-weight: 700; +} + +.data-bulk-bar .actions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.bulk-edit-modal, +.table-filter-modal { + max-width: 520px; +} + +.bulk-edit-modal .modal-body, +.table-filter-modal .modal-body { + white-space: normal; +} + +.bulk-edit-summary, +.table-filter-summary { + margin: 0 0 14px; + color: var(--text-secondary); +} + +.bulk-edit-summary strong, +.table-filter-summary strong { + color: var(--text-primary); +} + +.bulk-edit-form, +.table-filter-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.bulk-edit-form label, +.table-filter-form label { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.45px; +} + +.bulk-edit-form .modal-input, +.table-filter-form .modal-input { + margin-top: 0; + font-family: var(--font-mono); +} + +.bulk-null-row { + flex-direction: row !important; + align-items: center; + gap: 8px !important; + min-height: 26px; + color: var(--text-primary) !important; + font-size: 12px !important; + font-weight: 500 !important; + text-transform: none !important; + letter-spacing: 0 !important; +} + +.bulk-null-row input { + width: 14px; + height: 14px; + accent-color: var(--accent-blue); +} + +.bulk-null-row input:disabled, +.bulk-null-row input:disabled + span { + opacity: 0.5; +} + +.bulk-edit-note { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 10px; + border: 1px solid rgba(224, 175, 104, 0.25); + border-radius: var(--radius-sm); + background: rgba(224, 175, 104, 0.08); + color: var(--accent-yellow); + font-size: 12px; +} + +.schema-subnav { + position: sticky; + top: 0; + z-index: 2; + display: flex; + gap: 6px; + padding: 0 0 12px; + background: var(--bg-primary); +} + +.schema-subnav button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 11px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 12px; + font-family: var(--font-ui); + cursor: pointer; +} + +.schema-subnav button:hover, +.schema-subnav button.active { + color: var(--text-primary); + border-color: var(--border-light); + background: var(--bg-hover); +} + +.schema-subnav button.active { + box-shadow: inset 0 2px 0 var(--accent-blue); +} + +.schema-subnav button span { + color: var(--text-muted); + font-size: 11px; +} + +.schema-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(160px, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.schema-summary-card { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 96px; + padding: 12px; + color: var(--text-secondary); + text-align: left; + font-family: var(--font-ui); + cursor: pointer; +} + +.schema-summary-card span { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.schema-summary-card strong { + color: var(--text-primary); + font-size: 28px; + line-height: 1; +} + +.schema-summary-card small { + color: var(--text-secondary); + font-size: 12px; +} + +.query-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.query-main-stack { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.query-result-tabs { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary); + flex-shrink: 0; +} + +.query-result-tabs button { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 13px; + border: 0; + border-right: 1px solid var(--border-color); + border-top: 2px solid transparent; + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 12px; + cursor: pointer; +} + +.query-result-tabs button:hover, +.query-result-tabs button.active { + color: var(--text-primary); + background: var(--bg-primary); +} + +.query-result-tabs button.active { + border-top-color: var(--accent-blue); +} + +.query-result-tabs button span:not(:empty) { + min-width: 18px; + padding: 1px 6px; + border-radius: 999px; + background: rgba(122, 162, 247, 0.16); + color: var(--accent-blue); + font-size: 10px; + text-align: center; +} + +.query-result-meta { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; + padding: 0 12px; + color: var(--text-muted); + font-size: 11px; +} + +.query-result-body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.query-result-body > .grid-container { + height: 100%; +} + +.query-detail-pane { + padding: 18px; + color: var(--text-secondary); +} + +.query-detail-pane h3 { + margin-bottom: 8px; + color: var(--text-primary); + font-size: 13px; +} + +.query-detail-pane .ok { + color: var(--accent-green); +} + +.query-detail-pane .danger { + color: var(--accent-red); +} + +.query-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; + padding: 14px; +} + +.query-stat-grid > div { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-elevated); +} + +.query-stat-grid span { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.query-stat-grid strong { + color: var(--text-primary); + font-size: 16px; +} + +.query-history-rail { + display: flex; + flex-direction: column; + min-width: 0; + border-left: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.query-history-rail header { + height: 34px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px 0 12px; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.45px; +} + +.query-history-rail header button { + width: 24px; + height: 24px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.query-history-rail header button:hover:not(:disabled) { + color: var(--text-primary); + background: var(--bg-hover); +} + +.query-history-empty { + padding: 12px; + color: var(--text-muted); + font-size: 12px; +} + +.query-history-item { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; + padding: 9px 12px; + border: 0; + border-bottom: 1px solid var(--border-color); + background: transparent; + color: var(--text-primary); + text-align: left; + font-family: var(--font-ui); + cursor: pointer; +} + +.query-history-item:hover { + background: var(--bg-hover); +} + +.query-history-item strong, +.query-history-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.query-history-item strong { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; +} + +.query-history-item span, +.query-history-item small { + color: var(--text-muted); + font-size: 11px; +} + +.heavy-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + flex: 1; + min-height: 0; + overflow: hidden; +} + +.heavy-rail { + display: flex; + flex-direction: column; + overflow-y: auto; + border-right: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +.heavy-rail-head { + padding: 12px 14px 9px; + border-bottom: 1px solid var(--border-color); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.55px; +} + +.heavy-rail-section { + padding: 8px 0 4px; +} + +.heavy-rail-label { + padding: 4px 14px; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.55px; +} + +.heavy-rail-item { + display: flex; + align-items: center; + gap: 9px; + min-height: 32px; + padding: 0 12px; + border-left: 2px solid transparent; + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; +} + +.heavy-rail-item:hover, +.heavy-rail-item.active { + color: var(--text-primary); + background: var(--bg-hover); + text-decoration: none; +} + +.heavy-rail-item.active { + border-left-color: var(--accent-blue); + background: var(--sidebar-active-bg); +} + +.heavy-rail-item i { + width: 16px; + color: var(--text-muted); + text-align: center; +} + +.heavy-rail-item.active i { + color: var(--accent-blue); +} + +.heavy-rail-item .badge { + margin-left: auto; + padding: 1px 7px; + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-muted); + font-size: 10px; +} + +.heavy-rail-item .badge.warn { + background: rgba(224, 175, 104, 0.16); + color: var(--accent-yellow); +} + +.heavy-pane { + overflow-y: auto; + min-width: 0; +} + +.storage-tab .toolbar-info { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storage-tab .schema-table td, +.storage-tab .schema-table th { + overflow-wrap: anywhere; +} + +.storage-tab .schema-table .mono-cell { + word-break: break-all; +} + +.heavy-summary-strip { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 10px; +} + +.heavy-summary-strip > div { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px 12px; +} + +.heavy-summary-strip span { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.heavy-summary-strip strong { + color: var(--text-primary); + font-size: 18px; +} + +@media (max-width: 1200px) { + .titlebar-toolbar .titlebar-action-btn span, + .titlebar-action-btn { + font-size: 0; + } + + .titlebar-action-btn i { + font-size: 13px; + } + + .dashboard-stats { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + +} + +@media (max-width: 980px) { + .query-workspace { + grid-template-columns: minmax(0, 1fr); + } + + .query-history-rail { + display: none; + } +} + +@media (max-width: 820px) { + .palette-launcher { + min-width: 44px; + width: 44px; + justify-content: center; + } + + .palette-launcher span { + display: none; + } + + .object-header, + .dashboard-header { + flex-direction: column; + } + + .object-facts, + .schema-summary-grid, + .heavy-summary-strip { + grid-template-columns: 1fr; + } + + .object-facts span { + flex: 1 1 140px; + } + + .schema-subnav { + overflow-x: auto; + } + + .heavy-layout { + grid-template-columns: minmax(0, 1fr); + } + + .heavy-rail { + display: none; + } +} diff --git a/src/CSharpDB.Admin/wwwroot/js/interop.js b/src/CSharpDB.Admin/wwwroot/js/interop.js index 43fa7b20..cc440369 100644 --- a/src/CSharpDB.Admin/wwwroot/js/interop.js +++ b/src/CSharpDB.Admin/wwwroot/js/interop.js @@ -7,6 +7,24 @@ window.themeInterop = { } }; +window.fileInterop = { + downloadText: (fileName, contentType, content) => { + const blob = new Blob([content || ''], { type: contentType || 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName || 'export.txt'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } +}; + +window.clipboardInterop = { + writeText: (text) => navigator.clipboard.writeText(text || '') +}; + // Keyboard shortcut listener - invokes .NET methods window.keyboardInterop = { _dotNetRef: null, @@ -35,6 +53,11 @@ window.keyboardInterop = { e.preventDefault(); ref.invokeMethodAsync('OnKeyboardShortcut', 'NewQuery'); } + // Ctrl+K: command palette + else if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === 'k') { + e.preventDefault(); + ref.invokeMethodAsync('OnKeyboardShortcut', 'OpenCommandPalette'); + } // Ctrl+B: Toggle sidebar else if (e.ctrlKey && e.key === 'b') { e.preventDefault(); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs index e2b17b0c..34f002cf 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs @@ -35,6 +35,87 @@ public async Task OnParametersSetAsync_ReloadsWhenChildTableChangesForSameParent Assert.Equal([201L], ReadRows(component).Select(row => (long)row["PaymentId"]!).ToArray()); } + [Fact] + public async Task OnParametersSetAsync_StandaloneGridLoadsFirstPage() + { + var service = new TableSpecificRecordService(); + var component = new ChildDataGrid(); + SetProperty(component, "RecordService", service); + + SetProperty(component, nameof(ChildDataGrid.ChildTableName), "Orders"); + SetProperty(component, nameof(ChildDataGrid.ForeignKeyField), ""); + SetProperty(component, nameof(ChildDataGrid.ParentKeyValue), null); + SetProperty(component, nameof(ChildDataGrid.IsStandalone), true); + SetProperty(component, nameof(ChildDataGrid.ChildFormTableDefinition), CreateTableDefinition("Orders", "OrderId", "CustomerId")); + + await InvokeNonPublicAsync(component, "OnParametersSetAsync"); + + Assert.Empty(service.RequestedTables); + Assert.Empty(service.RequestedAllTables); + Assert.Equal([("Orders", 1, 25)], service.RequestedPages); + Assert.Equal(30, ReadIntField(component, "_totalCount")); + Assert.Equal(1, ReadIntField(component, "_pageNumber")); + Assert.Equal(Enumerable.Range(101, 25).Select(i => (long)i).ToArray(), ReadRows(component).Select(row => (long)row["OrderId"]!).ToArray()); + } + + [Fact] + public async Task GoToPageAsync_StandaloneGridLoadsRequestedPage() + { + var service = new TableSpecificRecordService(); + var component = new ChildDataGrid(); + SetProperty(component, "RecordService", service); + + SetProperty(component, nameof(ChildDataGrid.ChildTableName), "Orders"); + SetProperty(component, nameof(ChildDataGrid.ForeignKeyField), ""); + SetProperty(component, nameof(ChildDataGrid.ParentKeyValue), null); + SetProperty(component, nameof(ChildDataGrid.IsStandalone), true); + SetProperty(component, nameof(ChildDataGrid.ChildFormTableDefinition), CreateTableDefinition("Orders", "OrderId", "CustomerId")); + + await InvokeNonPublicAsync(component, "OnParametersSetAsync"); + await InvokeNonPublicAsync(component, "GoToPageAsync", 2); + + Assert.Equal([("Orders", 1, 25), ("Orders", 2, 25)], service.RequestedPages); + Assert.Equal(2, ReadIntField(component, "_pageNumber")); + Assert.Equal(Enumerable.Range(126, 5).Select(i => (long)i).ToArray(), ReadRows(component).Select(row => (long)row["OrderId"]!).ToArray()); + } + + [Fact] + public async Task AddRow_StandaloneGridDoesNotSeedForeignKey() + { + var service = new TableSpecificRecordService(); + var component = new ChildDataGrid(); + SetProperty(component, "RecordService", service); + + SetProperty(component, nameof(ChildDataGrid.ChildTableName), "Orders"); + SetProperty(component, nameof(ChildDataGrid.ForeignKeyField), ""); + SetProperty(component, nameof(ChildDataGrid.ParentKeyValue), null); + SetProperty(component, nameof(ChildDataGrid.IsStandalone), true); + SetProperty(component, nameof(ChildDataGrid.ChildFormTableDefinition), CreateTableDefinition("Orders", "OrderId", "CustomerId")); + + await InvokeNonPublicAsync(component, "AddRow"); + + Dictionary createdValues = Assert.Single(service.CreatedValues); + Assert.Empty(createdValues); + } + + [Fact] + public async Task AddRow_RelatedGridSeedsForeignKey() + { + var service = new TableSpecificRecordService(); + var component = new ChildDataGrid(); + SetProperty(component, "RecordService", service); + + SetProperty(component, nameof(ChildDataGrid.ChildTableName), "Orders"); + SetProperty(component, nameof(ChildDataGrid.ForeignKeyField), "CustomerId"); + SetProperty(component, nameof(ChildDataGrid.ParentKeyValue), 7L); + SetProperty(component, nameof(ChildDataGrid.ChildFormTableDefinition), CreateTableDefinition("Orders", "OrderId", "CustomerId")); + + await InvokeNonPublicAsync(component, "AddRow"); + + Dictionary createdValues = Assert.Single(service.CreatedValues); + Assert.Equal(7L, createdValues["CustomerId"]); + } + private static FormTableDefinition CreateTableDefinition(string tableName, string primaryKeyField, string foreignKeyField) => new( tableName, @@ -49,6 +130,9 @@ private static FormTableDefinition CreateTableDefinition(string tableName, strin private static List> ReadRows(ChildDataGrid component) => GetField>>(component, "_rows"); + private static int ReadIntField(ChildDataGrid component, string fieldName) + => GetField(component, fieldName); + private static T GetField(object instance, string fieldName) { FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) @@ -63,11 +147,11 @@ private static void SetProperty(object instance, string propertyName, object? va property.SetValue(instance, value); } - private static async Task InvokeNonPublicAsync(object instance, string methodName) + private static async Task InvokeNonPublicAsync(object instance, string methodName, params object?[] args) { MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); - var task = (Task?)method.Invoke(instance, null) + var task = (Task?)method.Invoke(instance, args) ?? throw new InvalidOperationException($"Method '{methodName}' did not return a task."); await task; } @@ -75,6 +159,9 @@ private static async Task InvokeNonPublicAsync(object instance, string methodNam private sealed class TableSpecificRecordService : IFormRecordService { public List RequestedTables { get; } = []; + public List RequestedAllTables { get; } = []; + public List<(string TableName, int PageNumber, int PageSize)> RequestedPages { get; } = []; + public List> CreatedValues { get; } = []; public string GetPrimaryKeyColumn(FormTableDefinition table) => table.PrimaryKey[0]; @@ -88,13 +175,30 @@ private sealed class TableSpecificRecordService : IFormRecordService => Task.FromResult?>(null); public Task ListRecordPageAsync(FormTableDefinition table, int pageNumber, int pageSize, CancellationToken ct = default) - => throw new NotSupportedException(); + { + RequestedPages.Add((table.TableName, pageNumber, pageSize)); + + List> allRows = GetAllRows(table.TableName); + int totalCount = allRows.Count; + int totalPages = totalCount == 0 ? 1 : (int)Math.Ceiling(totalCount / (double)pageSize); + int effectivePage = Math.Min(Math.Max(1, pageNumber), totalPages); + List> pageRows = allRows + .Skip((effectivePage - 1) * pageSize) + .Take(pageSize) + .ToList(); + + return Task.FromResult(new FormRecordPage(effectivePage, pageSize, totalCount, pageRows)); + } public Task SearchRecordPageAsync(FormTableDefinition table, string searchField, string searchValue, int pageNumber, int pageSize, CancellationToken ct = default) => throw new NotSupportedException(); public Task>> ListRecordsAsync(FormTableDefinition table, CancellationToken ct = default) - => throw new NotSupportedException(); + { + RequestedAllTables.Add(table.TableName); + + return Task.FromResult(GetAllRows(table.TableName)); + } public Task GetRecordOrdinalAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default) => throw new NotSupportedException(); @@ -131,12 +235,32 @@ public Task SearchRecordPageAsync(FormTableDefinition table, str } public Task> CreateRecordAsync(FormTableDefinition table, Dictionary values, CancellationToken ct = default) - => throw new NotSupportedException(); + { + CreatedValues.Add(new Dictionary(values, StringComparer.OrdinalIgnoreCase)); + var created = new Dictionary(values, StringComparer.OrdinalIgnoreCase) + { + [table.PrimaryKey[0]] = 301L + }; + return Task.FromResult(created); + } public Task> UpdateRecordAsync(FormTableDefinition table, object pkValue, Dictionary values, CancellationToken ct = default) => throw new NotSupportedException(); public Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default) => throw new NotSupportedException(); + + private static List> GetAllRows(string tableName) + => tableName switch + { + "Orders" => Enumerable.Range(101, 30) + .Select(id => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["OrderId"] = (long)id, + ["CustomerId"] = id % 2 == 0 ? 9L : 7L + }) + .ToList(), + _ => [] + }; } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs index 400ef28a..43d894a3 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -134,6 +134,42 @@ public void UpdateActionSequences_ReplacesReusableActionSequences() Assert.Equal("Ready.", step.Message); } + [Fact] + public void SetLayoutMode_UpdatesSavedLayout() + { + var state = new DesignerState(); + state.LoadForm(CreateForm()); + + state.SetLayoutMode("elastic"); + + FormDefinition saved = state.ToFormDefinition(); + Assert.Equal("elastic", saved.Layout.LayoutMode); + } + + [Fact] + public void SetFormName_TrimsAndPersistsName() + { + var state = new DesignerState(); + state.LoadForm(CreateForm()); + + state.SetFormName(" Customer Entry "); + + FormDefinition saved = state.ToFormDefinition(); + Assert.Equal("Customer Entry", saved.Name); + } + + [Fact] + public void SetFormName_BlankNameFallsBackToUntitled() + { + var state = new DesignerState(); + state.LoadForm(CreateForm()); + + state.SetFormName(" "); + + FormDefinition saved = state.ToFormDefinition(); + Assert.Equal("Untitled Form", saved.Name); + } + [Fact] public void UpdateEventBindings_ReplacesFormLevelBindings() { From 220d92accd71c802c7e1267f08367fd07b19166d Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 17:29:28 -0700 Subject: [PATCH 18/39] Update connection string to include database file extension --- src/CSharpDB.Admin/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CSharpDB.Admin/appsettings.json b/src/CSharpDB.Admin/appsettings.json index d76efd9d..a50885f7 100644 --- a/src/CSharpDB.Admin/appsettings.json +++ b/src/CSharpDB.Admin/appsettings.json @@ -10,7 +10,7 @@ } }, "ConnectionStrings": { - "CSharpDB": "Data Source=fulfillment-hub-demo" + "CSharpDB": "Data Source=fulfillment-hub-demo.db" }, "Logging": { "LogLevel": { From 1bec2202bc81706ad214d0376d99d77359dd4388 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 18:32:11 -0700 Subject: [PATCH 19/39] Add trusted C# automation developer workflow --- samples/trusted-csharp-host/Program.cs | 257 +++++++++----- samples/trusted-csharp-host/README.md | 30 +- .../Evaluation/FormulaEvaluator.cs | 11 +- .../Services/ReportFormulaEvaluator.cs | 13 +- .../ScalarFunctionEvaluator.cs | 13 +- .../Runtime/BuiltIns/TransformSupport.cs | 11 +- .../AutomationManifestValidation.cs | 309 ++++++++++++++++ .../AutomationStubGeneration.cs | 330 ++++++++++++++++++ .../DbCallbackDiagnostics.cs | 164 +++++++++ src/CSharpDB.Primitives/DbCommands.cs | 80 ++++- src/CSharpDB.Primitives/DbFunctions.cs | 69 +++- .../AutomationManifestValidatorTests.cs | 155 ++++++++ .../AutomationStubGeneratorTests.cs | 139 ++++++++ .../CallbackDiagnosticsTests.cs | 150 ++++++++ 14 files changed, 1635 insertions(+), 96 deletions(-) create mode 100644 src/CSharpDB.Primitives/AutomationManifestValidation.cs create mode 100644 src/CSharpDB.Primitives/AutomationStubGeneration.cs create mode 100644 src/CSharpDB.Primitives/DbCallbackDiagnostics.cs create mode 100644 tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs create mode 100644 tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs create mode 100644 tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs diff --git a/samples/trusted-csharp-host/Program.cs b/samples/trusted-csharp-host/Program.cs index 7e77c1d9..57f309f9 100644 --- a/samples/trusted-csharp-host/Program.cs +++ b/samples/trusted-csharp-host/Program.cs @@ -7,45 +7,18 @@ Console.WriteLine("CSharpDB trusted C# host sample"); Console.WriteLine(); -DatabaseOptions databaseOptions = new DatabaseOptions() - .ConfigureFunctions(functions => - { - functions.AddScalar( - "Slugify", - arity: 1, - options: new DbScalarFunctionOptions( - ReturnType: DbType.Text, - IsDeterministic: true, - NullPropagating: true), - invoke: static (_, args) => - DbValue.FromText(Slugify(args[0].AsText))); - }); - -await using Database db = await Database.OpenInMemoryAsync(databaseOptions); - -await db.ExecuteAsync(""" - CREATE TABLE articles ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - slug TEXT - ); - """); - -await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello From VS Code', Slugify('Hello From VS Code'));"); -await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Trusted CSharpDB Callbacks', Slugify('Trusted CSharpDB Callbacks'));"); - -Console.WriteLine("SQL scalar function result:"); -await using var rows = await db.ExecuteAsync(""" - SELECT id, title, slug - FROM articles - ORDER BY id; - """); - -while (await rows.MoveNextAsync()) +DbFunctionRegistry functions = DbFunctionRegistry.Create(builder => { - IReadOnlyList row = rows.Current; - Console.WriteLine($" {row[0].AsInteger}: {row[1].AsText} -> {row[2].AsText}"); -} + builder.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => + DbValue.FromText(Slugify(args[0].AsText))); +}); List auditLog = []; DbCommandRegistry commands = DbCommandRegistry.Create(builder => @@ -69,54 +42,176 @@ FROM articles }); }); -FormDefinition form = new( - "customers-entry", - "Customers Entry", - "Customers", - DefinitionVersion: 1, - SourceSchemaSignature: "sample:customers:v1", - Layout: new LayoutDefinition("absolute", 8, SnapToGrid: true, Breakpoints: []), - Controls: [], - EventBindings: - [ - new FormEventBinding( - FormEventKind.BeforeInsert, - CommandName: string.Empty, - ActionSequence: new DbActionSequence( - [ - new DbActionStep( - DbActionKind.SetFieldValue, - Target: "Status", - Value: "Ready"), - new DbActionStep( - DbActionKind.RunCommand, - CommandName: "AuditCustomerChange", - Arguments: new Dictionary - { - ["source"] = "trusted-csharp-host-sample", - }), - ], - Name: "PrepareCustomerInsert")), - ]); +FormDefinition form = FormAutomationMetadata.NormalizeForExport(CreateCustomerEntryForm()); +DbAutomationMetadata automation = form.Automation + ?? throw new InvalidOperationException("The sample form should export automation metadata."); -var record = new Dictionary(StringComparer.OrdinalIgnoreCase) -{ - ["Id"] = 42L, - ["Name"] = "Ada Lovelace", -}; +PrintAutomationMetadata(automation); +ValidateAutomationMetadata(automation, functions, commands); +PrintGeneratedStub(automation); -var dispatcher = new DefaultFormEventDispatcher(commands); -FormEventDispatchResult dispatchResult = await dispatcher.DispatchAsync(form, FormEventKind.BeforeInsert, record); +await RunSqlScalarFunctionDemoAsync(functions); +await RunAdminFormsAutomationDemoAsync(form, commands, auditLog); Console.WriteLine(); -Console.WriteLine("Admin Forms action sequence result:"); -Console.WriteLine($" Succeeded: {dispatchResult.Succeeded}"); -Console.WriteLine($" Status field: {record["Status"]}"); -foreach (string auditEntry in auditLog) - Console.WriteLine($" Audit: {auditEntry}"); +Console.WriteLine("Set breakpoints inside Slugify or AuditCustomerChange, then run this sample from VS Code."); -Console.WriteLine(); -Console.WriteLine("Set a breakpoint inside Slugify or AuditCustomerChange, then run this sample from VS Code."); +static async Task RunSqlScalarFunctionDemoAsync(DbFunctionRegistry functions) +{ + await using Database db = await Database.OpenInMemoryAsync(new DatabaseOptions + { + Functions = functions, + }); + + await db.ExecuteAsync(""" + CREATE TABLE articles ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT + ); + """); + + await db.ExecuteAsync("INSERT INTO articles VALUES (1, 'Hello From VS Code', Slugify('Hello From VS Code'));"); + await db.ExecuteAsync("INSERT INTO articles VALUES (2, 'Trusted CSharpDB Callbacks', Slugify('Trusted CSharpDB Callbacks'));"); + + Console.WriteLine(); + Console.WriteLine("SQL scalar function result:"); + await using var rows = await db.ExecuteAsync(""" + SELECT id, title, slug + FROM articles + ORDER BY id; + """); + + while (await rows.MoveNextAsync()) + { + IReadOnlyList row = rows.Current; + Console.WriteLine($" {row[0].AsInteger}: {row[1].AsText} -> {row[2].AsText}"); + } +} + +static async Task RunAdminFormsAutomationDemoAsync( + FormDefinition form, + DbCommandRegistry commands, + IReadOnlyList auditLog) +{ + var record = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Id"] = 42L, + ["Name"] = "Ada Lovelace", + }; + + var dispatcher = new DefaultFormEventDispatcher(commands); + FormEventDispatchResult dispatchResult = await dispatcher.DispatchAsync(form, FormEventKind.BeforeInsert, record); + + Console.WriteLine(); + Console.WriteLine("Admin Forms action sequence result:"); + Console.WriteLine($" Succeeded: {dispatchResult.Succeeded}"); + Console.WriteLine($" Status field: {record["Status"]}"); + foreach (string auditEntry in auditLog) + Console.WriteLine($" Audit: {auditEntry}"); +} + +static void PrintAutomationMetadata(DbAutomationMetadata automation) +{ + Console.WriteLine("Exported automation metadata:"); + foreach (DbAutomationScalarFunctionReference function in automation.ScalarFunctions ?? []) + { + Console.WriteLine( + $" scalar {function.Name}/{function.Arity} from {function.Surface}:{function.Location}"); + } + + foreach (DbAutomationCommandReference command in automation.Commands ?? []) + Console.WriteLine($" command {command.Name} from {command.Surface}:{command.Location}"); +} + +static void ValidateAutomationMetadata( + DbAutomationMetadata automation, + DbFunctionRegistry functions, + DbCommandRegistry commands) +{ + AutomationValidationResult result = AutomationManifestValidator.Validate( + automation, + functions, + commands, + new AutomationManifestValidationOptions(RequireMetadata: true)); + + Console.WriteLine(); + Console.WriteLine("Automation validation:"); + if (result.Succeeded) + { + Console.WriteLine(" All referenced callbacks are registered."); + return; + } + + foreach (AutomationValidationIssue issue in result.Issues) + Console.WriteLine($" {issue.Severity}: {issue.Message}"); +} + +static void PrintGeneratedStub(DbAutomationMetadata automation) +{ + string source = AutomationStubGenerator.GenerateCSharp( + automation, + new AutomationStubGenerationOptions( + Namespace: "MyApp.CSharpDbAutomation", + ClassName: "CSharpDbAutomationRegistration")); + + Console.WriteLine(); + Console.WriteLine("Starter C# registration stub:"); + Console.WriteLine(source); +} + +static FormDefinition CreateCustomerEntryForm() + => new( + "customers-entry", + "Customers Entry", + "Customers", + DefinitionVersion: 1, + SourceSchemaSignature: "sample:customers:v1", + Layout: new LayoutDefinition("absolute", 8, SnapToGrid: true, Breakpoints: []), + Controls: + [ + new ControlDefinition( + "slug-preview", + "computed", + new Rect(24, 24, 240, 32), + Binding: null, + Props: new PropertyBag(new Dictionary + { + ["formula"] = "=Slugify(Name)", + }), + ValidationOverride: null), + ], + EventBindings: + [ + new FormEventBinding( + FormEventKind.BeforeInsert, + CommandName: string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.SetFieldValue, + Target: "Status", + Value: "Ready"), + new DbActionStep( + DbActionKind.RunActionSequence, + SequenceName: "ReusableCustomerAudit", + Arguments: new Dictionary + { + ["source"] = "trusted-csharp-host-sample", + }), + ], + Name: "PrepareCustomerInsert")), + ], + ActionSequences: + [ + new DbActionSequence( + [ + new DbActionStep( + DbActionKind.RunCommand, + CommandName: "AuditCustomerChange"), + ], + Name: "ReusableCustomerAudit"), + ]); static string Slugify(string text) { diff --git a/samples/trusted-csharp-host/README.md b/samples/trusted-csharp-host/README.md index 77852cc9..59d1788e 100644 --- a/samples/trusted-csharp-host/README.md +++ b/samples/trusted-csharp-host/README.md @@ -6,10 +6,15 @@ registered callbacks, and run or debug the host process. It demonstrates: -- registering a trusted scalar function with `DatabaseOptions.ConfigureFunctions` +- registering a trusted scalar function with `DbFunctionRegistry` - calling that function from SQL - registering a trusted command with `DbCommandRegistry` -- running an Admin Forms action sequence that sets a field and calls the command +- exporting Admin Forms automation metadata +- validating automation metadata against registered callbacks +- generating starter C# registration stubs from automation metadata +- running an Admin Forms action sequence that sets a field +- invoking a reusable named Admin Forms action sequence that calls the command +- inspecting callback arguments and metadata in console output The sample keeps the important runtime boundary visible: C# callback bodies live in the host project. The database/form metadata stores names and action data @@ -20,7 +25,10 @@ only. 1. Open `samples/trusted-csharp-host` in VS Code. 2. Install the C# Dev Kit or C# extension if VS Code prompts for it. 3. Press `F5`, or run the task `run trusted C# host sample`. -4. Put breakpoints in `Slugify` or the `AuditCustomerChange` command callback. +4. Watch the sample print exported automation metadata. +5. Watch validation confirm that referenced callbacks are registered. +6. Inspect the generated starter C# registration stub. +7. Put breakpoints in `Slugify` or the `AuditCustomerChange` command callback. ## Run From Terminal @@ -28,11 +36,21 @@ only. dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj ``` -Expected output includes slug values from SQL and an audit entry from the form -action sequence. +Expected output includes: + +- exported callback metadata and locations +- validation status +- generated starter registration code +- slug values from SQL +- an audit entry from the reusable form action sequence + +The audit entry prints callback metadata such as the form event and reusable +action sequence name, along with callback arguments passed from the form record +and action sequence. ## Files -- `Program.cs` contains the host registration code and runnable demo. +- `Program.cs` contains the host registration code, metadata validation, stub + generation, and runnable demo. - `.vscode/launch.json` launches the sample under the debugger. - `.vscode/tasks.json` builds and runs the sample from VS Code tasks. diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs index 4eb51c02..5fb1a4f2 100644 --- a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs @@ -299,7 +299,7 @@ public Parser(string input, Func fieldResolver, DbFunctionRegis try { - DbValue value = definition.Invoke(arguments.ToArray()); + DbValue value = definition.Invoke(arguments.ToArray(), CreateFormCallbackMetadata(functionName)); return value.Type switch { DbType.Integer => value.AsInteger, @@ -319,4 +319,13 @@ private void SkipWhitespace() Position++; } } + + private static IReadOnlyDictionary? CreateFormCallbackMetadata(string functionName) + => DbCallbackDiagnostics.IsInvocationEnabled + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["location"] = $"formulas.functions.{functionName}", + } + : null; } diff --git a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs index fbd36af0..06da94ae 100644 --- a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs @@ -355,7 +355,7 @@ public Parser(string input, Func fieldResolver, DbFunctionRegis try { - DbValue value = definition.Invoke(arguments.ToArray()); + DbValue value = definition.Invoke(arguments.ToArray(), CreateReportCallbackMetadata(functionName)); return value.Type switch { DbType.Integer => value.AsInteger, @@ -405,7 +405,7 @@ private static bool TryEvaluateFunctionCall( return true; } - value = definition.Invoke(arguments); + value = definition.Invoke(arguments, CreateReportCallbackMetadata(name)); return true; } @@ -519,4 +519,13 @@ private static bool IsIdentifier(string value) return true; } + + private static IReadOnlyDictionary? CreateReportCallbackMetadata(string functionName) + => DbCallbackDiagnostics.IsInvocationEnabled + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminReports", + ["location"] = $"expressions.functions.{functionName}", + } + : null; } diff --git a/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs b/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs index 41d7a535..aecf8444 100644 --- a/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs +++ b/src/CSharpDB.Execution/ScalarFunctionEvaluator.cs @@ -78,7 +78,7 @@ private static DbValue EvaluateUserFunction( try { - return definition.Invoke(arguments); + return definition.Invoke(arguments, CreateSqlCallbackMetadata(func.FunctionName)); } catch (Exception ex) { @@ -109,7 +109,7 @@ private static DbValue EvaluateUserFunction( try { - return definition.Invoke(arguments); + return definition.Invoke(arguments, CreateSqlCallbackMetadata(func.FunctionName)); } catch (Exception ex) { @@ -129,4 +129,13 @@ private static DbValue EvaluateUserFunction( DbType.Blob => $"[{value.AsBlob.Length} bytes]", _ => throw new CSharpDbException(ErrorCode.Unknown, $"Unsupported DbValue type '{value.Type}'."), }; + + private static IReadOnlyDictionary? CreateSqlCallbackMetadata(string functionName) + => DbCallbackDiagnostics.IsInvocationEnabled + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "SQL", + ["location"] = $"functions.{functionName}", + } + : null; } diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs index 79239ce6..6ed5a1d8 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs @@ -204,7 +204,7 @@ private static bool TryEvaluateFunctionCall( try { - value = FromDbValue(definition.Invoke(arguments)); + value = FromDbValue(definition.Invoke(arguments, CreatePipelineCallbackMetadata(name))); return true; } catch (Exception ex) @@ -332,4 +332,13 @@ private static int Compare(object? left, object? right) return string.CompareOrdinal(left.ToString(), right.ToString()); } + + private static IReadOnlyDictionary? CreatePipelineCallbackMetadata(string functionName) + => DbCallbackDiagnostics.IsInvocationEnabled + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "Pipelines", + ["location"] = $"transforms.functions.{functionName}", + } + : null; } diff --git a/src/CSharpDB.Primitives/AutomationManifestValidation.cs b/src/CSharpDB.Primitives/AutomationManifestValidation.cs new file mode 100644 index 00000000..9bb41101 --- /dev/null +++ b/src/CSharpDB.Primitives/AutomationManifestValidation.cs @@ -0,0 +1,309 @@ +namespace CSharpDB.Primitives; + +public sealed record AutomationValidationResult( + bool Succeeded, + IReadOnlyList Issues); + +public sealed record AutomationValidationIssue( + AutomationValidationSeverity Severity, + AutomationCallbackKind CallbackKind, + string Name, + string Surface, + string Location, + string Message, + int? ExpectedArity = null); + +public sealed record AutomationManifestValidationOptions(bool RequireMetadata = false) +{ + public static AutomationManifestValidationOptions Default { get; } = new(); +} + +public enum AutomationValidationSeverity +{ + Warning, + Error, +} + +public enum AutomationCallbackKind +{ + Unknown, + ScalarFunction, + Command, +} + +public static class AutomationManifestValidator +{ + private const string UnknownSurface = "unknown"; + private const string UnknownLocation = "$"; + + public static AutomationValidationResult Validate( + DbAutomationMetadata? metadata, + DbFunctionRegistry functions, + DbCommandRegistry commands) + => Validate(metadata, functions, commands, AutomationManifestValidationOptions.Default); + + public static AutomationValidationResult Validate( + DbAutomationMetadata? metadata, + DbFunctionRegistry functions, + DbCommandRegistry commands, + AutomationManifestValidationOptions? options) + { + ArgumentNullException.ThrowIfNull(functions); + ArgumentNullException.ThrowIfNull(commands); + + options ??= AutomationManifestValidationOptions.Default; + var issues = new List(); + + if (metadata is null) + { + if (options.RequireMetadata) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.Unknown, + string.Empty, + UnknownSurface, + UnknownLocation, + "Automation metadata is missing. Export or build automation metadata before validating callbacks.")); + } + + return CreateResult(issues); + } + + AddDuplicateCommandIssues(metadata.Commands, issues); + AddDuplicateScalarFunctionIssues(metadata.ScalarFunctions, issues); + ValidateCommands(metadata.Commands, commands, issues); + ValidateScalarFunctions(metadata.ScalarFunctions, functions, issues); + + return CreateResult(issues); + } + + private static void AddDuplicateCommandIssues( + IReadOnlyList? references, + List issues) + { + if (references is null || references.Count == 0) + return; + + var occurrences = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DbAutomationCommandReference reference in references) + { + string name = NormalizeName(reference.Name); + string surface = NormalizeSurface(reference.Surface); + string location = NormalizeLocation(reference.Location); + string key = CreateKey(name, surface, location); + + occurrences[key] = occurrences.TryGetValue(key, out DuplicateOccurrence? occurrence) + ? occurrence.Increment() + : new DuplicateOccurrence(name, surface, location, Count: 1, Arity: null); + } + + foreach (DuplicateOccurrence occurrence in occurrences.Values + .Where(static occurrence => occurrence.Count > 1) + .OrderBy(static occurrence => occurrence.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static occurrence => occurrence.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static occurrence => occurrence.Location, StringComparer.OrdinalIgnoreCase)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Warning, + AutomationCallbackKind.Command, + occurrence.Name, + occurrence.Surface, + occurrence.Location, + $"Command '{occurrence.Name}' is referenced {occurrence.Count} times at {occurrence.Surface}:{occurrence.Location}. Remove duplicate metadata entries.")); + } + } + + private static void AddDuplicateScalarFunctionIssues( + IReadOnlyList? references, + List issues) + { + if (references is null || references.Count == 0) + return; + + var occurrences = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DbAutomationScalarFunctionReference reference in references) + { + string name = NormalizeName(reference.Name); + string surface = NormalizeSurface(reference.Surface); + string location = NormalizeLocation(reference.Location); + string key = CreateKey(name, reference.Arity.ToString(System.Globalization.CultureInfo.InvariantCulture), surface, location); + + occurrences[key] = occurrences.TryGetValue(key, out DuplicateOccurrence? occurrence) + ? occurrence.Increment() + : new DuplicateOccurrence(name, surface, location, Count: 1, reference.Arity); + } + + foreach (DuplicateOccurrence occurrence in occurrences.Values + .Where(static occurrence => occurrence.Count > 1) + .OrderBy(static occurrence => occurrence.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static occurrence => occurrence.Arity) + .ThenBy(static occurrence => occurrence.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static occurrence => occurrence.Location, StringComparer.OrdinalIgnoreCase)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Warning, + AutomationCallbackKind.ScalarFunction, + occurrence.Name, + occurrence.Surface, + occurrence.Location, + $"Scalar function '{occurrence.Name}' with arity {occurrence.Arity} is referenced {occurrence.Count} times at {occurrence.Surface}:{occurrence.Location}. Remove duplicate metadata entries.", + occurrence.Arity)); + } + } + + private static void ValidateCommands( + IReadOnlyList? references, + DbCommandRegistry commands, + List issues) + { + if (references is null || references.Count == 0) + return; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (DbAutomationCommandReference reference in references) + { + string name = NormalizeName(reference.Name); + string surface = NormalizeSurface(reference.Surface); + string location = NormalizeLocation(reference.Location); + if (!seen.Add(CreateKey(name, surface, location))) + continue; + + if (string.IsNullOrWhiteSpace(name)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.Command, + name, + surface, + location, + $"Command reference at {surface}:{location} has no command name.")); + continue; + } + + if (!commands.ContainsCommandName(name)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.Command, + name, + surface, + location, + $"Command '{name}' is referenced by {surface} at {location}, but it is not registered in the host command registry.")); + } + } + } + + private static void ValidateScalarFunctions( + IReadOnlyList? references, + DbFunctionRegistry functions, + List issues) + { + if (references is null || references.Count == 0) + return; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (DbAutomationScalarFunctionReference reference in references) + { + string name = NormalizeName(reference.Name); + string surface = NormalizeSurface(reference.Surface); + string location = NormalizeLocation(reference.Location); + string key = CreateKey(name, reference.Arity.ToString(System.Globalization.CultureInfo.InvariantCulture), surface, location); + if (!seen.Add(key)) + continue; + + if (string.IsNullOrWhiteSpace(name)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.ScalarFunction, + name, + surface, + location, + $"Scalar function reference at {surface}:{location} has no function name.", + reference.Arity)); + continue; + } + + if (reference.Arity < 0) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.ScalarFunction, + name, + surface, + location, + $"Scalar function '{name}' is referenced by {surface} at {location} with invalid arity {reference.Arity}. Arity must be zero or greater.", + reference.Arity)); + continue; + } + + if (functions.TryGetScalar(name, reference.Arity, out _)) + continue; + + if (functions.ContainsScalarName(name)) + { + string registeredArities = FormatRegisteredArities(functions, name); + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.ScalarFunction, + name, + surface, + location, + $"Scalar function '{name}' is referenced by {surface} at {location} with arity {reference.Arity}, but the host registry has {registeredArities}. Register it with arity {reference.Arity} or update the metadata expression.", + reference.Arity)); + continue; + } + + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.ScalarFunction, + name, + surface, + location, + $"Scalar function '{name}' is referenced by {surface} at {location} with arity {reference.Arity}, but it is not registered in the host function registry.", + reference.Arity)); + } + } + + private static AutomationValidationResult CreateResult(IReadOnlyList issues) + => new( + !issues.Any(static issue => issue.Severity == AutomationValidationSeverity.Error), + issues.ToArray()); + + private static string FormatRegisteredArities(DbFunctionRegistry functions, string name) + { + int[] arities = functions.ScalarFunctions + .Where(function => string.Equals(function.Name, name, StringComparison.OrdinalIgnoreCase)) + .Select(static function => function.Arity) + .Order() + .ToArray(); + + return arities.Length == 1 + ? $"arity {arities[0]}" + : $"arities {string.Join(", ", arities)}"; + } + + private static string NormalizeName(string? value) + => value?.Trim() ?? string.Empty; + + private static string NormalizeSurface(string? value) + => string.IsNullOrWhiteSpace(value) ? UnknownSurface : value.Trim(); + + private static string NormalizeLocation(string? value) + => string.IsNullOrWhiteSpace(value) ? UnknownLocation : value.Trim(); + + private static string CreateKey(params string[] parts) + => string.Join('\u001f', parts); + + private sealed record DuplicateOccurrence( + string Name, + string Surface, + string Location, + int Count, + int? Arity) + { + public DuplicateOccurrence Increment() + => this with { Count = Count + 1 }; + } +} diff --git a/src/CSharpDB.Primitives/AutomationStubGeneration.cs b/src/CSharpDB.Primitives/AutomationStubGeneration.cs new file mode 100644 index 00000000..7c8e92f0 --- /dev/null +++ b/src/CSharpDB.Primitives/AutomationStubGeneration.cs @@ -0,0 +1,330 @@ +using System.Globalization; +using System.Text; + +namespace CSharpDB.Primitives; + +public sealed record AutomationStubGenerationOptions( + string Namespace = "CSharpDbAutomation", + string ClassName = "CSharpDbAutomationRegistration", + string MethodName = "Register"); + +public static class AutomationStubGenerator +{ + public static string GenerateCSharp( + DbAutomationMetadata metadata, + AutomationStubGenerationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(metadata); + + options ??= new AutomationStubGenerationOptions(); + ValidateOptions(options); + + CallbackGroup[] scalarFunctions = GetScalarFunctionGroups(metadata); + CallbackGroup[] commands = GetCommandGroups(metadata); + + var source = new StringBuilder(); + source.AppendLine("using System;"); + source.AppendLine("using System.Threading.Tasks;"); + source.AppendLine("using CSharpDB.Primitives;"); + source.AppendLine(); + source.Append("namespace "); + source.Append(options.Namespace); + source.AppendLine(";"); + source.AppendLine(); + source.Append("public static class "); + source.AppendLine(options.ClassName); + source.AppendLine("{"); + source.Append(" public static void "); + source.Append(options.MethodName); + source.AppendLine("(DbFunctionRegistryBuilder functions, DbCommandRegistryBuilder commands)"); + source.AppendLine(" {"); + source.AppendLine(" ArgumentNullException.ThrowIfNull(functions);"); + source.AppendLine(" ArgumentNullException.ThrowIfNull(commands);"); + + bool wroteRegistration = false; + foreach (CallbackGroup function in scalarFunctions) + { + source.AppendLine(); + AppendScalarFunction(source, function); + wroteRegistration = true; + } + + foreach (CallbackGroup command in commands) + { + source.AppendLine(); + AppendCommand(source, command); + wroteRegistration = true; + } + + if (!wroteRegistration) + { + source.AppendLine(); + source.AppendLine(" // No trusted C# callbacks were found in the automation metadata."); + } + + source.AppendLine(" }"); + source.AppendLine("}"); + + return source.ToString(); + } + + private static void AppendScalarFunction(StringBuilder source, CallbackGroup function) + { + source.AppendLine(" functions.AddScalar("); + source.Append(" "); + source.Append(ToStringLiteral(function.Name)); + source.AppendLine(","); + source.Append(" arity: "); + source.Append(function.Arity!.Value.ToString(CultureInfo.InvariantCulture)); + source.AppendLine(","); + source.AppendLine(" options: new DbScalarFunctionOptions(DbType.Text),"); + source.AppendLine(" invoke: static (context, args) =>"); + source.AppendLine(" {"); + AppendReferenceComments(source, function); + source.Append(" throw new NotImplementedException("); + source.Append(ToStringLiteral($"Implement trusted scalar function '{function.Name}'.")); + source.AppendLine(");"); + source.AppendLine(" });"); + } + + private static void AppendCommand(StringBuilder source, CallbackGroup command) + { + source.AppendLine(" commands.AddAsyncCommand("); + source.Append(" "); + source.Append(ToStringLiteral(command.Name)); + source.AppendLine(","); + source.AppendLine(" static async (context, ct) =>"); + source.AppendLine(" {"); + AppendReferenceComments(source, command); + source.AppendLine(" await Task.CompletedTask;"); + source.Append(" throw new NotImplementedException("); + source.Append(ToStringLiteral($"Implement trusted command '{command.Name}'.")); + source.AppendLine(");"); + source.AppendLine(" });"); + } + + private static void AppendReferenceComments(StringBuilder source, CallbackGroup callback) + { + source.AppendLine(" // References:"); + foreach (CallbackLocation location in callback.Locations) + { + source.Append(" // - "); + source.Append(SanitizeComment(location.Surface)); + source.Append(": "); + source.AppendLine(SanitizeComment(location.Location)); + } + } + + private static CallbackGroup[] GetScalarFunctionGroups(DbAutomationMetadata metadata) + => (metadata.ScalarFunctions ?? []) + .Where(static reference => !string.IsNullOrWhiteSpace(reference.Name)) + .GroupBy( + static reference => $"{reference.Name.Trim()}|{reference.Arity}", + StringComparer.OrdinalIgnoreCase) + .Select(static group => + { + DbAutomationScalarFunctionReference first = group.First(); + return new CallbackGroup( + first.Name.Trim(), + first.Arity, + GetLocations(group.Select(static reference => new CallbackLocation(reference.Surface, reference.Location)))); + }) + .OrderBy(static group => group.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static group => group.Arity) + .ToArray(); + + private static CallbackGroup[] GetCommandGroups(DbAutomationMetadata metadata) + => (metadata.Commands ?? []) + .Where(static reference => !string.IsNullOrWhiteSpace(reference.Name)) + .GroupBy( + static reference => reference.Name.Trim(), + StringComparer.OrdinalIgnoreCase) + .Select(static group => + { + DbAutomationCommandReference first = group.First(); + return new CallbackGroup( + first.Name.Trim(), + Arity: null, + GetLocations(group.Select(static reference => new CallbackLocation(reference.Surface, reference.Location)))); + }) + .OrderBy(static group => group.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static CallbackLocation[] GetLocations(IEnumerable locations) + => locations + .Select(static location => new CallbackLocation( + NormalizeCommentValue(location.Surface, "unknown"), + NormalizeCommentValue(location.Location, "$"))) + .GroupBy( + static location => $"{location.Surface}|{location.Location}", + StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .OrderBy(static location => location.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static location => location.Location, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static string ToStringLiteral(string value) + { + var literal = new StringBuilder(value.Length + 2); + literal.Append('"'); + foreach (char ch in value) + { + literal.Append(ch switch + { + '\\' => "\\\\", + '"' => "\\\"", + '\0' => "\\0", + '\a' => "\\a", + '\b' => "\\b", + '\f' => "\\f", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '\v' => "\\v", + _ when char.IsControl(ch) => "\\u" + ((int)ch).ToString("X4", CultureInfo.InvariantCulture), + _ => ch.ToString(), + }); + } + + literal.Append('"'); + return literal.ToString(); + } + + private static string SanitizeComment(string value) + { + var sanitized = new StringBuilder(value.Length); + foreach (char ch in value) + sanitized.Append(char.IsControl(ch) ? ' ' : ch); + + return sanitized.ToString().Trim(); + } + + private static string NormalizeCommentValue(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + + private static void ValidateOptions(AutomationStubGenerationOptions options) + { + ValidateNamespace(options.Namespace); + ValidateIdentifier(options.ClassName, nameof(options.ClassName)); + ValidateIdentifier(options.MethodName, nameof(options.MethodName)); + } + + private static void ValidateNamespace(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + + foreach (string part in value.Split('.')) + ValidateIdentifier(part, nameof(AutomationStubGenerationOptions.Namespace)); + } + + private static void ValidateIdentifier(string value, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName); + + if (s_csharpKeywords.Contains(value)) + throw new ArgumentException($"'{value}' is a reserved C# keyword.", parameterName); + + if (!IsIdentifierStart(value[0])) + throw new ArgumentException($"'{value}' is not a valid C# identifier.", parameterName); + + for (int i = 1; i < value.Length; i++) + { + if (!IsIdentifierPart(value[i])) + throw new ArgumentException($"'{value}' is not a valid C# identifier.", parameterName); + } + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + + private static bool IsIdentifierPart(char value) + => char.IsLetterOrDigit(value) || value == '_'; + + private static readonly HashSet s_csharpKeywords = new(StringComparer.Ordinal) + { + "abstract", + "as", + "base", + "bool", + "break", + "byte", + "case", + "catch", + "char", + "checked", + "class", + "const", + "continue", + "decimal", + "default", + "delegate", + "do", + "double", + "else", + "enum", + "event", + "explicit", + "extern", + "false", + "finally", + "fixed", + "float", + "for", + "foreach", + "goto", + "if", + "implicit", + "in", + "int", + "interface", + "internal", + "is", + "lock", + "long", + "namespace", + "new", + "null", + "object", + "operator", + "out", + "override", + "params", + "private", + "protected", + "public", + "readonly", + "ref", + "return", + "sbyte", + "sealed", + "short", + "sizeof", + "stackalloc", + "static", + "string", + "struct", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "uint", + "ulong", + "unchecked", + "unsafe", + "ushort", + "using", + "virtual", + "void", + "volatile", + "while", + }; + + private sealed record CallbackGroup( + string Name, + int? Arity, + IReadOnlyList Locations); + + private sealed record CallbackLocation(string Surface, string Location); +} diff --git a/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs b/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs new file mode 100644 index 00000000..42a32bc7 --- /dev/null +++ b/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs @@ -0,0 +1,164 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace CSharpDB.Primitives; + +public sealed record DbCallbackInvocationDiagnostic( + AutomationCallbackKind CallbackKind, + string Name, + int? Arity, + string? Surface, + string? Location, + string? EventName, + TimeSpan Elapsed, + bool Succeeded, + bool TimedOut, + bool Canceled, + string? ResultMessage, + string? ExceptionMessage, + IReadOnlyDictionary Metadata); + +public static class DbCallbackDiagnostics +{ + public const string ListenerName = "CSharpDB.TrustedCallbacks"; + public const string InvocationEventName = "CSharpDB.TrustedCallbacks.Invocation"; + + public static DiagnosticListener Listener { get; } = new(ListenerName); + + public static bool IsInvocationEnabled + => Listener.IsEnabled(InvocationEventName); + + internal static long GetTimestamp() + => Stopwatch.GetTimestamp(); + + internal static TimeSpan GetElapsedTime(long startingTimestamp) + => Stopwatch.GetElapsedTime(startingTimestamp); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Callback diagnostics are emitted only for subscribed hosts; the strongly typed event payload is part of the public diagnostics contract.")] + internal static void WriteScalarInvocation( + string name, + int arity, + IReadOnlyDictionary? metadata, + TimeSpan elapsed, + bool succeeded, + bool canceled, + string? exceptionMessage) + { + if (!IsInvocationEnabled) + return; + + IReadOnlyDictionary metadataSnapshot = CopyMetadata(metadata); + Listener.Write( + InvocationEventName, + new DbCallbackInvocationDiagnostic( + AutomationCallbackKind.ScalarFunction, + name, + arity, + ReadMetadata(metadataSnapshot, "surface"), + BuildLocation(metadataSnapshot), + ReadMetadata(metadataSnapshot, "event"), + elapsed, + succeeded, + TimedOut: false, + canceled, + ResultMessage: null, + exceptionMessage, + metadataSnapshot)); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Callback diagnostics are emitted only for subscribed hosts; the strongly typed event payload is part of the public diagnostics contract.")] + internal static void WriteCommandInvocation( + string name, + IReadOnlyDictionary? metadata, + TimeSpan elapsed, + bool succeeded, + bool timedOut, + bool canceled, + string? resultMessage, + string? exceptionMessage) + { + if (!IsInvocationEnabled) + return; + + IReadOnlyDictionary metadataSnapshot = CopyMetadata(metadata); + Listener.Write( + InvocationEventName, + new DbCallbackInvocationDiagnostic( + AutomationCallbackKind.Command, + name, + Arity: null, + ReadMetadata(metadataSnapshot, "surface"), + BuildLocation(metadataSnapshot), + ReadMetadata(metadataSnapshot, "event"), + elapsed, + succeeded, + timedOut, + canceled, + resultMessage, + exceptionMessage, + metadataSnapshot)); + } + + private static IReadOnlyDictionary CopyMetadata( + IReadOnlyDictionary? metadata) + { + if (metadata is null || metadata.Count == 0) + return EmptyStringDictionary.Instance; + + return new Dictionary(metadata, StringComparer.OrdinalIgnoreCase); + } + + private static string? BuildLocation(IReadOnlyDictionary metadata) + { + if (TryReadMetadata(metadata, "location", out string? location)) + return location; + + string? eventName = ReadMetadata(metadata, "event"); + string? actionSequence = ReadMetadata(metadata, "actionSequence"); + string? actionStep = ReadMetadata(metadata, "actionStep"); + if (!string.IsNullOrWhiteSpace(actionSequence) && !string.IsNullOrWhiteSpace(actionStep)) + return $"actionSequences.{actionSequence}.steps[{actionStep}]"; + + string? controlId = ReadMetadata(metadata, "controlId"); + if (!string.IsNullOrWhiteSpace(controlId) && !string.IsNullOrWhiteSpace(eventName)) + return $"controls.{controlId}.events.{eventName}"; + + if (!string.IsNullOrWhiteSpace(eventName)) + return $"events.{eventName}"; + + if (!string.IsNullOrWhiteSpace(actionStep)) + return $"action.steps[{actionStep}]"; + + return null; + } + + private static string? ReadMetadata(IReadOnlyDictionary metadata, string key) + => TryReadMetadata(metadata, key, out string? value) ? value : null; + + private static bool TryReadMetadata( + IReadOnlyDictionary metadata, + string key, + out string? value) + { + if (metadata.TryGetValue(key, out string? raw) && !string.IsNullOrWhiteSpace(raw)) + { + value = raw; + return true; + } + + value = null; + return false; + } + + private static class EmptyStringDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CSharpDB.Primitives/DbCommands.cs b/src/CSharpDB.Primitives/DbCommands.cs index 9070d8e7..a2582641 100644 --- a/src/CSharpDB.Primitives/DbCommands.cs +++ b/src/CSharpDB.Primitives/DbCommands.cs @@ -31,6 +31,7 @@ public static DbCommandResult Failure(string message, DbValue value = default) public sealed class DbCommandDefinition { + private const string CommandTimeoutDataKey = "CSharpDB.CommandTimedOut"; private readonly DbCommandDelegate _invoke; internal DbCommandDefinition( @@ -56,11 +57,81 @@ public ValueTask InvokeAsync( Name, arguments ?? EmptyDbValueDictionary.Instance, metadata ?? EmptyStringDictionary.Instance); + if (DbCallbackDiagnostics.IsInvocationEnabled) + return InvokeWithDiagnosticsAsync(context, ct); + + return InvokeCoreAsync(context, ct); + } + + private ValueTask InvokeCoreAsync( + DbCommandContext context, + CancellationToken ct) + { return Options.Timeout is { } timeout ? InvokeWithTimeoutAsync(context, timeout, ct) : _invoke(context, ct); } + private async ValueTask InvokeWithDiagnosticsAsync( + DbCommandContext context, + CancellationToken ct) + { + long started = DbCallbackDiagnostics.GetTimestamp(); + try + { + DbCommandResult result = await InvokeCoreAsync(context, ct).ConfigureAwait(false); + DbCallbackDiagnostics.WriteCommandInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: result.Succeeded, + timedOut: false, + canceled: false, + result.Message, + exceptionMessage: null); + return result; + } + catch (TimeoutException ex) when (IsCommandTimeoutException(ex)) + { + DbCallbackDiagnostics.WriteCommandInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + timedOut: true, + canceled: false, + resultMessage: null, + ex.Message); + throw; + } + catch (OperationCanceledException ex) + { + DbCallbackDiagnostics.WriteCommandInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + timedOut: false, + canceled: true, + resultMessage: null, + ex.Message); + throw; + } + catch (Exception ex) + { + DbCallbackDiagnostics.WriteCommandInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + timedOut: false, + canceled: false, + resultMessage: null, + ex.Message); + throw; + } + } + private async ValueTask InvokeWithTimeoutAsync( DbCommandContext context, TimeSpan timeout, @@ -84,7 +155,14 @@ private async ValueTask InvokeWithTimeoutAsync( } private TimeoutException CreateTimeoutException(TimeSpan timeout, Exception inner) - => new($"Command '{Name}' timed out after {FormatTimeout(timeout)}.", inner); + { + var exception = new TimeoutException($"Command '{Name}' timed out after {FormatTimeout(timeout)}.", inner); + exception.Data[CommandTimeoutDataKey] = true; + return exception; + } + + private static bool IsCommandTimeoutException(TimeoutException exception) + => exception.Data[CommandTimeoutDataKey] is true; private static string FormatTimeout(TimeSpan timeout) => timeout.TotalMilliseconds < 1000 diff --git a/src/CSharpDB.Primitives/DbFunctions.cs b/src/CSharpDB.Primitives/DbFunctions.cs index f937932d..4c681c2c 100644 --- a/src/CSharpDB.Primitives/DbFunctions.cs +++ b/src/CSharpDB.Primitives/DbFunctions.cs @@ -4,7 +4,24 @@ public delegate DbValue DbScalarFunctionDelegate( DbScalarFunctionContext context, ReadOnlySpan arguments); -public sealed record DbScalarFunctionContext(string FunctionName); +public sealed record DbScalarFunctionContext(string FunctionName) +{ + public IReadOnlyDictionary Metadata { get; init; } = EmptyStringDictionary.Instance; + + internal static DbScalarFunctionContext Create( + string functionName, + IReadOnlyDictionary? metadata) + => new(functionName) + { + Metadata = metadata ?? EmptyStringDictionary.Instance, + }; + + private static class EmptyStringDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} public sealed record DbScalarFunctionOptions( DbType? ReturnType = null, @@ -34,7 +51,55 @@ internal DbScalarFunctionDefinition( public DbScalarFunctionOptions Options { get; } public DbValue Invoke(ReadOnlySpan arguments) - => _invoke(new DbScalarFunctionContext(Name), arguments); + => Invoke(arguments, metadata: null); + + public DbValue Invoke( + ReadOnlySpan arguments, + IReadOnlyDictionary? metadata) + { + DbScalarFunctionContext context = DbScalarFunctionContext.Create(Name, metadata); + if (!DbCallbackDiagnostics.IsInvocationEnabled) + return _invoke(context, arguments); + + long started = DbCallbackDiagnostics.GetTimestamp(); + try + { + DbValue result = _invoke(context, arguments); + DbCallbackDiagnostics.WriteScalarInvocation( + Name, + Arity, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: true, + canceled: false, + exceptionMessage: null); + return result; + } + catch (OperationCanceledException ex) + { + DbCallbackDiagnostics.WriteScalarInvocation( + Name, + Arity, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + canceled: true, + ex.Message); + throw; + } + catch (Exception ex) + { + DbCallbackDiagnostics.WriteScalarInvocation( + Name, + Arity, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + canceled: false, + ex.Message); + throw; + } + } } public sealed class DbFunctionRegistry diff --git a/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs b/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs new file mode 100644 index 00000000..837bd924 --- /dev/null +++ b/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs @@ -0,0 +1,155 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class AutomationManifestValidatorTests +{ + [Fact] + public void Validate_ReturnsSuccessWhenMetadataMatchesRegistries() + { + DbAutomationMetadata metadata = new( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.BeforeInsert"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + ]); + DbFunctionRegistry functions = CreateFunctions(("Slugify", 1)); + DbCommandRegistry commands = CreateCommands("AuditOrder"); + + AutomationValidationResult result = AutomationManifestValidator.Validate(metadata, functions, commands); + + Assert.True(result.Succeeded); + Assert.Empty(result.Issues); + } + + [Fact] + public void Validate_ReportsMissingCommandsAndScalarFunctions() + { + DbAutomationMetadata metadata = new( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.BeforeInsert"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + ]); + + AutomationValidationResult result = AutomationManifestValidator.Validate( + metadata, + DbFunctionRegistry.Empty, + DbCommandRegistry.Empty); + + Assert.False(result.Succeeded); + Assert.Equal(2, result.Issues.Count); + + AutomationValidationIssue commandIssue = Assert.Single( + result.Issues, + issue => issue.CallbackKind == AutomationCallbackKind.Command); + Assert.Equal(AutomationValidationSeverity.Error, commandIssue.Severity); + Assert.Equal("AuditOrder", commandIssue.Name); + Assert.Equal("admin.forms", commandIssue.Surface); + Assert.Equal("form.events.BeforeInsert", commandIssue.Location); + Assert.Contains("not registered", commandIssue.Message); + + AutomationValidationIssue functionIssue = Assert.Single( + result.Issues, + issue => issue.CallbackKind == AutomationCallbackKind.ScalarFunction); + Assert.Equal(AutomationValidationSeverity.Error, functionIssue.Severity); + Assert.Equal("Slugify", functionIssue.Name); + Assert.Equal(1, functionIssue.ExpectedArity); + Assert.Equal("controls.slug.formula", functionIssue.Location); + Assert.Contains("not registered", functionIssue.Message); + } + + [Fact] + public void Validate_ReportsScalarFunctionArityMismatch() + { + DbAutomationMetadata metadata = new( + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("Slugify", 2, "pipelines", "transforms[0].derivedColumns[0].expression"), + ]); + DbFunctionRegistry functions = CreateFunctions(("Slugify", 1)); + + AutomationValidationResult result = AutomationManifestValidator.Validate( + metadata, + functions, + DbCommandRegistry.Empty); + + AutomationValidationIssue issue = Assert.Single(result.Issues); + Assert.False(result.Succeeded); + Assert.Equal(AutomationValidationSeverity.Error, issue.Severity); + Assert.Equal(AutomationCallbackKind.ScalarFunction, issue.CallbackKind); + Assert.Equal("Slugify", issue.Name); + Assert.Equal(2, issue.ExpectedArity); + Assert.Contains("with arity 2", issue.Message); + Assert.Contains("host registry has arity 1", issue.Message); + } + + [Fact] + public void Validate_DuplicateReferencesProduceWarningsWithoutFailing() + { + DbAutomationMetadata metadata = new( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.BeforeInsert"), + new DbAutomationCommandReference("auditorder", "admin.forms", "form.events.BeforeInsert"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + new DbAutomationScalarFunctionReference("slugify", 1, "admin.forms", "controls.slug.formula"), + ]); + DbFunctionRegistry functions = CreateFunctions(("Slugify", 1)); + DbCommandRegistry commands = CreateCommands("AuditOrder"); + + AutomationValidationResult result = AutomationManifestValidator.Validate(metadata, functions, commands); + + Assert.True(result.Succeeded); + Assert.Equal(2, result.Issues.Count); + Assert.All(result.Issues, issue => Assert.Equal(AutomationValidationSeverity.Warning, issue.Severity)); + Assert.Contains(result.Issues, issue => issue.CallbackKind == AutomationCallbackKind.Command); + Assert.Contains(result.Issues, issue => issue.CallbackKind == AutomationCallbackKind.ScalarFunction); + } + + [Fact] + public void Validate_CanRequireAutomationMetadata() + { + AutomationValidationResult optionalResult = AutomationManifestValidator.Validate( + metadata: null, + DbFunctionRegistry.Empty, + DbCommandRegistry.Empty); + AutomationValidationResult requiredResult = AutomationManifestValidator.Validate( + metadata: null, + DbFunctionRegistry.Empty, + DbCommandRegistry.Empty, + new AutomationManifestValidationOptions(RequireMetadata: true)); + + Assert.True(optionalResult.Succeeded); + Assert.Empty(optionalResult.Issues); + + AutomationValidationIssue issue = Assert.Single(requiredResult.Issues); + Assert.False(requiredResult.Succeeded); + Assert.Equal(AutomationCallbackKind.Unknown, issue.CallbackKind); + Assert.Equal(AutomationValidationSeverity.Error, issue.Severity); + Assert.Contains("metadata is missing", issue.Message); + } + + private static DbFunctionRegistry CreateFunctions(params (string Name, int Arity)[] functions) + => DbFunctionRegistry.Create(builder => + { + foreach ((string name, int arity) in functions) + builder.AddScalar(name, arity, static (_, _) => DbValue.Null); + }); + + private static DbCommandRegistry CreateCommands(params string[] commands) + => DbCommandRegistry.Create(builder => + { + foreach (string command in commands) + builder.AddCommand(command, static _ => DbCommandResult.Success()); + }); +} diff --git a/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs b/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs new file mode 100644 index 00000000..cfdc06c3 --- /dev/null +++ b/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs @@ -0,0 +1,139 @@ +using CSharpDB.Primitives; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace CSharpDB.Tests; + +public sealed class AutomationStubGeneratorTests +{ + [Fact] + public void GenerateCSharp_ProducesDeterministicRegistrationStubs() + { + DbAutomationMetadata metadata = new( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.BeforeInsert"), + new DbAutomationCommandReference("auditorder", "admin.forms", "form.events.AfterUpdate"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("NormalizeName", 2, "pipelines", "transforms[0].filterExpression"), + new DbAutomationScalarFunctionReference("normalizeName", 2, "admin.forms", "controls.name.formula"), + ]); + + string source = AutomationStubGenerator.GenerateCSharp( + metadata, + new AutomationStubGenerationOptions( + Namespace: "MyApp.CSharpDbAutomation", + ClassName: "CSharpDbAutomationRegistration")); + + const string expected = """ + using System; + using System.Threading.Tasks; + using CSharpDB.Primitives; + + namespace MyApp.CSharpDbAutomation; + + public static class CSharpDbAutomationRegistration + { + public static void Register(DbFunctionRegistryBuilder functions, DbCommandRegistryBuilder commands) + { + ArgumentNullException.ThrowIfNull(functions); + ArgumentNullException.ThrowIfNull(commands); + + functions.AddScalar( + "NormalizeName", + arity: 2, + options: new DbScalarFunctionOptions(DbType.Text), + invoke: static (context, args) => + { + // References: + // - admin.forms: controls.name.formula + // - pipelines: transforms[0].filterExpression + throw new NotImplementedException("Implement trusted scalar function 'NormalizeName'."); + }); + + commands.AddAsyncCommand( + "AuditOrder", + static async (context, ct) => + { + // References: + // - admin.forms: form.events.AfterUpdate + // - admin.forms: form.events.BeforeInsert + await Task.CompletedTask; + throw new NotImplementedException("Implement trusted command 'AuditOrder'."); + }); + } + } + """; + + Assert.Equal(Normalize(expected), Normalize(source)); + } + + [Fact] + public void GenerateCSharp_ProducesCompilableSource() + { + DbAutomationMetadata metadata = new( + Commands: + [ + new DbAutomationCommandReference("WriteAudit", "admin.forms", "form.events.BeforeInsert\r\n// ignored"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("FormatValue", 1, "admin.reports", "bands.detail.controls.total.expression"), + ]); + + string source = AutomationStubGenerator.GenerateCSharp( + metadata, + new AutomationStubGenerationOptions( + Namespace: "MyApp.Generated", + ClassName: "RegistrationStubs", + MethodName: "AddCallbacks")); + + AssertCompiles(source); + } + + [Fact] + public void GenerateCSharp_RejectsInvalidTypeNames() + { + DbAutomationMetadata metadata = new(); + + Assert.Throws(() => + AutomationStubGenerator.GenerateCSharp( + metadata, + new AutomationStubGenerationOptions( + Namespace: "MyApp.Generated", + ClassName: "class"))); + } + + private static void AssertCompiles(string source) + { + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source); + CSharpCompilation compilation = CSharpCompilation.Create( + assemblyName: "AutomationStubGeneratorTests_" + Guid.NewGuid().ToString("N"), + syntaxTrees: [syntaxTree], + references: GetMetadataReferences(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + Diagnostic[] errors = compilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToArray(); + + Assert.Empty(errors); + } + + private static MetadataReference[] GetMetadataReferences() + { + string trustedPlatformAssemblies = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") + ?? throw new InvalidOperationException("Trusted platform assemblies were not available."); + + return trustedPlatformAssemblies + .Split(Path.PathSeparator) + .Select(static path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(DbAutomationMetadata).Assembly.Location)) + .ToArray(); + } + + private static string Normalize(string source) + => source.ReplaceLineEndings("\n").TrimEnd('\n'); +} diff --git a/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs b/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs new file mode 100644 index 00000000..3bc63d8d --- /dev/null +++ b/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs @@ -0,0 +1,150 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class CallbackDiagnosticsTests +{ + [Fact] + public void ScalarFunctionInvocation_EmitsDiagnosticEvent() + { + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbFunctionRegistry registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "DiagBump", + 1, + static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1))); + + Assert.True(registry.TryGetScalar("DiagBump", 1, out DbScalarFunctionDefinition definition)); + DbValue result = definition.Invoke( + [DbValue.FromInteger(41)], + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "SQL", + ["location"] = "functions.DiagBump", + }); + + Assert.Equal(42, result.AsInteger); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "DiagBump"); + Assert.Equal(AutomationCallbackKind.ScalarFunction, diagnostic.CallbackKind); + Assert.Equal(1, diagnostic.Arity); + Assert.Equal("SQL", diagnostic.Surface); + Assert.Equal("functions.DiagBump", diagnostic.Location); + Assert.True(diagnostic.Succeeded); + Assert.False(diagnostic.TimedOut); + Assert.False(diagnostic.Canceled); + Assert.Null(diagnostic.ExceptionMessage); + Assert.True(diagnostic.Elapsed >= TimeSpan.Zero); + } + + [Fact] + public async Task CommandInvocation_EmitsDiagnosticEventWithMetadataLocation() + { + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbCommandRegistry registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "DiagAudit", + static _ => DbCommandResult.Success("ok"))); + + Assert.True(registry.TryGetCommand("DiagAudit", out DbCommandDefinition definition)); + DbCommandResult result = await definition.InvokeAsync( + metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["event"] = "BeforeInsert", + ["actionSequence"] = "PrepareCustomer", + ["actionStep"] = "2", + }, + ct: TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "DiagAudit"); + Assert.Equal(AutomationCallbackKind.Command, diagnostic.CallbackKind); + Assert.Null(diagnostic.Arity); + Assert.Equal("AdminForms", diagnostic.Surface); + Assert.Equal("BeforeInsert", diagnostic.EventName); + Assert.Equal("actionSequences.PrepareCustomer.steps[2]", diagnostic.Location); + Assert.True(diagnostic.Succeeded); + Assert.Equal("ok", diagnostic.ResultMessage); + Assert.Null(diagnostic.ExceptionMessage); + } + + [Fact] + public async Task CommandFailureResult_EmitsFailedDiagnosticWithoutException() + { + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbCommandRegistry registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "DiagReject", + static _ => DbCommandResult.Failure("not allowed"))); + + Assert.True(registry.TryGetCommand("DiagReject", out DbCommandDefinition definition)); + DbCommandResult result = await definition.InvokeAsync(ct: TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "DiagReject"); + Assert.False(diagnostic.Succeeded); + Assert.False(diagnostic.TimedOut); + Assert.False(diagnostic.Canceled); + Assert.Equal("not allowed", diagnostic.ResultMessage); + Assert.Null(diagnostic.ExceptionMessage); + } + + [Fact] + public async Task CommandTimeout_EmitsTimedOutDiagnostic() + { + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbCommandRegistry registry = DbCommandRegistry.Create(commands => + commands.AddAsyncCommand( + "DiagSlow", + new DbCommandOptions(Timeout: TimeSpan.FromMilliseconds(10)), + static async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return DbCommandResult.Success(); + })); + + Assert.True(registry.TryGetCommand("DiagSlow", out DbCommandDefinition definition)); + TimeoutException ex = await Assert.ThrowsAsync(async () => + await definition.InvokeAsync(ct: TestContext.Current.CancellationToken)); + + Assert.Contains("timed out", ex.Message); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "DiagSlow"); + Assert.False(diagnostic.Succeeded); + Assert.True(diagnostic.TimedOut); + Assert.False(diagnostic.Canceled); + Assert.Contains("timed out", diagnostic.ExceptionMessage); + } + + private sealed class CallbackObserver(List diagnostics) + : IObserver> + { + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(KeyValuePair value) + { + if (value.Key == DbCallbackDiagnostics.InvocationEventName && + value.Value is DbCallbackInvocationDiagnostic diagnostic) + { + diagnostics.Add(diagnostic); + } + } + } +} From 4922c327b713871b8b5dacc704bb2aa1b9538f08 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 20:23:27 -0700 Subject: [PATCH 20/39] Implement Access-style macro actions --- .../Designer/ActionSequenceEditor.razor | 163 +++++ .../Components/Designer/FormRenderer.razor | 88 ++- .../Contracts/FormActionRequests.cs | 10 + .../Contracts/FormActionRuntimeContext.cs | 14 + .../Contracts/FormActionValidationModels.cs | 52 ++ .../Contracts/IFormActionRuntime.cs | 53 ++ .../Models/ControlRuleDefinition.cs | 12 + .../Models/FormDefinition.cs | 3 +- .../Pages/DataEntry.razor | 616 ++++++++++++++++- .../AdminFormsServiceCollectionExtensions.cs | 1 + .../Services/DefaultFormEventDispatcher.cs | 33 +- .../Services/FormActionManifestValidator.cs | 598 +++++++++++++++++ .../Services/FormActionSequenceExecutor.cs | 379 ++++++++++- .../Services/FormFilterExpression.cs | 623 ++++++++++++++++++ .../Services/NullFormActionRuntime.cs | 68 ++ .../Components/Tabs/FormEntryTab.razor | 18 +- src/CSharpDB.Primitives/DbActions.cs | 10 + .../FormRendererCommandButtonTests.cs | 44 +- .../Pages/DataEntryTests.cs | 153 ++++- .../Serialization/JsonRoundtripTests.cs | 58 ++ .../DefaultFormEventDispatcherTests.cs | 141 ++++ .../FormActionManifestValidatorTests.cs | 153 +++++ 22 files changed, 3255 insertions(+), 35 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs create mode 100644 src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs create mode 100644 src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs create mode 100644 src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs create mode 100644 src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Services/FormActionManifestValidatorTests.cs diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor index 539e91bb..e369d36e 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ActionSequenceEditor.razor @@ -144,6 +144,129 @@
break; + case DbActionKind.OpenForm: +
+ + +
+
+ + +
+ break; + case DbActionKind.ApplyFilter: +
+
+ + +
+
+ + +
+
+
+ + +
+ break; + case DbActionKind.ClearFilter: +
+ + +
+ break; + case DbActionKind.RunSql: +
+ + +
+
+ + +
+ break; + case DbActionKind.RunProcedure: +
+ + +
+
+ + +
+ break; + case DbActionKind.SetControlProperty: +
+
+ + +
+
+ + +
+
+ + +
+
+ break; + case DbActionKind.SetControlVisibility: + case DbActionKind.SetControlEnabled: + case DbActionKind.SetControlReadOnly: +
+
+ + +
+
+ + +
+
+ break; case DbActionKind.ShowMessage: case DbActionKind.Stop:
@@ -190,6 +313,12 @@ + + + + + +
@@ -295,6 +424,9 @@ private Task UpdateValue(int index, DbActionStep step, string? value) => ReplaceStep(index, step with { Value = string.IsNullOrEmpty(value) ? null : value }); + private Task UpdateBooleanValue(int index, DbActionStep step, string? value) + => ReplaceStep(index, step with { Value = bool.TryParse(value, out bool parsed) ? parsed : null }); + private Task UpdateMessage(int index, DbActionStep step, string? message) => ReplaceStep(index, step with { Message = string.IsNullOrWhiteSpace(message) ? null : message }); @@ -327,6 +459,19 @@ } } + private Task UpdateArgument(int index, DbActionStep step, string key, object? value) + { + var arguments = step.Arguments?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + if (value is string text && string.IsNullOrWhiteSpace(text)) + arguments.Remove(key); + else + arguments[key] = value; + + _argumentText[index] = FormatArguments(arguments); + return ReplaceStep(index, step with { Arguments = arguments.Count == 0 ? null : arguments }); + } + private Task ReplaceStep(int index, DbActionStep step) { DbActionSequence sequence = CurrentSequence(); @@ -351,11 +496,29 @@ DbActionKind.RunActionSequence => new DbActionStep(kind, SequenceName: FirstAvailableActionSequenceName()), DbActionKind.SetFieldValue => new DbActionStep(kind, Target: string.Empty, Value: string.Empty), DbActionKind.GoToRecord => new DbActionStep(kind, Value: string.Empty), + DbActionKind.OpenForm => new DbActionStep(kind, Target: string.Empty), + DbActionKind.ApplyFilter => new DbActionStep(kind, Target: "form", Value: string.Empty), + DbActionKind.ClearFilter => new DbActionStep(kind, Target: "form"), + DbActionKind.RunSql => new DbActionStep(kind, Value: string.Empty), + DbActionKind.RunProcedure => new DbActionStep(kind, Target: string.Empty), + DbActionKind.SetControlProperty => new DbActionStep( + kind, + Target: string.Empty, + Value: string.Empty, + Arguments: new Dictionary { ["property"] = "visible" }), + DbActionKind.SetControlVisibility or + DbActionKind.SetControlEnabled or + DbActionKind.SetControlReadOnly => new DbActionStep(kind, Target: string.Empty, Value: true), DbActionKind.ShowMessage => new DbActionStep(kind, Message: string.Empty), DbActionKind.Stop => new DbActionStep(kind), _ => new DbActionStep(kind), }; + private static string GetArgumentText(DbActionStep step, string key) + => step.Arguments is not null && step.Arguments.TryGetValue(key, out object? value) + ? value?.ToString() ?? string.Empty + : string.Empty; + private string GetArgumentsText(int index, DbActionStep step) { if (_argumentText.TryGetValue(index, out string? text)) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index c20f8caa..1e05281d 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -1,5 +1,6 @@ @using System.Globalization @using System.Text.Json +@using CSharpDB.Admin.Forms.Contracts @using CSharpDB.Admin.Forms.Models @using CSharpDB.Primitives @inject DbCommandRegistry Commands @@ -25,7 +26,8 @@ class="@inputClass" placeholder="@GetProp(c, "placeholder", "")" value="@GetFieldValue(fieldName)" - readonly="@GetBoolProp(c, "readOnly")" + readonly="@IsControlReadOnly(c)" + disabled="@(!IsControlEnabled(c))" tabindex="@tabIdx" @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" @@ -36,7 +38,8 @@ class="@inputClass" placeholder="@GetProp(c, "placeholder", "")" value="@GetFieldValue(fieldName)" - readonly="@GetBoolProp(c, "readOnly")" + readonly="@IsControlReadOnly(c)" + disabled="@(!IsControlEnabled(c))" tabindex="@tabIdx" @onfocus="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnGotFocus, fieldName))" @onblur="@(() => InvokeFieldControlEventAsync(c, ControlEventKind.OnLostFocus, fieldName))" @@ -46,7 +49,8 @@ @GetProp(c, "text", "Button") @@ -243,6 +252,8 @@ [Parameter] public EventCallback OnChildRowsChanged { get; set; } [Parameter] public EventCallback OnCommandError { get; set; } [Parameter] public Func>? OnBuiltInAction { get; set; } + [Parameter] public IFormActionRuntime? ActionRuntime { get; set; } + [Parameter] public IReadOnlyDictionary>? ControlPropertyOverrides { get; set; } private readonly HashSet _executingCommandButtons = []; private const string LayoutModeElastic = "elastic"; @@ -279,8 +290,9 @@ int desktopSpan = GetGridSpan(desktop, 12, DesktopCanvasWidth); int tabletSpan = GetGridSpan(tablet, 8, TabletCanvasWidth); + string visibilityStyle = IsControlVisible(c) ? string.Empty : " display: none;"; return FormattableString.Invariant( - $"--fr-left: {desktop.X}px; --fr-top: {desktop.Y}px; --fr-width: {desktop.Width}px; --fr-height: {desktop.Height}px; --fr-stack-order: {GetStackOrder(c)}; --fr-grid-span: {desktopSpan}; --fr-tablet-span: {tabletSpan};"); + $"--fr-left: {desktop.X}px; --fr-top: {desktop.Y}px; --fr-width: {desktop.Width}px; --fr-height: {desktop.Height}px; --fr-stack-order: {GetStackOrder(c)}; --fr-grid-span: {desktopSpan}; --fr-tablet-span: {tabletSpan};{visibilityStyle}"); } private double GetCanvasHeight() @@ -603,7 +615,8 @@ reusableSequences: Form.ActionSequences, setFieldValue: SetActionFieldValueAsync, showMessage: ReportCommandErrorAsync, - executeBuiltInFormAction: OnBuiltInAction); + executeBuiltInFormAction: OnBuiltInAction, + actionRuntime: ActionRuntime); if (!actionResult.Succeeded && binding.StopOnFailure) { @@ -676,10 +689,12 @@ return 0; } - private static string GetProp(ControlDefinition c, string key, string fallback) + private string GetProp(ControlDefinition c, string key, string fallback) { - if (c.Props.Values.TryGetValue(key, out var val) && val is string s) + if (TryGetEffectiveProperty(c, key, out object? val) && val is string s) return s; + if (val is not null && key is "text" or "placeholder") + return val.ToString() ?? fallback; return fallback; } @@ -706,11 +721,58 @@ .ToList(); } - private static bool GetBoolProp(ControlDefinition c, string key) + private bool IsControlVisible(ControlDefinition c) + => GetBoolProp(c, "visible", fallback: true); + + private bool IsControlEnabled(ControlDefinition c) + => GetBoolProp(c, "enabled", fallback: true); + + private bool IsControlReadOnly(ControlDefinition c) + => GetBoolProp(c, "readOnly", fallback: false); + + private bool GetBoolProp(ControlDefinition c, string key, bool fallback = false) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadBoolean(value, fallback) + : fallback; + + private bool TryGetEffectiveProperty(ControlDefinition control, string key, out object? value) { - if (c.Props.Values.TryGetValue(key, out var val) && val is bool b) - return b; - return false; + bool found = control.Props.Values.TryGetValue(key, out value); + + foreach (ControlRuleDefinition rule in Form.Rules ?? []) + { + if (!FormActionConditionEvaluator.TryEvaluate( + rule.Condition, + Record, + bindingArguments: null, + runtimeArguments: null, + stepArguments: null, + out bool shouldApply, + out _) || + !shouldApply) + { + continue; + } + + foreach (ControlRuleEffect effect in rule.Effects) + { + if (string.Equals(effect.ControlId, control.ControlId, StringComparison.OrdinalIgnoreCase) && + string.Equals(effect.Property, key, StringComparison.OrdinalIgnoreCase)) + { + value = effect.Value; + found = true; + } + } + } + + if (ControlPropertyOverrides?.TryGetValue(control.ControlId, out IReadOnlyDictionary? overrides) == true && + overrides.TryGetValue(key, out object? overrideValue)) + { + value = overrideValue; + found = true; + } + + return found; } private static object? ParseNumber(object? value) diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs new file mode 100644 index 00000000..c540c38c --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs @@ -0,0 +1,10 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormOpenRequest( + string FormId, + string FormName, + IReadOnlyDictionary Arguments); + +public sealed record FormCloseRequest( + string? FormId, + string? FormName); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs new file mode 100644 index 00000000..7896ccd0 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionRuntimeContext.cs @@ -0,0 +1,14 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormActionRuntimeContext( + string? FormId, + string? FormName, + string? TableName, + string? EventName, + string? ActionSequenceName, + int StepIndex, + IReadOnlyDictionary? Record, + IReadOnlyDictionary? BindingArguments, + IReadOnlyDictionary? RuntimeArguments, + IReadOnlyDictionary? StepArguments, + IReadOnlyDictionary Metadata); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs new file mode 100644 index 00000000..420a33f7 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionValidationModels.cs @@ -0,0 +1,52 @@ +using CSharpDB.Primitives; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormActionValidationResult( + bool Succeeded, + IReadOnlyList Issues); + +public sealed record FormActionValidationIssue( + FormActionValidationSeverity Severity, + DbActionKind ActionKind, + string Surface, + string Location, + string Message, + string? Target = null, + string? EventName = null, + string? ActionSequence = null, + int? StepIndex = null); + +public enum FormActionValidationSeverity +{ + Warning, + Error, +} + +public sealed record FormActionRuntimeCapabilities( + bool RecordActions = false, + bool OpenForm = false, + bool CloseForm = false, + bool ApplyFilter = false, + bool ClearFilter = false, + bool RunSql = false, + bool RunProcedure = false, + bool SetControlProperty = false) +{ + public static FormActionRuntimeCapabilities None { get; } = new(); + + public static FormActionRuntimeCapabilities RenderedForm { get; } = new( + RecordActions: true, + OpenForm: true, + CloseForm: true, + ApplyFilter: true, + ClearFilter: true, + SetControlProperty: true); +} + +public sealed record FormActionValidationOptions( + FormActionRuntimeCapabilities? RuntimeCapabilities = null, + IReadOnlyCollection? AvailableForms = null, + IReadOnlyCollection? AvailableProcedures = null, + FormTableDefinition? Schema = null); diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs new file mode 100644 index 00000000..0193e082 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormActionRuntime.cs @@ -0,0 +1,53 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Contracts; + +public interface IFormActionRuntime +{ + Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct); + + Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct); + + Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct); + + Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct); + + Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct); +} diff --git a/src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs b/src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs new file mode 100644 index 00000000..69919c5b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/ControlRuleDefinition.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Forms.Models; + +public sealed record ControlRuleDefinition( + string RuleId, + string Condition, + IReadOnlyList Effects, + string? Description = null); + +public sealed record ControlRuleEffect( + string ControlId, + string Property, + object? Value); diff --git a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs index 5f0ce75f..e7300191 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs @@ -13,4 +13,5 @@ public sealed record FormDefinition( IReadOnlyDictionary? RendererHints = null, IReadOnlyList? EventBindings = null, DbAutomationMetadata? Automation = null, - IReadOnlyList? ActionSequences = null); + IReadOnlyList? ActionSequences = null, + IReadOnlyList? Rules = null); diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index d8c2dfa6..13e8e626 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -1,7 +1,11 @@ @using System.Globalization +@using System.Text.Json +@using CSharpDB.Client +@using CSharpDB.Client.Models @using CSharpDB.Admin.Forms.Contracts @using CSharpDB.Primitives @implements IDisposable +@implements IFormActionRuntime @inject IFormRepository FormRepository @inject IFormRecordService RecordService @inject ISchemaProvider SchemaProvider @@ -93,6 +97,8 @@ ChildFormTableDefinitions="_childTableDefs" OnCommandError="OnCommandError" OnBuiltInAction="ExecuteBuiltInFormActionAsync" + ActionRuntime="this" + ControlPropertyOverrides="_controlPropertyOverrides" OnChildRowsChanged="OnChildRowsChanged" />
} @@ -200,7 +206,13 @@ [Parameter] public bool ShowDesignerButton { get; set; } = true; [Parameter] public string? BackHref { get; set; } [Parameter] public string BackLabel { get; set; } = "Forms"; + [Parameter] public EventCallback OnOpenForm { get; set; } + [Parameter] public EventCallback OnCloseForm { get; set; } + [Parameter] public bool EnableSqlActions { get; set; } + [Parameter] public bool EnableProcedureActions { get; set; } [Inject] public IFormEventDispatcher FormEvents { get; set; } = NullFormEventDispatcher.Instance; + [Inject] public ICSharpDbClient? DbClient { get; set; } + [Inject] public NavigationManager? Navigation { get; set; } private FormDefinition? _form; private FormTableDefinition? _table; @@ -224,12 +236,16 @@ private string _searchValue = string.Empty; private string? _activeSearchColumnName; private string? _activeSearchValue; + private string? _activeFilterExpression; + private FormFilterExpression? _activeFilter; + private IReadOnlyDictionary? _activeFilterParameters; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); private Dictionary? _validationErrors; private readonly Dictionary _childTableDefs = new(StringComparer.OrdinalIgnoreCase); private readonly List _computedControls = []; private readonly HashSet _computedFieldNames = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _controlPropertyOverrides = new(StringComparer.OrdinalIgnoreCase); private string? _loadedFormId; private ElementReference _layoutRef; private ElementReference _recordListRef; @@ -241,6 +257,7 @@ private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalRecords / (double)_pageSize)); private int CurrentRecordOrdinal => _isNew || _recordPageIndex < 0 ? 0 : ((_page - 1) * _pageSize) + _recordPageIndex + 1; private bool HasActiveSearch => !string.IsNullOrWhiteSpace(_activeSearchColumnName) && !string.IsNullOrWhiteSpace(_activeSearchValue); + private bool HasActiveFilter => _activeFilter is not null && !string.IsNullOrWhiteSpace(_activeFilterExpression); private bool HasAnySearchInput => HasActiveSearch || !string.IsNullOrWhiteSpace(_searchValue); private bool CanApplySearch => !string.IsNullOrWhiteSpace(_searchColumnName) && !string.IsNullOrWhiteSpace(_searchValue); private bool SupportsPrimaryKeyNavigation => _table?.HasSinglePrimaryKey == true; @@ -305,6 +322,8 @@ _childTableDefs.Clear(); _computedControls.Clear(); _computedFieldNames.Clear(); + _controlPropertyOverrides.Clear(); + ClearFilterState(); try { @@ -703,6 +722,208 @@ } } + public Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct) + => ExecuteBuiltInFormActionAsync(step, ct); + + public async Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + IReadOnlyDictionary resolvedArguments = ResolveActionArguments(context, arguments); + FormDefinition? targetForm = await ResolveFormAsync(formName); + if (targetForm is null) + return FormEventDispatchResult.Failure($"OpenForm target '{formName}' was not found."); + + if (!OnOpenForm.HasDelegate) + { + if (Navigation is null) + return FormEventDispatchResult.Failure("OpenForm action requires a host open-form handler."); + + Navigation.NavigateTo($"/forms/{Uri.EscapeDataString(targetForm.FormId)}"); + return FormEventDispatchResult.Success(); + } + + await OnOpenForm.InvokeAsync(new FormOpenRequest(targetForm.FormId, targetForm.Name, resolvedArguments)); + return FormEventDispatchResult.Success(); + } + + public async Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!OnCloseForm.HasDelegate) + return FormEventDispatchResult.Failure("CloseForm action requires a host close-form handler."); + + await OnCloseForm.InvokeAsync(new FormCloseRequest(_form?.FormId, formName ?? _form?.Name)); + return FormEventDispatchResult.Success(); + } + + public async Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_table is null) + return FormEventDispatchResult.Failure("ApplyFilter action requires a loaded form source."); + + if (!IsCurrentFormTarget(target)) + return FormEventDispatchResult.Failure($"ApplyFilter target '{target}' is not supported by this rendered form runtime."); + + if (!FormFilterExpression.TryParse(filter, _table, out FormFilterExpression? parsed, out string? filterError)) + return FormEventDispatchResult.Failure($"ApplyFilter action has an invalid filter expression: {filterError}"); + + IReadOnlyDictionary resolvedArguments = BuildActionParameterDictionary(context, arguments); + string? missingParameter = parsed!.Parameters.FirstOrDefault(parameter => !resolvedArguments.ContainsKey(parameter)); + if (missingParameter is not null) + return FormEventDispatchResult.Failure($"ApplyFilter action is missing parameter '@{missingParameter}'."); + + _activeFilterExpression = filter; + _activeFilter = parsed; + _activeFilterParameters = resolvedArguments; + ClearSearchState(clearPendingValue: true); + await LoadRecordPageAsync(1, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + await RefreshDataEntryStateAsync(); + return ToActionResult("ApplyFilter"); + } + + public async Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_table is null) + return FormEventDispatchResult.Failure("ClearFilter action requires a loaded form source."); + + if (!IsCurrentFormTarget(target)) + return FormEventDispatchResult.Failure($"ClearFilter target '{target}' is not supported by this rendered form runtime."); + + object? currentPk = TryGetPrimaryKeyValue(_currentRecord, out object? pkValue) ? pkValue : null; + ClearFilterState(); + await LoadRecordPageAsync(1, preferredPk: currentPk, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + await RefreshDataEntryStateAsync(); + return ToActionResult("ClearFilter"); + } + + public async Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!EnableSqlActions) + return FormEventDispatchResult.Failure("RunSql action is disabled by host policy."); + if (DbClient is null) + return FormEventDispatchResult.Failure("RunSql action requires a database client."); + + IReadOnlyDictionary resolvedArguments = BuildActionParameterDictionary(context, arguments); + string? sql = await ResolveSqlTextAsync(sqlOrName, ct); + if (string.IsNullOrWhiteSpace(sql)) + return FormEventDispatchResult.Failure($"RunSql action could not resolve SQL operation '{sqlOrName}'."); + + if (!TrySubstituteSqlParameters(sql, resolvedArguments, out string resolvedSql, out string? parameterError)) + return FormEventDispatchResult.Failure(parameterError ?? "RunSql action has invalid SQL parameters."); + + SqlExecutionResult result = await DbClient.ExecuteSqlAsync(resolvedSql, ct); + if (!string.IsNullOrWhiteSpace(result.Error)) + return FormEventDispatchResult.Failure($"RunSql action failed: {result.Error}"); + + if (TryReadArgumentText(resolvedArguments, "setField", "targetField") is { } targetField) + await SetRuntimeFieldValueAsync(targetField, ReadScalarResult(result)); + + if (ShouldRefreshAfterAction(resolvedArguments, defaultValue: true)) + await RefreshCurrentRecordsAsync(); + + await RefreshDataEntryStateAsync(); + string message = result.IsQuery + ? $"RunSql action returned {result.Rows?.Count ?? 0} row(s)." + : $"RunSql action affected {result.RowsAffected} row(s)."; + FormEventDispatchResult actionResult = ToActionResult("RunSql"); + return actionResult.Succeeded ? FormEventDispatchResult.Success(message) : actionResult; + } + + public async Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (!EnableProcedureActions) + return FormEventDispatchResult.Failure("RunProcedure action is disabled by host policy."); + if (DbClient is null) + return FormEventDispatchResult.Failure("RunProcedure action requires a database client."); + + IReadOnlyDictionary resolvedArguments = BuildExecutableArguments(BuildActionParameterDictionary(context, arguments)); + ProcedureExecutionResult result = await DbClient.ExecuteProcedureAsync(procedureName, resolvedArguments, ct); + if (!result.Succeeded) + return FormEventDispatchResult.Failure($"RunProcedure action failed: {result.Error ?? $"Procedure '{procedureName}' failed."}"); + + if (ShouldRefreshAfterAction(arguments, defaultValue: true)) + await RefreshCurrentRecordsAsync(); + + await RefreshDataEntryStateAsync(); + FormEventDispatchResult actionResult = ToActionResult("RunProcedure"); + return actionResult.Succeeded + ? FormEventDispatchResult.Success($"RunProcedure action executed '{procedureName}'.") + : actionResult; + } + + public async Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + if (_form is null) + return FormEventDispatchResult.Failure("SetControlProperty action requires a loaded form."); + + ControlDefinition? control = _form.Controls.FirstOrDefault(candidate => string.Equals(candidate.ControlId, controlId, StringComparison.OrdinalIgnoreCase)); + if (control is null) + return FormEventDispatchResult.Failure($"SetControlProperty action targets unknown control '{controlId}'."); + + string normalizedProperty = NormalizeControlPropertyName(propertyName); + object? resolvedValue = ResolveActionValue(value, context); + if (string.Equals(normalizedProperty, "value", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(control.Binding?.FieldName)) + return FormEventDispatchResult.Failure($"SetControlProperty action cannot set value for unbound control '{controlId}'."); + + await SetRuntimeFieldValueAsync(control.Binding.FieldName, resolvedValue); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + if (!IsSupportedRuntimeControlProperty(normalizedProperty)) + return FormEventDispatchResult.Failure($"SetControlProperty action does not support property '{propertyName}'."); + + if (!_controlPropertyOverrides.TryGetValue(control.ControlId, out IReadOnlyDictionary? existing) || + existing is not Dictionary overrides) + { + overrides = existing?.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + _controlPropertyOverrides[control.ControlId] = overrides; + } + + overrides[normalizedProperty] = NormalizeActionValue(resolvedValue); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + private async Task RefreshDataEntryStateAsync() { try @@ -720,6 +941,323 @@ ? FormEventDispatchResult.Success() : FormEventDispatchResult.Failure($"{actionName} action failed: {_error}"); + private async Task ResolveFormAsync(string formName) + { + if (string.IsNullOrWhiteSpace(formName)) + return null; + + string trimmed = formName.Trim(); + FormDefinition? direct = await FormRepository.GetAsync(trimmed); + if (direct is not null) + return direct; + + try + { + IReadOnlyList forms = await FormRepository.ListAsync(); + return forms.FirstOrDefault(form => + string.Equals(form.FormId, trimmed, StringComparison.OrdinalIgnoreCase) || + string.Equals(form.Name, trimmed, StringComparison.OrdinalIgnoreCase)); + } + catch (NotSupportedException) + { + return null; + } + } + + private bool IsCurrentFormTarget(string? target) + => string.IsNullOrWhiteSpace(target) || + string.Equals(target, "form", StringComparison.OrdinalIgnoreCase) || + string.Equals(target, _form?.FormId, StringComparison.OrdinalIgnoreCase) || + string.Equals(target, _form?.Name, StringComparison.OrdinalIgnoreCase); + + private IReadOnlyDictionary BuildActionParameterDictionary( + FormActionRuntimeContext context, + IReadOnlyDictionary arguments) + { + Dictionary resolved = ResolveActionArguments(context, arguments).ToDictionary( + pair => pair.Key, + pair => pair.Value, + StringComparer.OrdinalIgnoreCase); + + if (resolved.TryGetValue("parameters", out object? nestedParameters)) + { + foreach ((string key, object? value) in ToObjectDictionary(nestedParameters)) + resolved[key] = ResolveActionValue(value, context); + } + + return resolved; + } + + private IReadOnlyDictionary ResolveActionArguments( + FormActionRuntimeContext context, + IReadOnlyDictionary arguments) + { + if (arguments.Count == 0) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + var resolved = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, object? value) in arguments) + { + if (!string.IsNullOrWhiteSpace(key)) + resolved[key] = ResolveActionValue(value, context); + } + + return resolved; + } + + private object? ResolveActionValue(object? value, FormActionRuntimeContext context) + { + value = NormalizeActionValue(value); + if (value is string text) + { + if (TryResolvePath(text, "$record.", context.Record ?? _currentRecord, out object? recordValue)) + return recordValue; + if (TryResolvePath(text, "$binding.", context.BindingArguments, out object? bindingValue)) + return bindingValue; + if (TryResolvePath(text, "$runtime.", context.RuntimeArguments, out object? runtimeValue)) + return runtimeValue; + if (TryResolvePath(text, "$arguments.", context.StepArguments, out object? argumentValue)) + return argumentValue; + } + + return value; + } + + private static bool TryResolvePath( + string value, + string prefix, + IReadOnlyDictionary? source, + out object? resolved) + { + resolved = null; + if (source is null || !value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + + string key = value[prefix.Length..].Trim(); + if (key.Length == 0) + return false; + + if (source.TryGetValue(key, out resolved)) + return true; + + string? actualKey = source.Keys.FirstOrDefault(candidate => string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase)); + if (actualKey is null) + return false; + + resolved = source[actualKey]; + return true; + } + + private static object? NormalizeActionValue(object? value) + { + return value switch + { + JsonElement json => NormalizeJsonActionValue(json), + _ => value, + }; + } + + private static object? NormalizeJsonActionValue(JsonElement value) + { + return value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + property => property.Name, + property => NormalizeJsonActionValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(NormalizeJsonActionValue).ToArray(), + _ => value.ToString(), + }; + } + + private static IReadOnlyDictionary ToObjectDictionary(object? value) + { + value = NormalizeActionValue(value); + if (value is IReadOnlyDictionary readOnly) + return readOnly; + + if (value is IDictionary dictionary) + return dictionary.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyDictionary BuildExecutableArguments(IReadOnlyDictionary arguments) + { + var executable = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, object? value) in arguments) + { + if (key is "mode" or "refresh" or "procedure" or "procedureName" or "sql" or "name" or "setField" or "targetField") + continue; + + executable[key] = value; + } + + return executable; + } + + private async Task ResolveSqlTextAsync(string sqlOrName, CancellationToken ct) + { + string trimmed = sqlOrName.Trim(); + if (LooksLikeSql(trimmed)) + return trimmed; + + SavedQueryDefinition? savedQuery = await DbClient!.GetSavedQueryAsync(trimmed, ct); + return savedQuery?.SqlText; + } + + private static bool LooksLikeSql(string value) + { + string firstWord = value.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + return firstWord is "SELECT" or "WITH" or "INSERT" or "UPDATE" or "DELETE" or "CREATE" or "DROP" or "ALTER" or "REPLACE" + || firstWord.Equals("SELECT", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("WITH", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("INSERT", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("UPDATE", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("DELETE", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("CREATE", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("DROP", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("ALTER", StringComparison.OrdinalIgnoreCase) + || firstWord.Equals("REPLACE", StringComparison.OrdinalIgnoreCase); + } + + private static bool TrySubstituteSqlParameters( + string sql, + IReadOnlyDictionary arguments, + out string resolvedSql, + out string? error) + { + var builder = new System.Text.StringBuilder(sql.Length); + bool inString = false; + for (int i = 0; i < sql.Length;) + { + char ch = sql[i]; + if (ch == '\'') + { + builder.Append(ch); + if (inString && i + 1 < sql.Length && sql[i + 1] == '\'') + { + builder.Append(sql[i + 1]); + i += 2; + continue; + } + + inString = !inString; + i++; + continue; + } + + if (!inString && ch == '@') + { + int start = i + 1; + int end = start; + while (end < sql.Length && (char.IsLetterOrDigit(sql[end]) || sql[end] == '_')) + end++; + + if (end == start) + { + builder.Append(ch); + i++; + continue; + } + + string parameterName = sql[start..end]; + if (!arguments.TryGetValue(parameterName, out object? value)) + { + resolvedSql = string.Empty; + error = $"RunSql action is missing parameter '@{parameterName}'."; + return false; + } + + builder.Append(FormSql.FormatLiteral(value)); + i = end; + continue; + } + + builder.Append(ch); + i++; + } + + resolvedSql = builder.ToString(); + error = null; + return true; + } + + private static object? ReadScalarResult(SqlExecutionResult result) + => result.Rows is { Count: > 0 } rows && rows[0].Length > 0 + ? rows[0][0] + : null; + + private static bool ShouldRefreshAfterAction(IReadOnlyDictionary arguments, bool defaultValue) + => arguments.TryGetValue("refresh", out object? value) + ? ReadBooleanValue(value, defaultValue) + : defaultValue; + + private static bool ReadBooleanValue(object? value, bool fallback) + { + value = NormalizeActionValue(value); + return value switch + { + bool boolean => boolean, + long integer => integer != 0, + int integer => integer != 0, + string text when bool.TryParse(text, out bool parsed) => parsed, + string text when long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) => parsed != 0, + _ => fallback, + }; + } + + private static string? TryReadArgumentText( + IReadOnlyDictionary arguments, + params string[] keys) + { + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + { + string? text = NormalizeActionValue(value)?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + } + + return null; + } + + private async Task SetRuntimeFieldValueAsync(string fieldName, object? value) + { + string key = _currentRecord.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)) + ?? fieldName; + + _undoStack.Push(CloneRecord(_currentRecord)); + _redoStack.Clear(); + _currentRecord[key] = NormalizeActionValue(value); + _dirty = true; + + if (_recordPageIndex >= 0 && _recordPageIndex < _records.Count) + _records[_recordPageIndex] = CloneRecord(_currentRecord); + + _validationErrors?.Remove(key); + if (_computedControls.Count > 0) + await EvaluateComputedFields(); + } + + private static string NormalizeControlPropertyName(string propertyName) + { + string trimmed = propertyName.Trim(); + return trimmed.Equals("readonly", StringComparison.OrdinalIgnoreCase) + ? "readOnly" + : trimmed; + } + + private static bool IsSupportedRuntimeControlProperty(string propertyName) + => propertyName is "visible" or "enabled" or "readOnly" or "required" or "styleVariant" or "validationMessage" or "text" or "placeholder"; + private async Task ExecuteGoToRecordActionAsync(DbActionStep step) { if (_table is null) @@ -787,7 +1325,9 @@ if (_form is null) return true; - FormEventDispatchResult result = await FormEvents.DispatchAsync(_form, eventKind, record); + FormEventDispatchResult result = FormEvents is DefaultFormEventDispatcher defaultDispatcher + ? await defaultDispatcher.DispatchAsync(_form, eventKind, record, this) + : await FormEvents.DispatchAsync(_form, eventKind, record); if (result.Succeeded) return true; @@ -849,6 +1389,7 @@ _activeSearchColumnName = _searchColumnName; _activeSearchValue = requestedValue; + ClearFilterState(); _searchValue = requestedValue; _page = 1; _error = null; @@ -896,9 +1437,11 @@ try { - FormRecordPage page = HasActiveSearch - ? await RecordService.SearchRecordPageAsync(_table, _activeSearchColumnName!, _activeSearchValue!, pageNumber, _pageSize) - : await RecordService.ListRecordPageAsync(_table, pageNumber, _pageSize); + FormRecordPage page = HasActiveFilter + ? await LoadFilteredRecordPageAsync(pageNumber) + : HasActiveSearch + ? await RecordService.SearchRecordPageAsync(_table, _activeSearchColumnName!, _activeSearchValue!, pageNumber, _pageSize) + : await RecordService.ListRecordPageAsync(_table, pageNumber, _pageSize); _records = page.Records.Select(CloneRecord).ToList(); _page = page.PageNumber; _pageSize = page.PageSize; @@ -965,6 +1508,34 @@ return 0; } + private async Task LoadFilteredRecordPageAsync(int pageNumber) + { + if (_table is null || _activeFilter is null) + return new FormRecordPage(1, _pageSize, 0, []); + + List> filtered = await GetFilteredRecordsAsync(); + int totalPages = filtered.Count == 0 ? 1 : (int)Math.Ceiling(filtered.Count / (double)_pageSize); + int effectivePage = Math.Clamp(pageNumber, 1, totalPages); + List> pageRecords = filtered + .Skip((effectivePage - 1) * _pageSize) + .Take(_pageSize) + .ToList(); + + return new FormRecordPage(effectivePage, _pageSize, filtered.Count, pageRecords); + } + + private async Task>> GetFilteredRecordsAsync() + { + if (_table is null || _activeFilter is null) + return []; + + List> rows = await RecordService.ListRecordsAsync(_table); + return rows + .Where(row => _activeFilter.Evaluate(row, _activeFilterParameters)) + .Select(CloneRecord) + .ToList(); + } + private async Task LoadFocusedRecordWindowAsync(object pkValue) { if (_table is null) @@ -1054,9 +1625,17 @@ if (_table is null) return; - int? ordinal = HasActiveSearch - ? await RecordService.GetRecordOrdinalAsync(_table, pkValue, _activeSearchColumnName!, _activeSearchValue!) - : await RecordService.GetRecordOrdinalAsync(_table, pkValue); + int? ordinal = HasActiveFilter + ? await GetFilteredRecordOrdinalAsync(pkValue) + : HasActiveSearch + ? await RecordService.GetRecordOrdinalAsync(_table, pkValue, _activeSearchColumnName!, _activeSearchValue!) + : await RecordService.GetRecordOrdinalAsync(_table, pkValue); + + if (ordinal is null && HasActiveFilter) + { + ClearFilterState(); + ordinal = await RecordService.GetRecordOrdinalAsync(_table, pkValue); + } if (ordinal is null && HasActiveSearch) { @@ -1074,6 +1653,22 @@ await LoadRecordPageAsync(targetPage, preferredPk: pkValue); } + private async Task GetFilteredRecordOrdinalAsync(object pkValue) + { + if (_table is null || !_table.HasSinglePrimaryKey) + return null; + + string pkColumn = RecordService.GetPrimaryKeyColumn(_table); + List> filtered = await GetFilteredRecordsAsync(); + for (int i = 0; i < filtered.Count; i++) + { + if (TryGetFieldValue(filtered[i], pkColumn, out object? candidatePk) && AreValuesEqual(candidatePk, pkValue)) + return i; + } + + return null; + } + private void UpdateVisibleCurrentRecord(Dictionary updated) { var clonedRecord = CloneRecord(updated); @@ -1311,6 +1906,13 @@ _searchColumnName = GetDefaultSearchFieldName() ?? string.Empty; } + private void ClearFilterState() + { + _activeFilterExpression = null; + _activeFilter = null; + _activeFilterParameters = null; + } + private IReadOnlyList GetSearchableFields() { if (_table is null) diff --git a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs index 69b9b5e0..e3d2e674 100644 --- a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class AdminFormsServiceCollectionExtensions public static IServiceCollection AddCSharpDbAdminForms(this IServiceCollection services) { services.TryAddSingleton(DbCommandRegistry.Empty); + services.TryAddSingleton(NullFormActionRuntime.Instance); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs index eb5b5f0f..a72cfd3e 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -4,15 +4,41 @@ namespace CSharpDB.Admin.Forms.Services; -public sealed class DefaultFormEventDispatcher(DbCommandRegistry commands) : IFormEventDispatcher +public sealed class DefaultFormEventDispatcher : IFormEventDispatcher { + private readonly DbCommandRegistry _commands; + private readonly IFormActionRuntime _actionRuntime; + + public DefaultFormEventDispatcher(DbCommandRegistry commands) + : this(commands, NullFormActionRuntime.Instance) + { + } + + public DefaultFormEventDispatcher(DbCommandRegistry commands, IFormActionRuntime actionRuntime) + { + ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(actionRuntime); + + _commands = commands; + _actionRuntime = actionRuntime; + } + public async Task DispatchAsync( FormDefinition form, FormEventKind eventKind, IReadOnlyDictionary? record = null, CancellationToken ct = default) + => await DispatchAsync(form, eventKind, record, _actionRuntime, ct); + + public async Task DispatchAsync( + FormDefinition form, + FormEventKind eventKind, + IReadOnlyDictionary? record, + IFormActionRuntime actionRuntime, + CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(form); + ArgumentNullException.ThrowIfNull(actionRuntime); IReadOnlyList bindings = form.EventBindings ?? []; foreach (FormEventBinding binding in bindings.Where(binding => binding.Event == eventKind)) @@ -22,7 +48,7 @@ public async Task DispatchAsync( if (!string.IsNullOrWhiteSpace(binding.CommandName)) { - if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) + if (!_commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) return FormEventDispatchResult.Failure($"Unknown form command '{binding.CommandName}' for event '{eventKind}'."); Dictionary arguments = FormCommandInvocation.BuildArguments(record, binding.Arguments); @@ -67,12 +93,13 @@ public async Task DispatchAsync( { FormEventDispatchResult actionResult = await FormActionSequenceExecutor.ExecuteAsync( binding.ActionSequence, - commands, + _commands, record, binding.Arguments, runtimeArguments: null, metadata, reusableSequences: form.ActionSequences, + actionRuntime: actionRuntime, ct: ct); if (!actionResult.Succeeded && binding.StopOnFailure) diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs b/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs new file mode 100644 index 00000000..b9d2043b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs @@ -0,0 +1,598 @@ +using System.Text.Json; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public static class FormActionManifestValidator +{ + private static readonly HashSet s_supportedControlProperties = new(StringComparer.OrdinalIgnoreCase) + { + "visible", + "enabled", + "readOnly", + "required", + "styleVariant", + "validationMessage", + "text", + "value", + }; + + public static FormActionValidationResult Validate( + FormDefinition form, + FormActionValidationOptions? options = null) + { + ArgumentNullException.ThrowIfNull(form); + + options ??= new FormActionValidationOptions(); + FormActionRuntimeCapabilities capabilities = options.RuntimeCapabilities ?? FormActionRuntimeCapabilities.None; + var issues = new List(); + var controlIds = new HashSet( + form.Controls.Select(static control => control.ControlId), + StringComparer.OrdinalIgnoreCase); + var sequenceNames = BuildSequenceNameIndex(form.ActionSequences, issues); + HashSet? availableForms = options.AvailableForms is null + ? null + : new HashSet(options.AvailableForms, StringComparer.OrdinalIgnoreCase); + HashSet? availableProcedures = options.AvailableProcedures is null + ? null + : new HashSet(options.AvailableProcedures, StringComparer.OrdinalIgnoreCase); + + foreach (FormEventBinding binding in form.EventBindings ?? []) + { + if (binding.ActionSequence is not null) + { + ValidateSequence( + binding.ActionSequence, + $"form.events.{binding.Event}.actionSequence", + binding.Event.ToString(), + controlIds, + sequenceNames, + availableForms, + availableProcedures, + options.Schema, + capabilities, + issues); + } + } + + foreach (ControlDefinition control in form.Controls) + { + foreach (ControlEventBinding binding in control.EventBindings ?? []) + { + if (binding.ActionSequence is not null) + { + ValidateSequence( + binding.ActionSequence, + $"controls.{control.ControlId}.events.{binding.Event}.actionSequence", + binding.Event.ToString(), + controlIds, + sequenceNames, + availableForms, + availableProcedures, + options.Schema, + capabilities, + issues); + } + } + } + + foreach (DbActionSequence sequence in form.ActionSequences ?? []) + { + string location = string.IsNullOrWhiteSpace(sequence.Name) + ? "form.actionSequences.unnamed" + : $"form.actionSequences.{sequence.Name}"; + ValidateSequence( + sequence, + location, + eventName: null, + controlIds, + sequenceNames, + availableForms, + availableProcedures, + options.Schema, + capabilities, + issues); + } + + ValidateRules(form.Rules, controlIds, options.Schema, issues); + + return new FormActionValidationResult( + !issues.Any(static issue => issue.Severity == FormActionValidationSeverity.Error), + issues.ToArray()); + } + + private static Dictionary BuildSequenceNameIndex( + IReadOnlyList? sequences, + List issues) + { + var names = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DbActionSequence sequence in sequences ?? []) + { + if (string.IsNullOrWhiteSpace(sequence.Name)) + continue; + + string name = sequence.Name.Trim(); + names[name] = names.TryGetValue(name, out int count) ? count + 1 : 1; + } + + foreach ((string name, int count) in names.Where(static pair => pair.Value > 1)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.RunActionSequence, + "admin.forms", + $"form.actionSequences.{name}", + $"Form action sequence name '{name}' is ambiguous because {count} sequences use it.", + Target: name, + ActionSequence: name)); + } + + return names; + } + + private static void ValidateSequence( + DbActionSequence sequence, + string sequenceLocation, + string? eventName, + HashSet controlIds, + Dictionary sequenceNames, + HashSet? availableForms, + HashSet? availableProcedures, + FormTableDefinition? schema, + FormActionRuntimeCapabilities capabilities, + List issues) + { + IReadOnlyList steps = sequence.Steps ?? []; + for (int i = 0; i < steps.Count; i++) + { + DbActionStep step = steps[i]; + string location = $"{sequenceLocation}.steps[{i}]"; + ValidateStep( + step, + location, + eventName, + sequence.Name, + i, + controlIds, + sequenceNames, + availableForms, + availableProcedures, + schema, + capabilities, + issues); + } + } + + private static void ValidateStep( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + Dictionary sequenceNames, + HashSet? availableForms, + HashSet? availableProcedures, + FormTableDefinition? schema, + FormActionRuntimeCapabilities capabilities, + List issues) + { + switch (step.Kind) + { + case DbActionKind.RunCommand: + if (string.IsNullOrWhiteSpace(step.CommandName)) + AddError(issues, step, location, "RunCommand action requires a command name.", eventName, actionSequence, stepIndex); + break; + case DbActionKind.SetFieldValue: + if (string.IsNullOrWhiteSpace(step.Target)) + AddError(issues, step, location, "SetFieldValue action requires a target field.", eventName, actionSequence, stepIndex); + break; + case DbActionKind.RunActionSequence: + ValidateRunActionSequence(step, location, eventName, actionSequence, stepIndex, sequenceNames, issues); + break; + case DbActionKind.OpenForm: + RequireCapability(capabilities.OpenForm, issues, step, location, "OpenForm", eventName, actionSequence, stepIndex); + ValidateOpenForm(step, location, eventName, actionSequence, stepIndex, availableForms, issues); + break; + case DbActionKind.CloseForm: + RequireCapability(capabilities.CloseForm, issues, step, location, "CloseForm", eventName, actionSequence, stepIndex); + break; + case DbActionKind.ApplyFilter: + RequireCapability(capabilities.ApplyFilter, issues, step, location, "ApplyFilter", eventName, actionSequence, stepIndex); + ValidateApplyFilter(step, location, eventName, actionSequence, stepIndex, controlIds, schema, issues); + break; + case DbActionKind.ClearFilter: + RequireCapability(capabilities.ClearFilter, issues, step, location, "ClearFilter", eventName, actionSequence, stepIndex); + ValidateOptionalControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + break; + case DbActionKind.RunSql: + RequireCapability(capabilities.RunSql, issues, step, location, "RunSql", eventName, actionSequence, stepIndex); + if (string.IsNullOrWhiteSpace(ReadText(step.Value) ?? ReadText(step.Target) ?? ReadArgumentText(step.Arguments, "sql", "name"))) + AddError(issues, step, location, "RunSql action requires SQL text or a named SQL operation.", eventName, actionSequence, stepIndex); + break; + case DbActionKind.RunProcedure: + RequireCapability(capabilities.RunProcedure, issues, step, location, "RunProcedure", eventName, actionSequence, stepIndex); + ValidateRunProcedure(step, location, eventName, actionSequence, stepIndex, availableProcedures, issues); + break; + case DbActionKind.SetControlProperty: + RequireCapability(capabilities.SetControlProperty, issues, step, location, "SetControlProperty", eventName, actionSequence, stepIndex); + ValidateControlProperty(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + break; + case DbActionKind.SetControlVisibility: + case DbActionKind.SetControlEnabled: + case DbActionKind.SetControlReadOnly: + RequireCapability(capabilities.SetControlProperty, issues, step, location, step.Kind.ToString(), eventName, actionSequence, stepIndex); + ValidateRequiredControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + break; + case DbActionKind.NewRecord: + case DbActionKind.SaveRecord: + case DbActionKind.DeleteRecord: + case DbActionKind.RefreshRecords: + case DbActionKind.PreviousRecord: + case DbActionKind.NextRecord: + case DbActionKind.GoToRecord: + RequireCapability(capabilities.RecordActions, issues, step, location, step.Kind.ToString(), eventName, actionSequence, stepIndex); + break; + } + } + + private static void ValidateRunActionSequence( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + Dictionary sequenceNames, + List issues) + { + string? target = ReadSequenceName(step); + if (string.IsNullOrWhiteSpace(target)) + { + AddError(issues, step, location, "RunActionSequence action requires a sequence name.", eventName, actionSequence, stepIndex); + return; + } + + if (!sequenceNames.TryGetValue(target, out int count)) + { + AddError(issues, step, location, $"Unknown form action sequence '{target}'.", eventName, actionSequence, stepIndex, target); + return; + } + + if (count > 1) + AddError(issues, step, location, $"Form action sequence name '{target}' is ambiguous.", eventName, actionSequence, stepIndex, target); + } + + private static void ValidateOpenForm( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet? availableForms, + List issues) + { + string? formName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(step.Arguments, "formName", "form", "name"); + if (string.IsNullOrWhiteSpace(formName)) + { + AddError(issues, step, location, "OpenForm action requires a target form name.", eventName, actionSequence, stepIndex); + return; + } + + if (availableForms is not null && !availableForms.Contains(formName)) + AddError(issues, step, location, $"OpenForm target '{formName}' was not found.", eventName, actionSequence, stepIndex, formName); + } + + private static void ValidateApplyFilter( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + FormTableDefinition? schema, + List issues) + { + ValidateOptionalControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + string? filter = ReadText(step.Value) ?? ReadArgumentText(step.Arguments, "filter", "where"); + if (string.IsNullOrWhiteSpace(filter)) + AddError(issues, step, location, "ApplyFilter action requires a filter expression.", eventName, actionSequence, stepIndex); + else if (!FormFilterExpression.TryParse(filter, schema, out _, out string? filterError)) + AddError(issues, step, location, $"ApplyFilter expression '{filter}' is malformed: {filterError}", eventName, actionSequence, stepIndex); + } + + private static void ValidateRunProcedure( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet? availableProcedures, + List issues) + { + string? procedureName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(step.Arguments, "procedureName", "procedure", "name"); + if (string.IsNullOrWhiteSpace(procedureName)) + { + AddError(issues, step, location, "RunProcedure action requires a procedure name.", eventName, actionSequence, stepIndex); + return; + } + + if (availableProcedures is not null && !availableProcedures.Contains(procedureName)) + AddError(issues, step, location, $"Procedure '{procedureName}' was not found.", eventName, actionSequence, stepIndex, procedureName); + } + + private static void ValidateControlProperty( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + List issues) + { + ValidateRequiredControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); + string? property = ReadArgumentText(step.Arguments, "property", "propertyName"); + if (string.IsNullOrWhiteSpace(property)) + { + AddError(issues, step, location, "SetControlProperty action requires a property name.", eventName, actionSequence, stepIndex); + return; + } + + if (!s_supportedControlProperties.Contains(property)) + AddError(issues, step, location, $"Control property '{property}' is not supported.", eventName, actionSequence, stepIndex, step.Target); + } + + private static void ValidateRequiredControlTarget( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + List issues) + { + if (string.IsNullOrWhiteSpace(step.Target)) + { + AddError(issues, step, location, $"{step.Kind} action requires a target control id.", eventName, actionSequence, stepIndex); + return; + } + + if (!controlIds.Contains(step.Target)) + AddError(issues, step, location, $"Unknown control '{step.Target}'.", eventName, actionSequence, stepIndex, step.Target); + } + + private static void ValidateOptionalControlTarget( + DbActionStep step, + string location, + string? eventName, + string? actionSequence, + int stepIndex, + HashSet controlIds, + List issues) + { + if (string.IsNullOrWhiteSpace(step.Target) || + string.Equals(step.Target, "form", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!controlIds.Contains(step.Target)) + AddError(issues, step, location, $"Unknown control '{step.Target}'.", eventName, actionSequence, stepIndex, step.Target); + } + + private static void ValidateRules( + IReadOnlyList? rules, + HashSet controlIds, + FormTableDefinition? schema, + List issues) + { + foreach (ControlRuleDefinition rule in rules ?? []) + { + string ruleId = string.IsNullOrWhiteSpace(rule.RuleId) ? "unnamed" : rule.RuleId.Trim(); + string location = $"form.rules.{ruleId}"; + if (string.IsNullOrWhiteSpace(rule.RuleId)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + "Control rule requires a rule id.")); + } + + if (string.IsNullOrWhiteSpace(rule.Condition)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + $"Control rule '{ruleId}' requires a condition.")); + } + else if (schema is not null) + { + var values = schema.Fields.ToDictionary( + static field => field.Name, + static _ => (object?)null, + StringComparer.OrdinalIgnoreCase); + if (!FormActionConditionEvaluator.TryEvaluate(rule.Condition, values, null, null, null, out _, out string? conditionError)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + $"Control rule '{ruleId}' condition is malformed: {conditionError}")); + } + } + + if (rule.Effects.Count == 0) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + location, + $"Control rule '{ruleId}' requires at least one effect.")); + } + + for (int i = 0; i < rule.Effects.Count; i++) + { + ControlRuleEffect effect = rule.Effects[i]; + string effectLocation = $"{location}.effects[{i}]"; + if (string.IsNullOrWhiteSpace(effect.ControlId) || !controlIds.Contains(effect.ControlId)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + effectLocation, + $"Control rule '{ruleId}' targets unknown control '{effect.ControlId}'.", + Target: effect.ControlId)); + } + + if (string.IsNullOrWhiteSpace(effect.Property) || !s_supportedControlProperties.Contains(effect.Property)) + { + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + DbActionKind.SetControlProperty, + "admin.forms", + effectLocation, + $"Control rule '{ruleId}' uses unsupported property '{effect.Property}'.", + Target: effect.ControlId)); + } + } + } + } + + private static void RequireCapability( + bool capability, + List issues, + DbActionStep step, + string location, + string actionName, + string? eventName, + string? actionSequence, + int stepIndex) + { + if (capability) + return; + + issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Warning, + step.Kind, + "admin.forms", + location, + $"{actionName} action requires a rendered form runtime capability.", + Target: step.Target, + EventName: eventName, + ActionSequence: actionSequence, + StepIndex: stepIndex)); + } + + private static void AddError( + List issues, + DbActionStep step, + string location, + string message, + string? eventName, + string? actionSequence, + int stepIndex, + string? target = null) + => issues.Add(new FormActionValidationIssue( + FormActionValidationSeverity.Error, + step.Kind, + "admin.forms", + location, + message, + Target: target ?? step.Target, + EventName: eventName, + ActionSequence: actionSequence, + StepIndex: stepIndex)); + + private static string? ReadSequenceName(DbActionStep step) + { + if (!string.IsNullOrWhiteSpace(step.SequenceName)) + return step.SequenceName.Trim(); + + if (!string.IsNullOrWhiteSpace(step.Target)) + return step.Target.Trim(); + + return ReadArgumentText(step.Arguments, "sequenceName", "sequence", "name"); + } + + private static string? ReadText(object? value) + => NormalizeValue(value)?.ToString()?.Trim(); + + private static string? ReadArgumentText( + IReadOnlyDictionary? arguments, + params string[] keys) + { + if (arguments is null) + return null; + + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + { + string? text = ReadText(value); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + } + + return null; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + _ => value.ToString(), + }; + + private static bool HasBalancedBracketsAndQuotes(string filter) + { + int bracketDepth = 0; + bool inString = false; + for (int i = 0; i < filter.Length; i++) + { + char ch = filter[i]; + if (ch == '\'') + { + inString = !inString; + continue; + } + + if (inString) + continue; + + if (ch == '[') + { + bracketDepth++; + continue; + } + + if (ch == ']') + { + bracketDepth--; + if (bracketDepth < 0) + return false; + } + } + + return bracketDepth == 0 && !inString; + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs index 95b8d671..5b523f16 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -20,6 +20,7 @@ public static async Task ExecuteAsync( Func? setFieldValue = null, Func? showMessage = null, Func>? executeBuiltInFormAction = null, + IFormActionRuntime? actionRuntime = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(sequence); @@ -37,6 +38,7 @@ public static async Task ExecuteAsync( setFieldValue, showMessage, executeBuiltInFormAction, + actionRuntime ?? NullFormActionRuntime.Instance, ct, depth: 0); } @@ -52,6 +54,7 @@ private static async Task ExecuteCoreAsync( Func? setFieldValue, Func? showMessage, Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, CancellationToken ct, int depth) { @@ -73,6 +76,7 @@ private static async Task ExecuteCoreAsync( setFieldValue, showMessage, executeBuiltInFormAction, + actionRuntime, ct, depth); @@ -102,6 +106,7 @@ private static async Task ExecuteStepAsync( Func? setFieldValue, Func? showMessage, Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, CancellationToken ct, int depth) { @@ -140,15 +145,26 @@ private static async Task ExecuteStepAsync( setFieldValue, showMessage, executeBuiltInFormAction, + actionRuntime, ct, depth), + DbActionKind.OpenForm => await OpenFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.CloseForm => await CloseFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.ApplyFilter => await ApplyFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.ClearFilter => await ClearFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.RunSql => await RunSqlAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.RunProcedure => await RunProcedureAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlProperty => await SetControlPropertyAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlVisibility => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "visible", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlEnabled => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "enabled", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlReadOnly => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "readOnly", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), DbActionKind.NewRecord or DbActionKind.SaveRecord or DbActionKind.DeleteRecord or DbActionKind.RefreshRecords or DbActionKind.PreviousRecord or DbActionKind.NextRecord or - DbActionKind.GoToRecord => await ExecuteBuiltInFormActionAsync(step, executeBuiltInFormAction, ct), + DbActionKind.GoToRecord => await ExecuteRecordActionAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), _ => FormEventDispatchResult.Failure($"Unsupported form action kind '{step.Kind}'."), }; } @@ -163,14 +179,287 @@ DbActionKind.NextRecord or } } - private static Task ExecuteBuiltInFormActionAsync( + private static Task ExecuteRecordActionAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + return actionRuntime.ExecuteRecordActionAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + step, + ct); + } + + private static Task OpenFormAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? formName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(arguments, "formName", "form", "name"); + if (string.IsNullOrWhiteSpace(formName)) + return Task.FromResult(FormEventDispatchResult.Failure("OpenForm action requires a target form name.")); + + return actionRuntime.OpenFormAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + formName, + arguments, + ct); + } + + private static Task CloseFormAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? formName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(arguments, "formName", "form", "name"); + + return actionRuntime.CloseFormAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + formName, + ct); + } + + private static Task ApplyFilterAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string target = ReadText(step.Target) + ?? ReadArgumentText(arguments, "target") + ?? "form"; + string? filter = ReadText(step.Value) + ?? ReadArgumentText(arguments, "filter", "where"); + if (string.IsNullOrWhiteSpace(filter)) + return Task.FromResult(FormEventDispatchResult.Failure("ApplyFilter action requires a filter expression.")); + + return actionRuntime.ApplyFilterAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + target, + filter, + arguments, + ct); + } + + private static Task ClearFilterAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string target = ReadText(step.Target) + ?? ReadArgumentText(arguments, "target") + ?? "form"; + + return actionRuntime.ClearFilterAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + target, + ct); + } + + private static Task RunSqlAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? sqlOrName = ReadText(step.Value) + ?? ReadText(step.Target) + ?? ReadArgumentText(arguments, "sql", "name"); + if (string.IsNullOrWhiteSpace(sqlOrName)) + return Task.FromResult(FormEventDispatchResult.Failure("RunSql action requires SQL text or a named SQL operation.")); + + return actionRuntime.RunSqlAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + sqlOrName, + arguments, + ct); + } + + private static Task RunProcedureAsync( + DbActionSequence sequence, DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, Func>? executeBuiltInFormAction, CancellationToken ct) - => executeBuiltInFormAction is null - ? Task.FromResult(FormEventDispatchResult.Failure( - $"Form action '{step.Kind}' requires a rendered form runtime.")) - : executeBuiltInFormAction(step, ct); + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? procedureName = ReadText(step.Target) + ?? ReadText(step.Value) + ?? ReadArgumentText(arguments, "procedureName", "procedure", "name"); + if (string.IsNullOrWhiteSpace(procedureName)) + return Task.FromResult(FormEventDispatchResult.Failure("RunProcedure action requires a procedure name.")); + + return actionRuntime.RunProcedureAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + procedureName, + arguments, + ct); + } + + private static Task SetControlPropertyAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + string? propertyName = ReadArgumentText(arguments, "property", "propertyName"); + return SetControlPropertyCoreAsync( + sequence, + step, + stepIndex, + propertyName, + ReadValue(step, arguments), + record, + bindingArguments, + runtimeArguments, + metadata, + actionRuntime, + executeBuiltInFormAction, + ct); + } + + private static Task SetSpecificControlPropertyAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + string propertyName, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + IReadOnlyDictionary arguments = NormalizeArguments(step.Arguments); + return SetControlPropertyCoreAsync( + sequence, + step, + stepIndex, + propertyName, + ReadValue(step, arguments), + record, + bindingArguments, + runtimeArguments, + metadata, + actionRuntime, + executeBuiltInFormAction, + ct); + } + + private static Task SetControlPropertyCoreAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + string? propertyName, + object? value, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IFormActionRuntime actionRuntime, + Func>? executeBuiltInFormAction, + CancellationToken ct) + { + if (executeBuiltInFormAction is not null && actionRuntime is NullFormActionRuntime) + return executeBuiltInFormAction(step, ct); + + string? controlId = ReadText(step.Target); + if (string.IsNullOrWhiteSpace(controlId)) + return Task.FromResult(FormEventDispatchResult.Failure($"{step.Kind} action requires a target control id.")); + + if (string.IsNullOrWhiteSpace(propertyName)) + return Task.FromResult(FormEventDispatchResult.Failure("SetControlProperty action requires a property name.")); + + return actionRuntime.SetControlPropertyAsync( + BuildRuntimeContext(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata), + controlId, + propertyName, + value, + ct); + } private static async Task RunCommandAsync( DbActionSequence sequence, @@ -229,6 +518,7 @@ private static async Task RunActionSequenceAsync( Func? setFieldValue, Func? showMessage, Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, CancellationToken ct, int depth) { @@ -265,6 +555,7 @@ private static async Task RunActionSequenceAsync( setFieldValue, showMessage, executeBuiltInFormAction, + actionRuntime, ct, depth + 1); } @@ -340,6 +631,35 @@ private static Dictionary BuildStepMetadata( return result; } + private static FormActionRuntimeContext BuildRuntimeContext( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata) + { + Dictionary stepMetadata = BuildStepMetadata(sequence, step, stepIndex, metadata); + return new FormActionRuntimeContext( + ReadMetadata(stepMetadata, "formId"), + ReadMetadata(stepMetadata, "formName"), + ReadMetadata(stepMetadata, "tableName"), + ReadMetadata(stepMetadata, "event"), + string.IsNullOrWhiteSpace(sequence.Name) ? null : sequence.Name, + stepIndex, + record, + bindingArguments, + runtimeArguments, + NormalizeArguments(step.Arguments), + stepMetadata); + } + + private static string? ReadMetadata(IReadOnlyDictionary metadata, string key) + => metadata.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value) + ? value + : null; + private static string? ReadSequenceName(DbActionStep step) { if (!string.IsNullOrWhiteSpace(step.SequenceName)) @@ -377,6 +697,47 @@ private static Dictionary BuildStepMetadata( return merged; } + private static IReadOnlyDictionary NormalizeArguments( + IReadOnlyDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + return EmptyObjectDictionary.Instance; + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach ((string key, object? value) in arguments) + { + if (!string.IsNullOrWhiteSpace(key)) + result[key] = NormalizeValue(value); + } + + return result; + } + + private static object? ReadValue(DbActionStep step, IReadOnlyDictionary arguments) + => step.Value is null && arguments.TryGetValue("value", out object? argumentValue) + ? argumentValue + : NormalizeValue(step.Value); + + private static string? ReadText(object? value) + => NormalizeValue(value)?.ToString()?.Trim(); + + private static string? ReadArgumentText( + IReadOnlyDictionary arguments, + params string[] keys) + { + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + { + string? text = ReadText(value); + if (!string.IsNullOrWhiteSpace(text)) + return text; + } + } + + return null; + } + private static string? ReadMessage(DbActionStep step) { if (!string.IsNullOrWhiteSpace(step.Message)) @@ -417,4 +778,10 @@ private static Dictionary BuildStepMetadata( _ => value.ToString(), }; } + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs b/src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs new file mode 100644 index 00000000..5f172949 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormFilterExpression.cs @@ -0,0 +1,623 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +internal sealed class FormFilterExpression +{ + private readonly Node _root; + + private FormFilterExpression(Node root, IReadOnlyCollection fields, IReadOnlyCollection parameters) + { + _root = root; + Fields = fields; + Parameters = parameters; + } + + public IReadOnlyCollection Fields { get; } + + public IReadOnlyCollection Parameters { get; } + + public bool Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary? parameters = null) + => IsTruthy(_root.Evaluate(record, parameters ?? EmptyObjectDictionary.Instance)); + + public static bool TryParse( + string expression, + FormTableDefinition? table, + out FormFilterExpression? filter, + out string? error) + { + filter = null; + error = null; + + if (string.IsNullOrWhiteSpace(expression)) + { + error = "Filter expression is empty."; + return false; + } + + if (!Tokenizer.TryTokenize(expression, out List tokens, out error)) + return false; + + var parser = new Parser(tokens); + if (!parser.TryParse(out Node? root, out error)) + return false; + + var fields = new HashSet(StringComparer.OrdinalIgnoreCase); + var parameters = new HashSet(StringComparer.OrdinalIgnoreCase); + root!.CollectReferences(fields, parameters); + + if (table is not null) + { + var availableFields = new HashSet( + table.Fields.Select(static field => field.Name), + StringComparer.OrdinalIgnoreCase); + string? missingField = fields.FirstOrDefault(field => !availableFields.Contains(field)); + if (missingField is not null) + { + error = $"Filter references unknown field '{missingField}'."; + return false; + } + } + + filter = new FormFilterExpression(root, fields, parameters); + return true; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + _ => value.ToString(), + }; + + private static bool IsTruthy(object? value) + { + value = NormalizeValue(value); + if (value is null) + return false; + + if (value is bool boolean) + return boolean; + + if (TryConvertDouble(value, out double number)) + return Math.Abs(number) > double.Epsilon; + + return !string.IsNullOrWhiteSpace(Convert.ToString(value, CultureInfo.InvariantCulture)); + } + + private static bool Compare(object? left, object? right, string op) + { + left = NormalizeValue(left); + right = NormalizeValue(right); + + if (left is null || right is null) + { + int nullComparison = left is null && right is null ? 0 : left is null ? -1 : 1; + return ApplyComparison(nullComparison, op); + } + + if (TryConvertDouble(left, out double leftNumber) && + TryConvertDouble(right, out double rightNumber)) + { + return ApplyComparison(leftNumber.CompareTo(rightNumber), op); + } + + if (left is bool leftBool && right is bool rightBool) + return ApplyComparison(leftBool.CompareTo(rightBool), op); + + int comparison = string.Compare( + Convert.ToString(left, CultureInfo.InvariantCulture), + Convert.ToString(right, CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + return ApplyComparison(comparison, op); + } + + private static bool ApplyComparison(int comparison, string op) + => op switch + { + "=" or "==" => comparison == 0, + "!=" or "<>" => comparison != 0, + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + _ => false, + }; + + private static bool TryConvertDouble(object? value, out double result) + { + value = NormalizeValue(value); + return value switch + { + byte number => Set(number, out result), + short number => Set(number, out result), + int number => Set(number, out result), + long number => Set(number, out result), + float number => Set(number, out result), + double number => Set(number, out result), + decimal number => Set((double)number, out result), + string text => double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result), + _ => Set(0, out result, success: false), + }; + } + + private static bool Set(double value, out double result, bool success = true) + { + result = value; + return success; + } + + private abstract class Node + { + public abstract object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters); + + public virtual void CollectReferences(HashSet fields, HashSet parameters) + { + } + } + + private sealed class LiteralNode(object? value) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + => value; + } + + private sealed class FieldNode(string name) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + { + if (record.TryGetValue(name, out object? value)) + return value; + + string? actualKey = record.Keys.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)); + return actualKey is null ? null : record[actualKey]; + } + + public override void CollectReferences(HashSet fields, HashSet parameters) + => fields.Add(name); + } + + private sealed class ParameterNode(string name) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + { + if (parameters.TryGetValue(name, out object? value)) + return value; + + string? actualKey = parameters.Keys.FirstOrDefault(candidate => string.Equals(candidate, name, StringComparison.OrdinalIgnoreCase)); + return actualKey is null ? null : parameters[actualKey]; + } + + public override void CollectReferences(HashSet fields, HashSet parameters) + => parameters.Add(name); + } + + private sealed class ComparisonNode(Node left, string op, Node right) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + => Compare(left.Evaluate(record, parameters), right.Evaluate(record, parameters), op); + + public override void CollectReferences(HashSet fields, HashSet parameters) + { + left.CollectReferences(fields, parameters); + right.CollectReferences(fields, parameters); + } + } + + private sealed class LogicalNode(Node left, TokenType op, Node right) : Node + { + public override object? Evaluate( + IReadOnlyDictionary record, + IReadOnlyDictionary parameters) + => op == TokenType.And + ? IsTruthy(left.Evaluate(record, parameters)) && IsTruthy(right.Evaluate(record, parameters)) + : IsTruthy(left.Evaluate(record, parameters)) || IsTruthy(right.Evaluate(record, parameters)); + + public override void CollectReferences(HashSet fields, HashSet parameters) + { + left.CollectReferences(fields, parameters); + right.CollectReferences(fields, parameters); + } + } + + private sealed class Parser(List tokens) + { + private int _position; + + public bool TryParse(out Node? node, out string? error) + { + node = ParseOr(out error); + if (node is null) + return false; + + if (!IsAtEnd) + { + error = $"Unexpected token '{Current.Text}' in filter expression."; + node = null; + return false; + } + + return true; + } + + private Node? ParseOr(out string? error) + { + Node? left = ParseAnd(out error); + if (left is null) + return null; + + while (Match(TokenType.Or)) + { + Node? right = ParseAnd(out error); + if (right is null) + return null; + + left = new LogicalNode(left, TokenType.Or, right); + } + + return left; + } + + private Node? ParseAnd(out string? error) + { + Node? left = ParseComparison(out error); + if (left is null) + return null; + + while (Match(TokenType.And)) + { + Node? right = ParseComparison(out error); + if (right is null) + return null; + + left = new LogicalNode(left, TokenType.And, right); + } + + return left; + } + + private Node? ParseComparison(out string? error) + { + Node? left = ParsePrimary(out error); + if (left is null) + return null; + + if (Current.Type != TokenType.Operator) + return left; + + string op = Current.Text; + Advance(); + Node? right = ParsePrimary(out error); + return right is null ? null : new ComparisonNode(left, op, right); + } + + private Node? ParsePrimary(out string? error) + { + Token token = Current; + switch (token.Type) + { + case TokenType.Field: + Advance(); + error = null; + return new FieldNode(token.Text); + case TokenType.Parameter: + Advance(); + error = null; + return new ParameterNode(token.Text); + case TokenType.String: + case TokenType.Number: + case TokenType.Boolean: + case TokenType.Null: + Advance(); + error = null; + return new LiteralNode(token.Value); + case TokenType.LeftParen: + Advance(); + Node? expression = ParseOr(out error); + if (expression is null) + return null; + if (!Match(TokenType.RightParen)) + { + error = "Filter expression is missing a closing parenthesis."; + return null; + } + + error = null; + return expression; + default: + error = IsAtEnd + ? "Filter expression ended unexpectedly." + : $"Unexpected token '{token.Text}' in filter expression."; + return null; + } + } + + private bool Match(TokenType type) + { + if (Current.Type != type) + return false; + + Advance(); + return true; + } + + private void Advance() + { + if (!IsAtEnd) + _position++; + } + + private bool IsAtEnd => Current.Type == TokenType.End; + + private Token Current => tokens[_position]; + } + + private static class Tokenizer + { + public static bool TryTokenize(string expression, out List tokens, out string? error) + { + tokens = []; + error = null; + + for (int i = 0; i < expression.Length;) + { + char ch = expression[i]; + if (char.IsWhiteSpace(ch)) + { + i++; + continue; + } + + if (ch == '[') + { + int end = expression.IndexOf(']', i + 1); + if (end < 0) + { + error = "Filter expression has an unclosed field reference."; + return false; + } + + string fieldName = expression[(i + 1)..end].Trim(); + if (fieldName.Length == 0) + { + error = "Filter expression contains an empty field reference."; + return false; + } + + tokens.Add(new Token(TokenType.Field, fieldName)); + i = end + 1; + continue; + } + + if (ch == '@') + { + int start = i + 1; + int end = ReadIdentifierEnd(expression, start); + if (end == start) + { + error = "Filter expression contains an empty parameter reference."; + return false; + } + + tokens.Add(new Token(TokenType.Parameter, expression[start..end])); + i = end; + continue; + } + + if (ch == '\'') + { + if (!TryReadString(expression, i, out string? value, out int nextIndex, out error)) + return false; + + tokens.Add(new Token(TokenType.String, value, value)); + i = nextIndex; + continue; + } + + if (ch == '(') + { + tokens.Add(new Token(TokenType.LeftParen, "(")); + i++; + continue; + } + + if (ch == ')') + { + tokens.Add(new Token(TokenType.RightParen, ")")); + i++; + continue; + } + + string? op = ReadOperator(expression, i); + if (op is not null) + { + tokens.Add(new Token(TokenType.Operator, op)); + i += op.Length; + continue; + } + + if (char.IsDigit(ch) || ch == '-') + { + int end = ReadNumberEnd(expression, i); + if (end > i && TryReadNumber(expression[i..end], out object number)) + { + tokens.Add(new Token(TokenType.Number, expression[i..end], number)); + i = end; + continue; + } + } + + if (IsIdentifierStart(ch)) + { + int end = ReadIdentifierEnd(expression, i); + string identifier = expression[i..end]; + if (string.Equals(identifier, "AND", StringComparison.OrdinalIgnoreCase)) + tokens.Add(new Token(TokenType.And, identifier)); + else if (string.Equals(identifier, "OR", StringComparison.OrdinalIgnoreCase)) + tokens.Add(new Token(TokenType.Or, identifier)); + else if (string.Equals(identifier, "NULL", StringComparison.OrdinalIgnoreCase)) + tokens.Add(new Token(TokenType.Null, identifier, null)); + else if (bool.TryParse(identifier, out bool boolean)) + tokens.Add(new Token(TokenType.Boolean, identifier, boolean)); + else + tokens.Add(new Token(TokenType.String, identifier, identifier)); + i = end; + continue; + } + + error = $"Unexpected character '{ch}' in filter expression."; + return false; + } + + tokens.Add(new Token(TokenType.End, string.Empty)); + return true; + } + + private static bool TryReadString( + string expression, + int start, + out string value, + out int nextIndex, + out string? error) + { + var builder = new System.Text.StringBuilder(); + for (int i = start + 1; i < expression.Length; i++) + { + char ch = expression[i]; + if (ch == '\'') + { + if (i + 1 < expression.Length && expression[i + 1] == '\'') + { + builder.Append('\''); + i++; + continue; + } + + value = builder.ToString(); + nextIndex = i + 1; + error = null; + return true; + } + + builder.Append(ch); + } + + value = string.Empty; + nextIndex = expression.Length; + error = "Filter expression has an unclosed string literal."; + return false; + } + + private static string? ReadOperator(string expression, int index) + { + foreach (string op in new[] { ">=", "<=", "==", "!=", "<>", "=", ">", "<" }) + { + if (expression.AsSpan(index).StartsWith(op, StringComparison.Ordinal)) + return op; + } + + return null; + } + + private static int ReadNumberEnd(string expression, int start) + { + int i = start; + if (i < expression.Length && expression[i] == '-') + i++; + + bool hasDigit = false; + while (i < expression.Length && char.IsDigit(expression[i])) + { + hasDigit = true; + i++; + } + + if (i < expression.Length && expression[i] == '.') + { + i++; + while (i < expression.Length && char.IsDigit(expression[i])) + { + hasDigit = true; + i++; + } + } + + return hasDigit ? i : start; + } + + private static bool TryReadNumber(string text, out object value) + { + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + { + value = integer; + return true; + } + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double real)) + { + value = real; + return true; + } + + value = text; + return false; + } + + private static int ReadIdentifierEnd(string expression, int start) + { + int i = start; + while (i < expression.Length && (char.IsLetterOrDigit(expression[i]) || expression[i] == '_')) + i++; + + return i; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + } + + private sealed record Token(TokenType Type, string Text, object? Value = null); + + private enum TokenType + { + Field, + Parameter, + String, + Number, + Boolean, + Null, + Operator, + And, + Or, + LeftParen, + RightParen, + End, + } + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs b/src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs new file mode 100644 index 00000000..7b3ffa9e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/NullFormActionRuntime.cs @@ -0,0 +1,68 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class NullFormActionRuntime : IFormActionRuntime +{ + public static NullFormActionRuntime Instance { get; } = new(); + + public Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct) + => UnsupportedAsync(step.Kind); + + public Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.OpenForm); + + public Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.CloseForm); + + public Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.ApplyFilter); + + public Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.ClearFilter); + + public Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.RunSql); + + public Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.RunProcedure); + + public Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct) + => UnsupportedAsync(DbActionKind.SetControlProperty); + + private static Task UnsupportedAsync(DbActionKind actionKind) + => Task.FromResult(FormEventDispatchResult.Failure( + $"Form action '{actionKind}' requires a rendered form runtime.")); +} diff --git a/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor b/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor index 51d7439e..df009608 100644 --- a/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor @@ -11,7 +11,11 @@ } else { - + } @code { @@ -35,6 +39,18 @@ else return Task.CompletedTask; } + private Task OpenForm(FormOpenRequest request) + { + TabManager.OpenFormEntryTab(request.FormId, request.FormName); + return Task.CompletedTask; + } + + private Task CloseForm(FormCloseRequest request) + { + TabManager.CloseTab(Tab.Id); + return Task.CompletedTask; + } + private void OnDatabaseChanged() { _renderKey = BuildRenderKey(); diff --git a/src/CSharpDB.Primitives/DbActions.cs b/src/CSharpDB.Primitives/DbActions.cs index eee0d3a2..b94ad11a 100644 --- a/src/CSharpDB.Primitives/DbActions.cs +++ b/src/CSharpDB.Primitives/DbActions.cs @@ -14,6 +14,16 @@ public enum DbActionKind NextRecord, GoToRecord, RunActionSequence, + OpenForm, + CloseForm, + ApplyFilter, + ClearFilter, + RunSql, + RunProcedure, + SetControlProperty, + SetControlVisibility, + SetControlEnabled, + SetControlReadOnly, } public sealed record DbActionSequence( diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs index 3fdbd903..61ab892f 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -310,6 +310,37 @@ public async Task CommandButton_SkipsBuiltInFormActionWhenConditionIsFalse() Assert.False(invoked); } + [Fact] + public void ControlRules_ApplyVisibilityAndTextEffects() + { + ControlDefinition label = new( + "statusLabel", + "label", + new Rect(10, 20, 120, 34), + null, + new PropertyBag(new Dictionary { ["text"] = "Ready" }), + null); + FormDefinition form = CreateForm( + label, + rules: + [ + new ControlRuleDefinition( + "closed-state", + "[Status] = 'Ready'", + [ + new ControlRuleEffect("statusLabel", "visible", false), + new ControlRuleEffect("statusLabel", "text", "Matched"), + ]), + ]); + var renderer = CreateRenderer(DbCommandRegistry.Empty, form); + + string style = InvokeNonPublic(renderer, "GetControlStyle", label); + string text = InvokeNonPublic(renderer, "GetProp", label, "text", "Fallback"); + + Assert.Contains("display: none", style, StringComparison.Ordinal); + Assert.Equal("Matched", text); + } + private static FormRenderer CreateRenderer( DbCommandRegistry commands, FormDefinition form, @@ -349,7 +380,8 @@ private static ControlDefinition CreateCommandButton(string commandName) private static FormDefinition CreateForm( ControlDefinition button, - IReadOnlyList? actionSequences = null) + IReadOnlyList? actionSequences = null, + IReadOnlyList? rules = null) => new( "orders-form", "Orders", @@ -358,7 +390,8 @@ private static FormDefinition CreateForm( "sig:orders", new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), [button], - ActionSequences: actionSequences); + ActionSequences: actionSequences, + Rules: rules); private static void SetProperty(object instance, string propertyName, object? value) { @@ -382,4 +415,11 @@ private static async Task InvokeNonPublicAsync(object instance, string methodNam ?? throw new InvalidOperationException($"Method '{methodName}' did not return a task."); await task; } + + private static T InvokeNonPublic(object instance, string methodName, params object?[] args) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + return (T)method.Invoke(instance, args)!; + } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs index 091ef921..9573e59f 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs @@ -409,6 +409,138 @@ Name TEXT NOT NULL Assert.Single(await db.QueryRowsAsync("SELECT * FROM Products")); } + [Fact] + public async Task Phase8Runtime_AppliesAndClearsFormFilter() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL, + Status TEXT NOT NULL + ); + INSERT INTO Products VALUES (1, 'Widget', 'Open'); + INSERT INTO Products VALUES (2, 'Gadget', 'Closed'); + INSERT INTO Products VALUES (3, 'Sprocket', 'Open'); + """); + + DataEntry component = await CreateComponentAsync( + form: CreateForm("products-form", "Products"), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: new DbFormRecordService(db.Client)); + + FormEventDispatchResult result = await ((IFormActionRuntime)component).ApplyFilterAsync( + CreateRuntimeContext(component), + "form", + "[Status] = @status AND [Id] > 1", + new Dictionary + { + ["parameters"] = new Dictionary { ["status"] = "Open" }, + }, + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal([3L], ReadRecords(component).Select(row => (long)row["Id"]!).ToArray()); + + result = await ((IFormActionRuntime)component).ClearFilterAsync( + CreateRuntimeContext(component), + "form", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal([1L, 2L, 3L], ReadRecords(component).Select(row => (long)row["Id"]!).ToArray()); + } + + [Fact] + public async Task Phase8Runtime_SetsControlPropertyAndBoundValue() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + INSERT INTO Products VALUES (1, 'Widget'); + """); + + ControlDefinition control = new( + "nameBox", + "text", + new Rect(0, 0, 120, 32), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + ValidationOverride: null); + DataEntry component = await CreateComponentAsync( + form: CreateForm("products-form", "Products", [control]), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: new DbFormRecordService(db.Client)); + + FormEventDispatchResult result = await ((IFormActionRuntime)component).SetControlPropertyAsync( + CreateRuntimeContext(component), + "nameBox", + "visible", + false, + CancellationToken.None); + + Assert.True(result.Succeeded); + var overrides = GetField>>(component, "_controlPropertyOverrides"); + Assert.False(Assert.IsType(overrides["nameBox"]["visible"])); + + result = await ((IFormActionRuntime)component).SetControlPropertyAsync( + CreateRuntimeContext(component), + "nameBox", + "value", + "Widget Pro", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal("Widget Pro", ReadCurrentRecord(component)["Name"]); + Assert.True(GetField(component, "_dirty")); + } + + [Fact] + public async Task Phase8Runtime_RunSqlRequiresHostOptInAndRefreshesRecord() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL, + Status TEXT NOT NULL + ); + INSERT INTO Products VALUES (1, 'Widget', 'Open'); + """); + + DataEntry component = await CreateComponentAsync( + form: CreateForm("products-form", "Products"), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: new DbFormRecordService(db.Client)); + SetProperty(component, nameof(DataEntry.DbClient), db.Client); + + FormEventDispatchResult result = await ((IFormActionRuntime)component).RunSqlAsync( + CreateRuntimeContext(component), + "UPDATE Products SET Status = @status WHERE Id = @id", + new Dictionary { ["status"] = "Closed", ["id"] = 1L }, + CancellationToken.None); + + Assert.False(result.Succeeded); + Assert.Contains("disabled", result.Message, StringComparison.OrdinalIgnoreCase); + + SetProperty(component, nameof(DataEntry.EnableSqlActions), true); + result = await ((IFormActionRuntime)component).RunSqlAsync( + CreateRuntimeContext(component), + "UPDATE Products SET Status = @status WHERE Id = @id", + new Dictionary { ["status"] = "Closed", ["id"] = 1L }, + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Equal("Closed", ReadCurrentRecord(component)["Status"]); + Assert.Equal("Closed", (await db.QueryRowsAsync("SELECT Status FROM Products WHERE Id = 1"))[0]["Status"]); + } + [Fact] public async Task ViewBackedForm_LoadsAndSearchesInReadOnlyMode() { @@ -469,7 +601,10 @@ private static async Task CreateComponentAsync( return component; } - private static FormDefinition CreateForm(string formId, string tableName) + private static FormDefinition CreateForm( + string formId, + string tableName, + IReadOnlyList? controls = null) => new( formId, $"{tableName} Form", @@ -477,7 +612,21 @@ private static FormDefinition CreateForm(string formId, string tableName) DefinitionVersion: 1, SourceSchemaSignature: $"sig:{tableName}", Layout: new LayoutDefinition("absolute", 8, SnapToGrid: false, []), - Controls: []); + Controls: controls ?? []); + + private static FormActionRuntimeContext CreateRuntimeContext(DataEntry component) + => new( + FormId: component.FormId, + FormName: null, + TableName: null, + EventName: "Test", + ActionSequenceName: "TestActions", + StepIndex: 0, + Record: ReadCurrentRecord(component), + BindingArguments: null, + RuntimeArguments: null, + StepArguments: null, + Metadata: new Dictionary()); private static Dictionary ReadCurrentRecord(DataEntry component) => GetField>(component, "_currentRecord"); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index cf741fe1..edc7ae98 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -223,6 +223,64 @@ public void FormEventBinding_WithActionSequence_RoundTrips() Assert.Equal("AuditReusableShip", reusable.Steps[0].CommandName); } + [Fact] + public void Phase8MacroActionsAndRules_RoundTrip() + { + var form = new FormDefinition( + "f-phase8", + "Phase 8 Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, []), + [ + new ControlDefinition( + "ordersGrid", + "childDataGrid", + new Rect(0, 0, 320, 180), + Binding: null, + Props: new PropertyBag(new Dictionary()), + ValidationOverride: null), + ], + EventBindings: + [ + new FormEventBinding( + FormEventKind.OnLoad, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.OpenForm, Target: "orders-detail"), + new DbActionStep(DbActionKind.ApplyFilter, Target: "ordersGrid", Value: "[Status] = 'Open'"), + new DbActionStep(DbActionKind.RunSql, Value: "UPDATE Orders SET Status = @status"), + new DbActionStep(DbActionKind.SetControlVisibility, Target: "ordersGrid", Value: true), + ], + Name: "LoadActions")), + ], + Rules: + [ + new ControlRuleDefinition( + "hide-grid", + "Status = 'Closed'", + [new ControlRuleEffect("ordersGrid", "visible", false)]), + ]); + + string json = JsonSerializer.Serialize(form, Options); + FormDefinition deserialized = JsonSerializer.Deserialize(json, Options)!; + + DbActionSequence sequence = deserialized.EventBindings![0].ActionSequence!; + Assert.Equal(DbActionKind.OpenForm, sequence.Steps[0].Kind); + Assert.Equal(DbActionKind.ApplyFilter, sequence.Steps[1].Kind); + Assert.Equal(DbActionKind.RunSql, sequence.Steps[2].Kind); + Assert.Equal(DbActionKind.SetControlVisibility, sequence.Steps[3].Kind); + Assert.Contains("\"kind\":\"openForm\"", json); + Assert.NotNull(deserialized.Rules); + ControlRuleDefinition rule = Assert.Single(deserialized.Rules); + Assert.Equal("hide-grid", rule.RuleId); + ControlRuleEffect effect = Assert.Single(rule.Effects); + Assert.Equal("ordersGrid", effect.ControlId); + Assert.Equal("visible", effect.Property); + } + [Fact] public void FormAutomationMetadata_NormalizeForExport_RoundTrips() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs index 73befade..4477e29b 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormEventDispatcherTests.cs @@ -414,6 +414,78 @@ [new DbActionStep(DbActionKind.RunCommand, CommandName: "MissingCommand")])), Assert.Contains("Unknown form command 'MissingCommand'", result.Message); } + [Fact] + public async Task DispatchAsync_Phase8ActionsUseTypedRuntime() + { + var runtime = new RecordingFormActionRuntime(); + var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty, runtime); + var form = CreateForm([ + new FormEventBinding( + FormEventKind.OnLoad, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep( + DbActionKind.OpenForm, + Target: "orders-detail", + Arguments: new Dictionary { ["mode"] = "dialog" }), + new DbActionStep( + DbActionKind.ApplyFilter, + Target: "orders-grid", + Value: "[Status] = 'Open'"), + new DbActionStep(DbActionKind.ClearFilter, Target: "orders-grid"), + new DbActionStep( + DbActionKind.RunSql, + Value: "UPDATE Orders SET Status = @status WHERE Id = @id", + Arguments: new Dictionary { ["status"] = "Ready" }), + new DbActionStep(DbActionKind.RunProcedure, Target: "RepriceOrder"), + new DbActionStep(DbActionKind.SetControlVisibility, Target: "internalNotes", Value: false), + ], + Name: "LoadActions")), + ]); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.OnLoad, + new Dictionary { ["Id"] = 7L }, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal( + ["OpenForm:orders-detail", "ApplyFilter:orders-grid:[Status] = 'Open'", "ClearFilter:orders-grid", "RunSql:UPDATE Orders SET Status = @status WHERE Id = @id", "RunProcedure:RepriceOrder", "SetControlProperty:internalNotes.visible=False"], + runtime.Calls); + Assert.NotNull(runtime.LastContext); + Assert.Equal("customers-form", runtime.LastContext!.FormId); + Assert.Equal("Customers Form", runtime.LastContext.FormName); + Assert.Equal("OnLoad", runtime.LastContext.EventName); + Assert.Equal("LoadActions", runtime.LastContext.ActionSequenceName); + } + + [Fact] + public async Task DispatchAsync_Phase8ActionsFailClearlyWithoutRuntime() + { + var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty); + var form = CreateForm([ + new FormEventBinding( + FormEventKind.OnLoad, + string.Empty, + ActionSequence: new DbActionSequence( + [ + new DbActionStep(DbActionKind.OpenForm, Target: "orders-detail"), + ])), + ]); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.OnLoad, + new Dictionary { ["Id"] = 7L }, + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + Assert.Contains("OpenForm", result.Message); + Assert.Contains("rendered form runtime", result.Message); + } + private static FormDefinition CreateForm( IReadOnlyList eventBindings, IReadOnlyList? actionSequences = null) @@ -427,4 +499,73 @@ private static FormDefinition CreateForm( Controls: [], EventBindings: eventBindings, ActionSequences: actionSequences); + + private sealed class RecordingFormActionRuntime : IFormActionRuntime + { + public List Calls { get; } = []; + + public FormActionRuntimeContext? LastContext { get; private set; } + + public Task ExecuteRecordActionAsync( + FormActionRuntimeContext context, + DbActionStep step, + CancellationToken ct) + => RecordAsync(context, step.Kind.ToString()); + + public Task OpenFormAsync( + FormActionRuntimeContext context, + string formName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => RecordAsync(context, $"OpenForm:{formName}"); + + public Task CloseFormAsync( + FormActionRuntimeContext context, + string? formName, + CancellationToken ct) + => RecordAsync(context, $"CloseForm:{formName}"); + + public Task ApplyFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + => RecordAsync(context, $"ApplyFilter:{target}:{filter}"); + + public Task ClearFilterAsync( + FormActionRuntimeContext context, + string target, + CancellationToken ct) + => RecordAsync(context, $"ClearFilter:{target}"); + + public Task RunSqlAsync( + FormActionRuntimeContext context, + string sqlOrName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => RecordAsync(context, $"RunSql:{sqlOrName}"); + + public Task RunProcedureAsync( + FormActionRuntimeContext context, + string procedureName, + IReadOnlyDictionary arguments, + CancellationToken ct) + => RecordAsync(context, $"RunProcedure:{procedureName}"); + + public Task SetControlPropertyAsync( + FormActionRuntimeContext context, + string controlId, + string propertyName, + object? value, + CancellationToken ct) + => RecordAsync(context, $"SetControlProperty:{controlId}.{propertyName}={value}"); + + private Task RecordAsync(FormActionRuntimeContext context, string call) + { + LastContext = context; + Calls.Add(call); + return Task.FromResult(FormEventDispatchResult.Success()); + } + } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionManifestValidatorTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionManifestValidatorTests.cs new file mode 100644 index 00000000..63f9c2af --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionManifestValidatorTests.cs @@ -0,0 +1,153 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Tests.Services; + +public sealed class FormActionManifestValidatorTests +{ + [Fact] + public void Validate_ReturnsSuccessForSupportedPhase8Actions() + { + FormDefinition form = CreateForm([ + new DbActionStep(DbActionKind.OpenForm, Target: "orders-detail"), + new DbActionStep(DbActionKind.ApplyFilter, Target: "ordersGrid", Value: "[Status] = 'Open'"), + new DbActionStep(DbActionKind.ClearFilter, Target: "ordersGrid"), + new DbActionStep(DbActionKind.RunSql, Value: "UPDATE Orders SET Status = @status"), + new DbActionStep(DbActionKind.RunProcedure, Target: "RepriceOrder"), + new DbActionStep( + DbActionKind.SetControlProperty, + Target: "ordersGrid", + Value: false, + Arguments: new Dictionary { ["property"] = "visible" }), + ]); + var capabilities = FormActionRuntimeCapabilities.RenderedForm with + { + RunSql = true, + RunProcedure = true, + }; + + FormActionValidationResult result = FormActionManifestValidator.Validate( + form, + new FormActionValidationOptions( + RuntimeCapabilities: capabilities, + AvailableForms: ["orders-detail"], + AvailableProcedures: ["RepriceOrder"])); + + Assert.True(result.Succeeded); + Assert.Empty(result.Issues); + } + + [Fact] + public void Validate_ReportsActionReadinessIssues() + { + FormDefinition form = CreateForm( + [ + new DbActionStep(DbActionKind.OpenForm, Target: "missing-form"), + new DbActionStep(DbActionKind.ApplyFilter, Target: "missingGrid", Value: "[Status = 'Open'"), + new DbActionStep(DbActionKind.RunSql), + new DbActionStep(DbActionKind.RunProcedure, Target: "MissingProcedure"), + new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "MissingSequence"), + new DbActionStep(DbActionKind.SetControlReadOnly, Target: "missingControl", Value: true), + ], + rules: + [ + new ControlRuleDefinition( + "hide-internal", + "Status = 'Closed'", + [ + new ControlRuleEffect("missingControl", "notAProperty", true), + ]), + ]); + + FormActionValidationResult result = FormActionManifestValidator.Validate( + form, + new FormActionValidationOptions( + RuntimeCapabilities: FormActionRuntimeCapabilities.None, + AvailableForms: ["orders-detail"], + AvailableProcedures: ["RepriceOrder"])); + + Assert.False(result.Succeeded); + Assert.Contains(result.Issues, issue => issue.Severity == FormActionValidationSeverity.Warning && issue.ActionKind == DbActionKind.OpenForm); + Assert.Contains(result.Issues, issue => issue.Message.Contains("OpenForm target 'missing-form'", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("Unknown control 'missingGrid'", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("malformed", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("RunSql action requires SQL", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("Procedure 'MissingProcedure'", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("Unknown form action sequence 'MissingSequence'", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("Control rule 'hide-internal' targets unknown control", StringComparison.Ordinal)); + Assert.Contains(result.Issues, issue => issue.Message.Contains("unsupported property 'notAProperty'", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_ReportsAmbiguousReusableSequences() + { + FormDefinition form = CreateForm( + [new DbActionStep(DbActionKind.RunActionSequence, SequenceName: "Prepare")], + actionSequences: + [ + new DbActionSequence([], "Prepare"), + new DbActionSequence([], "prepare"), + ]); + + FormActionValidationResult result = FormActionManifestValidator.Validate(form); + + Assert.False(result.Succeeded); + Assert.Contains(result.Issues, issue => issue.Message.Contains("ambiguous", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void Validate_ReportsUnknownFilterFieldsWhenSchemaIsAvailable() + { + FormDefinition form = CreateForm([ + new DbActionStep(DbActionKind.ApplyFilter, Target: "form", Value: "[MissingStatus] = 'Open'"), + ]); + var schema = new FormTableDefinition( + "Orders", + "orders:v1", + [new FormFieldDefinition("Status", FieldDataType.String, IsNullable: false, IsReadOnly: false)], + PrimaryKey: [], + ForeignKeys: []); + + FormActionValidationResult result = FormActionManifestValidator.Validate( + form, + new FormActionValidationOptions( + RuntimeCapabilities: FormActionRuntimeCapabilities.RenderedForm, + Schema: schema)); + + Assert.False(result.Succeeded); + Assert.Contains(result.Issues, issue => issue.Message.Contains("unknown field 'MissingStatus'", StringComparison.OrdinalIgnoreCase)); + } + + private static FormDefinition CreateForm( + IReadOnlyList steps, + IReadOnlyList? actionSequences = null, + IReadOnlyList? rules = null) + => new( + "orders-form", + "Orders Form", + "Orders", + DefinitionVersion: 1, + SourceSchemaSignature: "orders:v1", + Layout: new LayoutDefinition("absolute", 8, SnapToGrid: false, []), + Controls: + [ + new ControlDefinition( + "ordersGrid", + "childDataGrid", + new Rect(0, 0, 300, 120), + Binding: null, + Props: new PropertyBag(new Dictionary()), + ValidationOverride: null), + ], + EventBindings: + [ + new FormEventBinding( + FormEventKind.OnLoad, + string.Empty, + ActionSequence: new DbActionSequence(steps, "LoadActions")), + ], + ActionSequences: actionSequences, + Rules: rules); +} From a81db07ee18b58670fbb40b49db942e817eb16a0 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 22:02:06 -0700 Subject: [PATCH 21/39] feat: Enhance FormOpenRequest with additional parameters and update DataEntry component - Added optional parameters (Mode, RecordId, FilterExpression) to FormOpenRequest. - Updated DataEntry component to accept InitialRecordId, InitialMode, InitialFilterExpression, and InitialFilterParameters. - Implemented logic to apply initial entry state based on new parameters in DataEntry. - Enhanced TabManagerService to manage initial state for reopened tabs. - Added tests for new functionality including initial record loading and filter application in DataEntry. - Introduced FormActionDiagnostics to track action sequence execution and emit diagnostic events. --- .../access-style-macro-actions.md | 87 +++++++ samples/trusted-csharp-host/README.md | 4 + .../access-style-macro-form.json | 178 +++++++++++++ .../Components/Designer/ChildDataGrid.razor | 99 ++++++- .../Designer/ControlRulesEditor.razor | 246 ++++++++++++++++++ .../Components/Designer/DesignerState.cs | 13 +- .../Components/Designer/FormRenderer.razor | 11 + .../Designer/MacroValidationPanel.razor | 45 ++++ .../Designer/PropertyInspector.razor | 12 + .../Contracts/ControlFilterState.cs | 5 + .../Contracts/FormActionDiagnostics.cs | 139 ++++++++++ .../Contracts/FormActionRequests.cs | 5 +- .../Pages/DataEntry.razor | 225 +++++++++++++++- src/CSharpDB.Admin.Forms/Pages/Designer.razor | 10 + .../Services/FormActionManifestValidator.cs | 7 +- .../Services/FormActionSequenceExecutor.cs | 179 +++++++++---- .../Components/Tabs/FormEntryTab.razor | 12 +- src/CSharpDB.Admin/Models/TabDescriptor.cs | 24 ++ .../Services/TabManagerService.cs | 33 ++- .../Admin/TabManagerServiceTests.cs | 21 ++ .../Components/Designer/ChildDataGridTests.cs | 39 ++- .../Components/Designer/DesignerStateTests.cs | 49 ++++ .../Pages/DataEntryTests.cs | 96 ++++++- .../Services/FormActionDiagnosticsTests.cs | 81 ++++++ 24 files changed, 1547 insertions(+), 73 deletions(-) create mode 100644 docs/trusted-csharp-functions/access-style-macro-actions.md create mode 100644 samples/trusted-csharp-host/access-style-macro-form.json create mode 100644 src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor create mode 100644 src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor create mode 100644 src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs create mode 100644 src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs diff --git a/docs/trusted-csharp-functions/access-style-macro-actions.md b/docs/trusted-csharp-functions/access-style-macro-actions.md new file mode 100644 index 00000000..f3a68f36 --- /dev/null +++ b/docs/trusted-csharp-functions/access-style-macro-actions.md @@ -0,0 +1,87 @@ +# Access-Style Macro Actions + +Phase 8 extends Admin Forms action sequences with Access-style UI and data actions. The action metadata still stays declarative: host applications own executable C# callbacks, SQL/procedure opt-in policy, database connections, and navigation behavior. + +## Supported Actions + +| Action | Runtime behavior | +| --- | --- | +| `openForm` | Resolves a form by id or name and asks the host to open it. Arguments can include `mode`, `recordId`, `primaryKey`, `id`, `filter`, or `where`. | +| `closeForm` | Asks the host to close the active form entry tab or surface. | +| `applyFilter` | Filters the current form record list when `target` is `form`; filters a rendered `datagrid` control when `target` is that control id. | +| `clearFilter` | Clears the form or data-grid filter selected by `target`. | +| `runSql` | Executes SQL only when the host enables SQL actions. `@name` parameters are resolved from action arguments. | +| `runProcedure` | Executes a named database procedure only when the host enables procedure actions. | +| `setControlProperty` | Overrides rendered control properties such as `visible`, `enabled`, `readOnly`, `text`, `placeholder`, and bound `value`. | +| `setControlVisibility`, `setControlEnabled`, `setControlReadOnly` | Short forms for the corresponding `setControlProperty` calls. | + +Existing actions such as `setFieldValue`, `runCommand`, `runActionSequence`, `newRecord`, `saveRecord`, `deleteRecord`, `refreshRecords`, `previousRecord`, `nextRecord`, and `goToRecord` continue to work in the same action sequence model. + +## Filter Expressions + +Filters use the same bracketed field expression style as conditions: + +```json +{ + "kind": "applyFilter", + "target": "ordersGrid", + "value": "[Status] = @status AND [Total] > @minimum", + "arguments": { + "status": "Open", + "minimum": 100 + } +} +``` + +Use `target: "form"` for the parent form list. Use a DataGrid control id for child-row filtering. + +## Open Form Arguments + +`openForm` carries navigation arguments to the host: + +```json +{ + "kind": "openForm", + "target": "Orders Entry", + "arguments": { + "recordId": "$record.Id", + "mode": "view", + "filter": "[Status] = 'Open'" + } +} +``` + +The built-in admin tab host forwards those values to `DataEntry` as initial state. `mode: "new"` starts a writable form on a new record. `recordId` navigates to the requested primary key after load. `filter` or `where` applies an initial form filter. + +## Conditional UI Rules + +Form-level `rules` apply control property effects whenever their condition is true: + +```json +{ + "ruleId": "archived-state", + "condition": "[Status] = 'Archived'", + "effects": [ + { "controlId": "statusBox", "property": "readOnly", "value": true }, + { "controlId": "archiveButton", "property": "enabled", "value": false } + ] +} +``` + +The designer property inspector includes a rules editor and a validation panel for action/rule readiness. + +## Diagnostics + +Subscribe to `FormActionDiagnostics.Listener` to observe action execution: + +```csharp +using CSharpDB.Admin.Forms.Contracts; + +using IDisposable subscription = FormActionDiagnostics.Listener.Subscribe(observer); +``` + +Events use `FormActionDiagnostics.InvocationEventName` and carry `FormActionInvocationDiagnostic`, including action kind, target, form id, event name, action sequence name, step index, elapsed time, success state, cancellation state, result message, exception message, and metadata. + +## Sample + +See `samples/trusted-csharp-host/access-style-macro-form.json` for a form manifest that combines open form, data-grid filtering, SQL execution, control property changes, and conditional UI rules. diff --git a/samples/trusted-csharp-host/README.md b/samples/trusted-csharp-host/README.md index 59d1788e..9253fa36 100644 --- a/samples/trusted-csharp-host/README.md +++ b/samples/trusted-csharp-host/README.md @@ -14,6 +14,8 @@ It demonstrates: - generating starter C# registration stubs from automation metadata - running an Admin Forms action sequence that sets a field - invoking a reusable named Admin Forms action sequence that calls the command +- inspecting an Access-style macro form manifest with open form, filter, + run SQL, and conditional UI rule actions - inspecting callback arguments and metadata in console output The sample keeps the important runtime boundary visible: C# callback bodies live @@ -52,5 +54,7 @@ and action sequence. - `Program.cs` contains the host registration code, metadata validation, stub generation, and runnable demo. +- `access-style-macro-form.json` contains a Phase 8 form manifest with richer + macro actions and conditional UI rules. - `.vscode/launch.json` launches the sample under the debugger. - `.vscode/tasks.json` builds and runs the sample from VS Code tasks. diff --git a/samples/trusted-csharp-host/access-style-macro-form.json b/samples/trusted-csharp-host/access-style-macro-form.json new file mode 100644 index 00000000..211f6fa0 --- /dev/null +++ b/samples/trusted-csharp-host/access-style-macro-form.json @@ -0,0 +1,178 @@ +{ + "formId": "products-entry-access-macros", + "name": "Products Entry Access Macros", + "tableName": "Products", + "definitionVersion": 1, + "sourceSchemaSignature": "sample:products:v1", + "layout": { + "layoutMode": "absolute", + "gridSize": 8, + "snapToGrid": true, + "breakpoints": [] + }, + "controls": [ + { + "controlId": "statusBox", + "controlType": "text", + "rect": { "x": 24, "y": 24, "width": 180, "height": 32 }, + "binding": { "fieldName": "Status", "mode": "TwoWay" }, + "props": { "placeholder": "Status" } + }, + { + "controlId": "openOrdersButton", + "controlType": "commandButton", + "rect": { "x": 224, "y": 24, "width": 120, "height": 32 }, + "props": { "text": "Open Orders" }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "OpenOrders", + "steps": [ + { + "kind": "openForm", + "target": "Orders Entry", + "arguments": { + "recordId": "$record.Id", + "mode": "view" + } + } + ] + } + } + ] + }, + { + "controlId": "showOpenOrdersButton", + "controlType": "commandButton", + "rect": { "x": 24, "y": 72, "width": 152, "height": 32 }, + "props": { "text": "Show Open" }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "ShowOpenOrders", + "steps": [ + { + "kind": "applyFilter", + "target": "ordersGrid", + "value": "[Status] = @status", + "arguments": { + "status": "Open" + } + }, + { + "kind": "setControlVisibility", + "target": "clearOrdersFilterButton", + "value": true + } + ] + } + } + ] + }, + { + "controlId": "clearOrdersFilterButton", + "controlType": "commandButton", + "rect": { "x": 184, "y": 72, "width": 152, "height": 32 }, + "props": { + "text": "Clear Filter", + "visible": false + }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "ClearOrdersFilter", + "steps": [ + { + "kind": "clearFilter", + "target": "ordersGrid" + }, + { + "kind": "setControlVisibility", + "target": "clearOrdersFilterButton", + "value": false + } + ] + } + } + ] + }, + { + "controlId": "archiveButton", + "controlType": "commandButton", + "rect": { "x": 344, "y": 72, "width": 120, "height": 32 }, + "props": { "text": "Archive" }, + "eventBindings": [ + { + "event": "onClick", + "commandName": "", + "stopOnFailure": true, + "actionSequence": { + "name": "ArchiveProduct", + "steps": [ + { + "kind": "runSql", + "value": "UPDATE Products SET Status = @status WHERE Id = @id", + "arguments": { + "status": "Archived", + "id": "$record.Id", + "refresh": true + } + }, + { + "kind": "setControlProperty", + "target": "statusBox", + "value": "Archived", + "arguments": { + "property": "value" + } + } + ] + } + } + ] + }, + { + "controlId": "ordersGrid", + "controlType": "datagrid", + "rect": { "x": 24, "y": 128, "width": 720, "height": 260 }, + "props": { + "childTable": "Orders", + "dataGridMode": "related", + "foreignKeyField": "ProductId", + "parentKeyField": "Id", + "visibleColumns": [ "OrderId", "Status", "Total" ], + "allowAdd": true, + "allowEdit": true, + "allowDelete": false + } + } + ], + "rules": [ + { + "ruleId": "archived-state", + "condition": "[Status] = 'Archived'", + "description": "Archived products cannot be edited from the entry form.", + "effects": [ + { + "controlId": "statusBox", + "property": "readOnly", + "value": true + }, + { + "controlId": "archiveButton", + "property": "enabled", + "value": false + } + ] + } + ] +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor index 33cdcdfa..ad3870cb 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ChildDataGrid.razor @@ -1,5 +1,6 @@ @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Services @inject IFormRecordService RecordService
@@ -121,6 +122,8 @@ [Parameter] public bool EnableRowSelection { get; set; } [Parameter] public EventCallback?> OnRowSelected { get; set; } [Parameter] public EventCallback OnRowsChanged { get; set; } + [Parameter] public string? FilterExpression { get; set; } + [Parameter] public IReadOnlyDictionary? FilterParameters { get; set; } private List> _rows = []; private bool _loading = true; @@ -133,12 +136,15 @@ private string? _lastChildTableName; private string? _lastForeignKeyField; private string? _lastChildTableDefinitionName; + private string? _lastFilterExpression; + private string _lastFilterParametersKey = string.Empty; private bool _lastIsStandalone; private int _pageNumber = 1; private int _pageSize = 25; private int _totalCount; private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalCount / (double)_pageSize)); + private bool HasActiveFilter => !string.IsNullOrWhiteSpace(FilterExpression); protected override async Task OnParametersSetAsync() { @@ -146,17 +152,22 @@ bool childTableChanged = !string.Equals(_lastChildTableName, ChildTableName, StringComparison.OrdinalIgnoreCase); bool foreignKeyChanged = !string.Equals(_lastForeignKeyField, ForeignKeyField, StringComparison.OrdinalIgnoreCase); bool definitionChanged = !string.Equals(_lastChildTableDefinitionName, ChildFormTableDefinition?.TableName, StringComparison.OrdinalIgnoreCase); + string filterParametersKey = FormatFilterParameterKey(FilterParameters); + bool filterChanged = !string.Equals(_lastFilterExpression, FilterExpression, StringComparison.Ordinal) || + !string.Equals(_lastFilterParametersKey, filterParametersKey, StringComparison.Ordinal); bool modeChanged = _lastIsStandalone != IsStandalone; - if (parentChanged || childTableChanged || foreignKeyChanged || definitionChanged || modeChanged) + if (parentChanged || childTableChanged || foreignKeyChanged || definitionChanged || filterChanged || modeChanged) { - if (childTableChanged || definitionChanged || modeChanged) + if (childTableChanged || definitionChanged || filterChanged || modeChanged) _pageNumber = 1; _lastParentKeyValue = ParentKeyValue; _lastChildTableName = ChildTableName; _lastForeignKeyField = ForeignKeyField; _lastChildTableDefinitionName = ChildFormTableDefinition?.TableName; + _lastFilterExpression = FilterExpression; + _lastFilterParametersKey = filterParametersKey; _lastIsStandalone = IsStandalone; await LoadChildRecords(); } @@ -182,6 +193,24 @@ if (IsStandalone) { + if (TryGetActiveFilter(out FormFilterExpression? standaloneFilter, out IReadOnlyDictionary standaloneParameters, out string? standaloneFilterError)) + { + if (standaloneFilterError is not null) + throw new InvalidOperationException(standaloneFilterError); + + List> filteredRows = (await RecordService.ListRecordsAsync(ChildFormTableDefinition)) + .Where(row => standaloneFilter!.Evaluate(row, standaloneParameters)) + .ToList(); + int totalPages = filteredRows.Count == 0 ? 1 : (int)Math.Ceiling(filteredRows.Count / (double)_pageSize); + _pageNumber = Math.Clamp(_pageNumber, 1, totalPages); + _totalCount = filteredRows.Count; + _rows = filteredRows + .Skip((_pageNumber - 1) * _pageSize) + .Take(_pageSize) + .ToList(); + return; + } + FormRecordPage page = await RecordService.ListRecordPageAsync(ChildFormTableDefinition, _pageNumber, _pageSize); _pageNumber = page.PageNumber; _pageSize = page.PageSize; @@ -194,6 +223,16 @@ throw new InvalidOperationException("The related DataGrid foreign key field is not configured."); _rows = await RecordService.ListFilteredRecordsAsync(ChildFormTableDefinition, ForeignKeyField, ParentKeyValue); + if (TryGetActiveFilter(out FormFilterExpression? relatedFilter, out IReadOnlyDictionary relatedParameters, out string? relatedFilterError)) + { + if (relatedFilterError is not null) + throw new InvalidOperationException(relatedFilterError); + + _rows = _rows + .Where(row => relatedFilter!.Evaluate(row, relatedParameters)) + .ToList(); + } + _totalCount = _rows.Count; } catch (Exception ex) @@ -227,9 +266,11 @@ } var created = await RecordService.CreateRecordAsync(ChildFormTableDefinition, newRow); - if (IsStandalone) + if (IsStandalone || HasActiveFilter) { - _pageNumber = GetTotalPages(_totalCount + 1, _pageSize); + if (IsStandalone) + _pageNumber = GetTotalPages(_totalCount + 1, _pageSize); + await LoadChildRecords(); } else @@ -445,4 +486,54 @@ private static int GetTotalPages(int totalCount, int pageSize) => Math.Max(1, (int)Math.Ceiling(totalCount / (double)pageSize)); + + private bool TryGetActiveFilter( + out FormFilterExpression? filter, + out IReadOnlyDictionary parameters, + out string? error) + { + filter = null; + parameters = FilterParameters ?? EmptyObjectDictionary.Instance; + error = null; + + if (!HasActiveFilter) + return false; + + if (!FormFilterExpression.TryParse(FilterExpression!, ChildFormTableDefinition, out filter, out string? parseError)) + { + error = $"Invalid DataGrid filter: {parseError}"; + return true; + } + + IReadOnlyDictionary parameterSnapshot = parameters; + string? missingParameter = filter!.Parameters.FirstOrDefault(parameter => !parameterSnapshot.ContainsKey(parameter)); + if (missingParameter is not null) + { + error = $"DataGrid filter is missing parameter '@{missingParameter}'."; + return true; + } + + return true; + } + + private static string FormatFilterParameterKey(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return string.Empty; + + return string.Join( + "\u001f", + parameters + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(pair => $"{pair.Key}={FormatFilterParameterValue(pair.Value)}")); + } + + private static string FormatFilterParameterValue(object? value) + => value is null ? "" : $"{value.GetType().FullName}:{value}"; + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor new file mode 100644 index 00000000..6997a43b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ControlRulesEditor.razor @@ -0,0 +1,246 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Serialization + +
+ @if (Rules.Count == 0) + { +
No control rules
+ } + + @for (int i = 0; i < Rules.Count; i++) + { + var ruleIndex = i; + var rule = Rules[ruleIndex]; +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + @for (int effectIndex = 0; effectIndex < rule.Effects.Count; effectIndex++) + { + var idx = effectIndex; + var effect = rule.Effects[idx]; +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ } + + +
+
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList Rules { get; set; } = []; + [Parameter] public IReadOnlyList Controls { get; set; } = []; + [Parameter] public EventCallback> RulesChanged { get; set; } + + private static readonly string[] SupportedProperties = + [ + "visible", + "enabled", + "readOnly", + "required", + "styleVariant", + "validationMessage", + "text", + "value", + "placeholder", + ]; + + private async Task AddRule() + { + var updated = Rules + .Append(new ControlRuleDefinition(NextRuleId(), string.Empty, [])) + .ToList(); + await RulesChanged.InvokeAsync(updated); + } + + private async Task RemoveRule(int index) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + await RulesChanged.InvokeAsync(updated); + } + + private Task UpdateRuleId(int index, ControlRuleDefinition rule, string value) + => ReplaceRule(index, rule with { RuleId = value.Trim() }); + + private Task UpdateDescription(int index, ControlRuleDefinition rule, string? value) + => ReplaceRule(index, rule with { Description = string.IsNullOrWhiteSpace(value) ? null : value.Trim() }); + + private Task UpdateCondition(int index, ControlRuleDefinition rule, string value) + => ReplaceRule(index, rule with { Condition = value.Trim() }); + + private async Task AddEffect(int ruleIndex, ControlRuleDefinition rule) + { + string controlId = Controls.FirstOrDefault()?.ControlId ?? string.Empty; + var updatedEffects = rule.Effects + .Append(new ControlRuleEffect(controlId, "visible", true)) + .ToList(); + await ReplaceRule(ruleIndex, rule with { Effects = updatedEffects }); + } + + private async Task RemoveEffect(int ruleIndex, int effectIndex, ControlRuleDefinition rule) + { + var updatedEffects = rule.Effects.ToList(); + if (effectIndex < 0 || effectIndex >= updatedEffects.Count) + return; + + updatedEffects.RemoveAt(effectIndex); + await ReplaceRule(ruleIndex, rule with { Effects = updatedEffects }); + } + + private Task UpdateEffectControl(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect, string value) + => ReplaceEffect(ruleIndex, effectIndex, rule, effect with { ControlId = value.Trim() }); + + private Task UpdateEffectProperty(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect, string value) + => ReplaceEffect(ruleIndex, effectIndex, rule, effect with { Property = value.Trim() }); + + private Task UpdateEffectValue(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect, string value) + => ReplaceEffect(ruleIndex, effectIndex, rule, effect with { Value = ParseValue(value) }); + + private async Task ReplaceEffect(int ruleIndex, int effectIndex, ControlRuleDefinition rule, ControlRuleEffect effect) + { + var updatedEffects = rule.Effects.ToList(); + if (effectIndex < 0 || effectIndex >= updatedEffects.Count) + return; + + updatedEffects[effectIndex] = effect; + await ReplaceRule(ruleIndex, rule with { Effects = updatedEffects }); + } + + private async Task ReplaceRule(int index, ControlRuleDefinition rule) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = rule; + await RulesChanged.InvokeAsync(updated); + } + + private string NextRuleId() + { + const string prefix = "Rule"; + HashSet existing = Rules + .Select(rule => rule.RuleId) + .Where(ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + for (int i = 1; ; i++) + { + string candidate = $"{prefix}{i}"; + if (!existing.Contains(candidate)) + return candidate; + } + } + + private bool ShouldRenderMissingControl(string controlId) + => !string.IsNullOrWhiteSpace(controlId) && + Controls.All(control => !string.Equals(control.ControlId, controlId, StringComparison.OrdinalIgnoreCase)); + + private static string GetControlLabel(ControlDefinition control) + => $"{control.ControlId} ({control.ControlType})"; + + private static string FormatValue(object? value) + => value switch + { + null => "null", + string text => text, + _ => JsonSerializer.Serialize(value, JsonDefaults.Options), + }; + + private static object? ParseValue(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + + try + { + using JsonDocument doc = JsonDocument.Parse(text); + return ReadJsonValue(doc.RootElement); + } + catch (JsonException) + { + return text; + } + } + + private static object? ReadJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + static property => property.Name, + static property => ReadJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(ReadJsonValue).ToArray(), + _ => value.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 8e52cc93..1f9c4247 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -8,6 +8,7 @@ public class DesignerState private readonly List _controls = []; private readonly List _eventBindings = []; private readonly List _actionSequences = []; + private readonly List _rules = []; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); @@ -21,6 +22,7 @@ public class DesignerState public IReadOnlyList Controls => _controls; public IReadOnlyList EventBindings => _eventBindings; public IReadOnlyList ActionSequences => _actionSequences; + public IReadOnlyList Rules => _rules; public HashSet SelectedIds { get; } = []; // Active tool from toolbox (null = select mode) @@ -89,6 +91,8 @@ public void LoadForm(FormDefinition form) _eventBindings.AddRange(form.EventBindings ?? []); _actionSequences.Clear(); _actionSequences.AddRange(form.ActionSequences ?? []); + _rules.Clear(); + _rules.AddRange(form.Rules ?? []); _undoStack.Clear(); _redoStack.Clear(); SelectedIds.Clear(); @@ -113,7 +117,7 @@ public FormDefinition ToFormDefinition() { return new FormDefinition( FormId, FormName, TableName, DefinitionVersion, SourceSchemaSignature, - Layout, _controls.ToList(), EventBindings: _eventBindings.ToList(), ActionSequences: _actionSequences.ToList()); + Layout, _controls.ToList(), EventBindings: _eventBindings.ToList(), ActionSequences: _actionSequences.ToList(), Rules: _rules.ToList()); } public void UpdateEventBindings(IReadOnlyList bindings) @@ -130,6 +134,13 @@ public void UpdateActionSequences(IReadOnlyList sequences) NotifyChanged(); } + public void UpdateRules(IReadOnlyList rules) + { + _rules.Clear(); + _rules.AddRange(rules); + NotifyChanged(); + } + public void UpdateControlEventBindings(string controlId, IReadOnlyList bindings) { var idx = _controls.FindIndex(c => c.ControlId == controlId); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 1e05281d..2ce85520 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -176,6 +176,7 @@ var dgAllowEdit = GetBoolProp(c, "allowEdit"); var dgAllowDelete = GetBoolProp(c, "allowDelete"); var parentPkValue = GetFieldObjectValue(dgPkField); + var dgFilter = GetControlFilter(c.ControlId); @if (!string.IsNullOrEmpty(dgChildTable) && dgIsStandalone) { @@ -188,6 +189,8 @@ AllowEdit="dgAllowEdit" AllowDelete="dgAllowDelete" ChildFormTableDefinition="dgTableDefinition" + FilterExpression="@dgFilter?.FilterExpression" + FilterParameters="@dgFilter?.Parameters" OnRowsChanged="OnChildRowsChanged" /> } else if (!string.IsNullOrEmpty(dgChildTable) && !string.IsNullOrEmpty(dgFkField) && parentPkValue is not null) @@ -201,6 +204,8 @@ AllowEdit="dgAllowEdit" AllowDelete="dgAllowDelete" ChildFormTableDefinition="dgTableDefinition" + FilterExpression="@dgFilter?.FilterExpression" + FilterParameters="@dgFilter?.Parameters" OnRowsChanged="OnChildRowsChanged" /> } else if (!dgIsStandalone && parentPkValue is null && !string.IsNullOrEmpty(dgChildTable)) @@ -254,6 +259,7 @@ [Parameter] public Func>? OnBuiltInAction { get; set; } [Parameter] public IFormActionRuntime? ActionRuntime { get; set; } [Parameter] public IReadOnlyDictionary>? ControlPropertyOverrides { get; set; } + [Parameter] public IReadOnlyDictionary? ControlFilters { get; set; } private readonly HashSet _executingCommandButtons = []; private const string LayoutModeElastic = "elastic"; @@ -721,6 +727,11 @@ .ToList(); } + private ControlFilterState? GetControlFilter(string controlId) + => ControlFilters?.TryGetValue(controlId, out ControlFilterState? filter) == true + ? filter + : null; + private bool IsControlVisible(ControlDefinition c) => GetBoolProp(c, "visible", fallback: true); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor b/src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor new file mode 100644 index 00000000..bc2ed172 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/MacroValidationPanel.razor @@ -0,0 +1,45 @@ +@using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Services + +@if (_issues.Count > 0) +{ +
+ @GetSummaryText() +
    + @foreach (FormActionValidationIssue issue in _issues.Take(6)) + { +
  • @issue.Message
  • + } +
+
+} + +@code { + [Parameter, EditorRequired] public FormDefinition Form { get; set; } = default!; + [Parameter] public FormTableDefinition? Schema { get; set; } + + private IReadOnlyList _issues = []; + + protected override void OnParametersSet() + { + FormActionValidationResult result = FormActionManifestValidator.Validate( + Form, + new FormActionValidationOptions( + RuntimeCapabilities: FormActionRuntimeCapabilities.RenderedForm, + Schema: Schema)); + _issues = result.Issues; + } + + private string GetSummaryText() + { + int errors = _issues.Count(issue => issue.Severity == FormActionValidationSeverity.Error); + int warnings = _issues.Count - errors; + if (errors > 0 && warnings > 0) + return $"{errors} macro error(s), {warnings} warning(s)"; + + return errors > 0 + ? $"{errors} macro error(s)" + : $"{warnings} macro warning(s)"; + } +} diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index 46843f76..df8af45c 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -51,6 +51,12 @@
+
+ + +
} else { @@ -899,6 +905,12 @@ return Task.CompletedTask; } + private Task OnRulesChanged(IReadOnlyList rules) + { + State.UpdateRules(rules); + return Task.CompletedTask; + } + private Task OnControlEventBindingsChanged(IReadOnlyList bindings) { if (_selected is not null) diff --git a/src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs b/src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs new file mode 100644 index 00000000..68be9154 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/ControlFilterState.cs @@ -0,0 +1,5 @@ +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record ControlFilterState( + string FilterExpression, + IReadOnlyDictionary Parameters); diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs new file mode 100644 index 00000000..a504eef9 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionDiagnostics.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Contracts; + +public sealed record FormActionInvocationDiagnostic( + DbActionKind ActionKind, + string? Target, + string? FormId, + string? FormName, + string? TableName, + string? EventName, + string? ActionSequenceName, + int StepIndex, + string? Location, + TimeSpan Elapsed, + bool Succeeded, + bool Canceled, + string? ResultMessage, + string? ExceptionMessage, + IReadOnlyDictionary Metadata); + +public static class FormActionDiagnostics +{ + public const string ListenerName = "CSharpDB.Admin.Forms.Actions"; + public const string InvocationEventName = "CSharpDB.Admin.Forms.Actions.Invocation"; + + public static DiagnosticListener Listener { get; } = new(ListenerName); + + public static bool IsInvocationEnabled + => Listener.IsEnabled(InvocationEventName); + + internal static long GetTimestamp() + => Stopwatch.GetTimestamp(); + + internal static TimeSpan GetElapsedTime(long startingTimestamp) + => Stopwatch.GetElapsedTime(startingTimestamp); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Form action diagnostics are emitted only for subscribed hosts; the strongly typed event payload is part of the public diagnostics contract.")] + internal static void WriteInvocation( + DbActionKind actionKind, + string? target, + IReadOnlyDictionary? metadata, + TimeSpan elapsed, + bool succeeded, + bool canceled, + string? resultMessage, + string? exceptionMessage) + { + if (!IsInvocationEnabled) + return; + + IReadOnlyDictionary metadataSnapshot = CopyMetadata(metadata); + Listener.Write( + InvocationEventName, + new FormActionInvocationDiagnostic( + actionKind, + string.IsNullOrWhiteSpace(target) ? null : target, + ReadMetadata(metadataSnapshot, "formId"), + ReadMetadata(metadataSnapshot, "formName"), + ReadMetadata(metadataSnapshot, "tableName"), + ReadMetadata(metadataSnapshot, "event"), + ReadMetadata(metadataSnapshot, "actionSequence"), + ReadStepIndex(metadataSnapshot), + BuildLocation(metadataSnapshot), + elapsed, + succeeded, + canceled, + resultMessage, + exceptionMessage, + metadataSnapshot)); + } + + private static IReadOnlyDictionary CopyMetadata( + IReadOnlyDictionary? metadata) + { + if (metadata is null || metadata.Count == 0) + return EmptyStringDictionary.Instance; + + return new Dictionary(metadata, StringComparer.OrdinalIgnoreCase); + } + + private static string? BuildLocation(IReadOnlyDictionary metadata) + { + if (TryReadMetadata(metadata, "location", out string? location)) + return location; + + string? eventName = ReadMetadata(metadata, "event"); + string? actionSequence = ReadMetadata(metadata, "actionSequence"); + string? actionStep = ReadMetadata(metadata, "actionStep"); + if (!string.IsNullOrWhiteSpace(actionSequence) && !string.IsNullOrWhiteSpace(actionStep)) + return $"actionSequences.{actionSequence}.steps[{actionStep}]"; + + string? controlId = ReadMetadata(metadata, "controlId"); + if (!string.IsNullOrWhiteSpace(controlId) && !string.IsNullOrWhiteSpace(eventName)) + return $"controls.{controlId}.events.{eventName}"; + + if (!string.IsNullOrWhiteSpace(eventName) && !string.IsNullOrWhiteSpace(actionStep)) + return $"events.{eventName}.actionSequence.steps[{actionStep}]"; + + if (!string.IsNullOrWhiteSpace(actionStep)) + return $"action.steps[{actionStep}]"; + + return null; + } + + private static int ReadStepIndex(IReadOnlyDictionary metadata) + => int.TryParse(ReadMetadata(metadata, "actionStep"), out int stepIndex) + ? stepIndex + : -1; + + private static string? ReadMetadata(IReadOnlyDictionary metadata, string key) + => TryReadMetadata(metadata, key, out string? value) ? value : null; + + private static bool TryReadMetadata( + IReadOnlyDictionary metadata, + string key, + out string? value) + { + if (metadata.TryGetValue(key, out string? raw) && !string.IsNullOrWhiteSpace(raw)) + { + value = raw; + return true; + } + + value = null; + return false; + } + + private static class EmptyStringDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs b/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs index c540c38c..8fbc17e9 100644 --- a/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs +++ b/src/CSharpDB.Admin.Forms/Contracts/FormActionRequests.cs @@ -3,7 +3,10 @@ namespace CSharpDB.Admin.Forms.Contracts; public sealed record FormOpenRequest( string FormId, string FormName, - IReadOnlyDictionary Arguments); + IReadOnlyDictionary Arguments, + string? Mode = null, + object? RecordId = null, + string? FilterExpression = null); public sealed record FormCloseRequest( string? FormId, diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index 13e8e626..79a12384 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -99,6 +99,7 @@ OnBuiltInAction="ExecuteBuiltInFormActionAsync" ActionRuntime="this" ControlPropertyOverrides="_controlPropertyOverrides" + ControlFilters="_controlFilters" OnChildRowsChanged="OnChildRowsChanged" />
} @@ -210,6 +211,10 @@ [Parameter] public EventCallback OnCloseForm { get; set; } [Parameter] public bool EnableSqlActions { get; set; } [Parameter] public bool EnableProcedureActions { get; set; } + [Parameter] public object? InitialRecordId { get; set; } + [Parameter] public string? InitialMode { get; set; } + [Parameter] public string? InitialFilterExpression { get; set; } + [Parameter] public IReadOnlyDictionary? InitialFilterParameters { get; set; } [Inject] public IFormEventDispatcher FormEvents { get; set; } = NullFormEventDispatcher.Instance; [Inject] public ICSharpDbClient? DbClient { get; set; } [Inject] public NavigationManager? Navigation { get; set; } @@ -246,7 +251,9 @@ private readonly List _computedControls = []; private readonly HashSet _computedFieldNames = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _controlPropertyOverrides = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _controlFilters = new(StringComparer.OrdinalIgnoreCase); private string? _loadedFormId; + private string _appliedInitialStateKey = string.Empty; private ElementReference _layoutRef; private ElementReference _recordListRef; @@ -273,11 +280,20 @@ protected override async Task OnParametersSetAsync() { - if (string.IsNullOrWhiteSpace(FormId) || string.Equals(_loadedFormId, FormId, StringComparison.Ordinal)) + if (string.IsNullOrWhiteSpace(FormId)) return; - _loadedFormId = FormId; - await LoadAsync(); + string initialStateKey = BuildInitialStateKey(); + if (!string.Equals(_loadedFormId, FormId, StringComparison.Ordinal)) + { + _loadedFormId = FormId; + _appliedInitialStateKey = string.Empty; + await LoadAsync(); + return; + } + + if (_form is not null && !string.Equals(_appliedInitialStateKey, initialStateKey, StringComparison.Ordinal)) + await ApplyInitialEntryStateAsync(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -323,6 +339,7 @@ _computedControls.Clear(); _computedFieldNames.Clear(); _controlPropertyOverrides.Clear(); + _controlFilters.Clear(); ClearFilterState(); try @@ -351,6 +368,8 @@ InitializeSearchState(); _page = 1; await LoadRecordPageAsync(_page); + if (_error is null) + await ApplyInitialEntryStateAsync(); if (_error is null) await DispatchFormEventAsync(FormEventKind.OnLoad, _currentRecord); } @@ -360,6 +379,94 @@ } } + private async Task ApplyInitialEntryStateAsync() + { + _appliedInitialStateKey = BuildInitialStateKey(); + if (_table is null) + return; + + string? mode = InitialMode?.Trim(); + if (string.Equals(mode, "new", StringComparison.OrdinalIgnoreCase)) + { + if (SupportsWriteOperations) + NewRecord(); + return; + } + + if (!string.IsNullOrWhiteSpace(InitialFilterExpression)) + { + if (!FormFilterExpression.TryParse(InitialFilterExpression, _table, out FormFilterExpression? parsed, out string? filterError)) + { + _error = $"Initial filter is invalid: {filterError}"; + return; + } + + IReadOnlyDictionary parameters = InitialFilterParameters ?? EmptyObjectDictionary.Instance; + string? missingParameter = parsed!.Parameters.FirstOrDefault(parameter => !parameters.ContainsKey(parameter)); + if (missingParameter is not null) + { + _error = $"Initial filter is missing parameter '@{missingParameter}'."; + return; + } + + _activeFilterExpression = InitialFilterExpression; + _activeFilter = parsed; + _activeFilterParameters = parameters; + ClearSearchState(clearPendingValue: true); + await LoadRecordPageAsync(1, preserveCurrentRecordWhenEmpty: _currentRecord.Count > 0 || _isNew); + } + + if (InitialRecordId is not null) + await NavigateToInitialRecordAsync(InitialRecordId); + } + + private async Task NavigateToInitialRecordAsync(object initialRecordId) + { + FormFieldDefinition? primaryKeyField = GetPrimaryKeyField(); + if (primaryKeyField is null) + { + _error = "Initial record navigation requires a form source with a single primary key column."; + return; + } + + string rawValue = NormalizeActionValue(initialRecordId)?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(rawValue)) + return; + + if (!TryParseFieldValue(primaryKeyField, rawValue, out object? parsedValue, out string? validationError) || parsedValue is null) + { + _error = validationError ?? $"'{rawValue}' is not a valid {GetPrimaryKeyLabel()} value."; + return; + } + + ClearSearchState(clearPendingValue: true); + await NavigateToRecordAsync(parsedValue); + } + + private string BuildInitialStateKey() + => string.Join( + "|", + FormId, + InitialMode?.Trim() ?? string.Empty, + FormatInitialStateValue(InitialRecordId), + InitialFilterExpression?.Trim() ?? string.Empty, + FormatInitialParameterKey(InitialFilterParameters)); + + private static string FormatInitialParameterKey(IReadOnlyDictionary? parameters) + { + if (parameters is null || parameters.Count == 0) + return string.Empty; + + return string.Join( + "\u001f", + parameters + .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase) + .Select(pair => $"{pair.Key}={FormatInitialStateValue(pair.Value)}")); + } + + private static string FormatInitialStateValue(object? value) + => value is null ? "" : $"{value.GetType().FullName}:{value}"; + private async Task>> LoadChoicesAsync(FormDefinition form, FormTableDefinition table) { var choices = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -749,7 +856,10 @@ return FormEventDispatchResult.Success(); } - await OnOpenForm.InvokeAsync(new FormOpenRequest(targetForm.FormId, targetForm.Name, resolvedArguments)); + string? mode = TryReadArgumentText(resolvedArguments, "mode", "openMode"); + object? recordId = TryReadArgumentValue(resolvedArguments, "recordId", "primaryKey", "id"); + string? filterExpression = TryReadArgumentText(resolvedArguments, "filter", "where"); + await OnOpenForm.InvokeAsync(new FormOpenRequest(targetForm.FormId, targetForm.Name, resolvedArguments, mode, recordId, filterExpression)); return FormEventDispatchResult.Success(); } @@ -778,7 +888,7 @@ return FormEventDispatchResult.Failure("ApplyFilter action requires a loaded form source."); if (!IsCurrentFormTarget(target)) - return FormEventDispatchResult.Failure($"ApplyFilter target '{target}' is not supported by this rendered form runtime."); + return await ApplyControlFilterAsync(context, target, filter, arguments, ct); if (!FormFilterExpression.TryParse(filter, _table, out FormFilterExpression? parsed, out string? filterError)) return FormEventDispatchResult.Failure($"ApplyFilter action has an invalid filter expression: {filterError}"); @@ -807,7 +917,7 @@ return FormEventDispatchResult.Failure("ClearFilter action requires a loaded form source."); if (!IsCurrentFormTarget(target)) - return FormEventDispatchResult.Failure($"ClearFilter target '{target}' is not supported by this rendered form runtime."); + return await ClearControlFilterAsync(target, ct); object? currentPk = TryGetPrimaryKeyValue(_currentRecord, out object? pkValue) ? pkValue : null; ClearFilterState(); @@ -970,6 +1080,90 @@ string.Equals(target, _form?.FormId, StringComparison.OrdinalIgnoreCase) || string.Equals(target, _form?.Name, StringComparison.OrdinalIgnoreCase); + private async Task ApplyControlFilterAsync( + FormActionRuntimeContext context, + string target, + string filter, + IReadOnlyDictionary arguments, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (!TryResolveDataGridFilterTarget(target, out ControlDefinition? control, out FormTableDefinition? table, out string? targetError)) + return FormEventDispatchResult.Failure(targetError ?? $"ApplyFilter target '{target}' is not supported by this rendered form runtime."); + + if (!FormFilterExpression.TryParse(filter, table, out FormFilterExpression? parsed, out string? filterError)) + return FormEventDispatchResult.Failure($"ApplyFilter action has an invalid filter expression for control '{control!.ControlId}': {filterError}"); + + IReadOnlyDictionary resolvedArguments = BuildActionParameterDictionary(context, arguments); + string? missingParameter = parsed!.Parameters.FirstOrDefault(parameter => !resolvedArguments.ContainsKey(parameter)); + if (missingParameter is not null) + return FormEventDispatchResult.Failure($"ApplyFilter action is missing parameter '@{missingParameter}'."); + + _controlFilters[control!.ControlId] = new ControlFilterState(filter, resolvedArguments); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + private async Task ClearControlFilterAsync(string target, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (!TryResolveDataGridFilterTarget(target, out ControlDefinition? control, out _, out string? targetError)) + return FormEventDispatchResult.Failure(targetError ?? $"ClearFilter target '{target}' is not supported by this rendered form runtime."); + + _controlFilters.Remove(control!.ControlId); + await RefreshDataEntryStateAsync(); + return FormEventDispatchResult.Success(); + } + + private bool TryResolveDataGridFilterTarget( + string target, + out ControlDefinition? control, + out FormTableDefinition? table, + out string? error) + { + control = null; + table = null; + error = null; + + if (_form is null) + { + error = "Control filter actions require a loaded form."; + return false; + } + + control = _form.Controls.FirstOrDefault(candidate => string.Equals(candidate.ControlId, target, StringComparison.OrdinalIgnoreCase)); + if (control is null) + { + error = $"Filter target '{target}' does not match the current form or a known control."; + return false; + } + + if (!string.Equals(control.ControlType, "datagrid", StringComparison.OrdinalIgnoreCase)) + { + error = $"Filter target '{target}' is not a DataGrid control."; + return false; + } + + string? childTableName = control.Props.Values.TryGetValue("childTable", out object? childTableValue) + ? childTableValue?.ToString() + : null; + if (string.IsNullOrWhiteSpace(childTableName)) + { + error = $"DataGrid control '{target}' does not have a child table configured."; + return false; + } + + if (!_childTableDefs.TryGetValue(childTableName, out table)) + { + error = $"DataGrid control '{target}' child table '{childTableName}' was not found."; + return false; + } + + return true; + } + private IReadOnlyDictionary BuildActionParameterDictionary( FormActionRuntimeContext context, IReadOnlyDictionary arguments) @@ -1229,6 +1423,19 @@ return null; } + private static object? TryReadArgumentValue( + IReadOnlyDictionary arguments, + params string[] keys) + { + foreach (string key in keys) + { + if (arguments.TryGetValue(key, out object? value)) + return NormalizeActionValue(value); + } + + return null; + } + private async Task SetRuntimeFieldValueAsync(string fieldName, object? value) { string key = _currentRecord.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)) @@ -2102,4 +2309,10 @@ => TryGetFieldValue(record, fieldName, out object? value) && value is not null ? value.ToString() ?? string.Empty : string.Empty; + + private static class EmptyObjectDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/CSharpDB.Admin.Forms/Pages/Designer.razor b/src/CSharpDB.Admin.Forms/Pages/Designer.razor index 161074c8..9615694a 100644 --- a/src/CSharpDB.Admin.Forms/Pages/Designer.razor +++ b/src/CSharpDB.Admin.Forms/Pages/Designer.razor @@ -82,6 +82,10 @@ {
Select a source before saving this form.
} + else + { + + }
@@ -126,6 +130,7 @@ private bool _saving; private string? _error; private string? _schemaWarning; + private FormTableDefinition? _currentSourceDefinition; private bool _saved; private System.Timers.Timer? _savedTimer; private bool _editingName; @@ -226,6 +231,7 @@ } var generated = FormGenerator.GenerateDefault(source) with { FormId = string.Empty }; + _currentSourceDefinition = source; _state.LoadForm(generated); } catch (Exception ex) @@ -237,6 +243,7 @@ private void LoadBlankDesigner(FormTableDefinition? source) { + _currentSourceDefinition = source; _state.LoadForm(new FormDefinition( string.Empty, source is null ? "Untitled Form" : $"{source.TableName} Form", @@ -255,6 +262,7 @@ if (string.IsNullOrWhiteSpace(tableName)) { _state.SetTableContext(null); + _currentSourceDefinition = null; _schemaWarning = null; return; } @@ -269,6 +277,7 @@ } _state.SetTableContext(source); + _currentSourceDefinition = source; if (string.IsNullOrWhiteSpace(_state.FormId) && shouldUseDefaultName) { _state.SetFormName($"{source.TableName} Form"); @@ -337,6 +346,7 @@ } FormTableDefinition? current = await SchemaProvider.GetTableDefinitionAsync(form.TableName); + _currentSourceDefinition = current; _schemaWarning = current is not null && !string.Equals(current.SourceSchemaSignature, form.SourceSchemaSignature, StringComparison.Ordinal) ? "The source schema has changed since this form was last saved." : null; diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs b/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs index b9d2043b..d06e70fd 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormActionManifestValidator.cs @@ -17,6 +17,7 @@ public static class FormActionManifestValidator "validationMessage", "text", "value", + "placeholder", }; public static FormActionValidationResult Validate( @@ -298,9 +299,13 @@ private static void ValidateApplyFilter( { ValidateOptionalControlTarget(step, location, eventName, actionSequence, stepIndex, controlIds, issues); string? filter = ReadText(step.Value) ?? ReadArgumentText(step.Arguments, "filter", "where"); + string? target = ReadText(step.Target) ?? ReadArgumentText(step.Arguments, "target"); + bool targetsForm = string.IsNullOrWhiteSpace(target) || + string.Equals(target, "form", StringComparison.OrdinalIgnoreCase); + FormTableDefinition? filterSchema = targetsForm ? schema : null; if (string.IsNullOrWhiteSpace(filter)) AddError(issues, step, location, "ApplyFilter action requires a filter expression.", eventName, actionSequence, stepIndex); - else if (!FormFilterExpression.TryParse(filter, schema, out _, out string? filterError)) + else if (!FormFilterExpression.TryParse(filter, filterSchema, out _, out string? filterError)) AddError(issues, step, location, $"ApplyFilter expression '{filter}' is malformed: {filterError}", eventName, actionSequence, stepIndex); } diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs index 5b523f16..146c4800 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -110,73 +110,148 @@ private static async Task ExecuteStepAsync( CancellationToken ct, int depth) { + bool diagnosticsEnabled = FormActionDiagnostics.IsInvocationEnabled; + long started = diagnosticsEnabled ? FormActionDiagnostics.GetTimestamp() : 0; + Dictionary? stepMetadata = diagnosticsEnabled + ? BuildStepMetadata(sequence, step, stepIndex, metadata) + : null; + try { - if (!FormActionConditionEvaluator.TryEvaluate( - step.Condition, + FormEventDispatchResult result = await ExecuteStepCoreAsync( + sequence, + step, + stepIndex, + commands, record, bindingArguments, runtimeArguments, - step.Arguments, - out bool shouldRun, - out string? conditionError)) - { - return FormEventDispatchResult.Failure( - $"Form action '{step.Kind}' condition failed: {conditionError}"); - } - - if (!shouldRun) - return FormEventDispatchResult.Success(); + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime, + ct, + depth); - return step.Kind switch - { - DbActionKind.RunCommand => await RunCommandAsync(sequence, step, stepIndex, commands, record, bindingArguments, runtimeArguments, metadata, ct), - DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), - DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), - DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), - DbActionKind.RunActionSequence => await RunActionSequenceAsync( - step, - commands, - record, - bindingArguments, - runtimeArguments, - metadata, - reusableSequences, - setFieldValue, - showMessage, - executeBuiltInFormAction, - actionRuntime, - ct, - depth), - DbActionKind.OpenForm => await OpenFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.CloseForm => await CloseFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.ApplyFilter => await ApplyFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.ClearFilter => await ClearFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.RunSql => await RunSqlAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.RunProcedure => await RunProcedureAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.SetControlProperty => await SetControlPropertyAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.SetControlVisibility => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "visible", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.SetControlEnabled => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "enabled", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.SetControlReadOnly => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "readOnly", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - DbActionKind.NewRecord or - DbActionKind.SaveRecord or - DbActionKind.DeleteRecord or - DbActionKind.RefreshRecords or - DbActionKind.PreviousRecord or - DbActionKind.NextRecord or - DbActionKind.GoToRecord => await ExecuteRecordActionAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), - _ => FormEventDispatchResult.Failure($"Unsupported form action kind '{step.Kind}'."), - }; + WriteActionDiagnostic(step, stepMetadata, started, result, canceled: false, exceptionMessage: null); + return result; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + WriteActionDiagnostic( + step, + stepMetadata, + started, + FormEventDispatchResult.Failure($"Form action '{step.Kind}' was canceled."), + canceled: true, + exceptionMessage: null); throw; } catch (Exception ex) { - return FormEventDispatchResult.Failure( + FormEventDispatchResult result = FormEventDispatchResult.Failure( $"Form action '{step.Kind}' failed: {ex.Message}"); + WriteActionDiagnostic(step, stepMetadata, started, result, canceled: false, ex.Message); + return result; + } + } + + private static async Task ExecuteStepCoreAsync( + DbActionSequence sequence, + DbActionStep step, + int stepIndex, + DbCommandRegistry commands, + IReadOnlyDictionary? record, + IReadOnlyDictionary? bindingArguments, + IReadOnlyDictionary? runtimeArguments, + IReadOnlyDictionary metadata, + IReadOnlyList? reusableSequences, + Func? setFieldValue, + Func? showMessage, + Func>? executeBuiltInFormAction, + IFormActionRuntime actionRuntime, + CancellationToken ct, + int depth) + { + if (!FormActionConditionEvaluator.TryEvaluate( + step.Condition, + record, + bindingArguments, + runtimeArguments, + step.Arguments, + out bool shouldRun, + out string? conditionError)) + { + return FormEventDispatchResult.Failure( + $"Form action '{step.Kind}' condition failed: {conditionError}"); } + + if (!shouldRun) + return FormEventDispatchResult.Success(); + + return step.Kind switch + { + DbActionKind.RunCommand => await RunCommandAsync(sequence, step, stepIndex, commands, record, bindingArguments, runtimeArguments, metadata, ct), + DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), + DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), + DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), + DbActionKind.RunActionSequence => await RunActionSequenceAsync( + step, + commands, + record, + bindingArguments, + runtimeArguments, + metadata, + reusableSequences, + setFieldValue, + showMessage, + executeBuiltInFormAction, + actionRuntime, + ct, + depth), + DbActionKind.OpenForm => await OpenFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.CloseForm => await CloseFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.ApplyFilter => await ApplyFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.ClearFilter => await ClearFilterAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.RunSql => await RunSqlAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.RunProcedure => await RunProcedureAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlProperty => await SetControlPropertyAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlVisibility => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "visible", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlEnabled => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "enabled", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.SetControlReadOnly => await SetSpecificControlPropertyAsync(sequence, step, stepIndex, "readOnly", record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + DbActionKind.NewRecord or + DbActionKind.SaveRecord or + DbActionKind.DeleteRecord or + DbActionKind.RefreshRecords or + DbActionKind.PreviousRecord or + DbActionKind.NextRecord or + DbActionKind.GoToRecord => await ExecuteRecordActionAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), + _ => FormEventDispatchResult.Failure($"Unsupported form action kind '{step.Kind}'."), + }; + } + + private static void WriteActionDiagnostic( + DbActionStep step, + IReadOnlyDictionary? metadata, + long started, + FormEventDispatchResult result, + bool canceled, + string? exceptionMessage) + { + if (metadata is null) + return; + + FormActionDiagnostics.WriteInvocation( + step.Kind, + step.Target, + metadata, + FormActionDiagnostics.GetElapsedTime(started), + result.Succeeded, + canceled, + result.Message, + exceptionMessage); } private static Task ExecuteRecordActionAsync( diff --git a/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor b/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor index df009608..9a786d9e 100644 --- a/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/FormEntryTab.razor @@ -13,6 +13,10 @@ else { @@ -41,7 +45,13 @@ else private Task OpenForm(FormOpenRequest request) { - TabManager.OpenFormEntryTab(request.FormId, request.FormName); + TabManager.OpenFormEntryTab( + request.FormId, + request.FormName, + request.RecordId, + request.Mode, + request.FilterExpression, + request.Arguments); return Task.CompletedTask; } diff --git a/src/CSharpDB.Admin/Models/TabDescriptor.cs b/src/CSharpDB.Admin/Models/TabDescriptor.cs index 4bd80d54..28d3d759 100644 --- a/src/CSharpDB.Admin/Models/TabDescriptor.cs +++ b/src/CSharpDB.Admin/Models/TabDescriptor.cs @@ -73,6 +73,30 @@ public string? InitialTableName set => State["InitialTableName"] = value; } + public object? InitialRecordId + { + get => State.TryGetValue("InitialRecordId", out var v) ? v : null; + set => State["InitialRecordId"] = value; + } + + public string? InitialFormEntryMode + { + get => State.TryGetValue("InitialFormEntryMode", out var v) ? v as string : null; + set => State["InitialFormEntryMode"] = value; + } + + public string? InitialFilterExpression + { + get => State.TryGetValue("InitialFilterExpression", out var v) ? v as string : null; + set => State["InitialFilterExpression"] = value; + } + + public IReadOnlyDictionary? InitialFilterParameters + { + get => State.TryGetValue("InitialFilterParameters", out var v) ? v as IReadOnlyDictionary : null; + set => State["InitialFilterParameters"] = value; + } + public string? ReportId { get => State.TryGetValue("ReportId", out var v) ? v as string : null; diff --git a/src/CSharpDB.Admin/Services/TabManagerService.cs b/src/CSharpDB.Admin/Services/TabManagerService.cs index e9671f29..565d99ff 100644 --- a/src/CSharpDB.Admin/Services/TabManagerService.cs +++ b/src/CSharpDB.Admin/Services/TabManagerService.cs @@ -180,17 +180,46 @@ public TabDescriptor OpenFormDesignerTab(string? formId = null, string? initialT return _tabs.First(t => t.Id == tab.Id); } - public TabDescriptor OpenFormEntryTab(string formId, string? title = null) + public TabDescriptor OpenFormEntryTab( + string formId, + string? title = null, + object? initialRecordId = null, + string? initialMode = null, + string? initialFilterExpression = null, + IReadOnlyDictionary? initialFilterParameters = null) { - var tab = new TabDescriptor($"form-entry:{formId}", title ?? "Form Entry", "bi-file-earmark-text", TabKind.FormEntry) + string tabId = $"form-entry:{formId}"; + TabDescriptor? existing = _tabs.FirstOrDefault(t => t.Id == tabId); + if (existing is not null) + { + ApplyFormEntryInitialState(existing, initialRecordId, initialMode, initialFilterExpression, initialFilterParameters); + ActivateTab(existing.Id); + return existing; + } + + var tab = new TabDescriptor(tabId, title ?? "Form Entry", "bi-file-earmark-text", TabKind.FormEntry) { FormId = formId, }; + ApplyFormEntryInitialState(tab, initialRecordId, initialMode, initialFilterExpression, initialFilterParameters); OpenTab(tab); return _tabs.First(t => t.Id == tab.Id); } + private static void ApplyFormEntryInitialState( + TabDescriptor tab, + object? initialRecordId, + string? initialMode, + string? initialFilterExpression, + IReadOnlyDictionary? initialFilterParameters) + { + tab.InitialRecordId = initialRecordId; + tab.InitialFormEntryMode = string.IsNullOrWhiteSpace(initialMode) ? null : initialMode.Trim(); + tab.InitialFilterExpression = string.IsNullOrWhiteSpace(initialFilterExpression) ? null : initialFilterExpression.Trim(); + tab.InitialFilterParameters = initialFilterParameters; + } + /// Open a table tab and switch it to Schema view. public TabDescriptor OpenTableSchemaTab(string tableName) { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs index acb400d7..4c492960 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs @@ -55,6 +55,27 @@ public void OpenFormEntryTab_DeduplicatesByFormId() Assert.Equal(TabKind.FormEntry, second.Kind); } + [Fact] + public void OpenFormEntryTab_UpdatesInitialStateWhenExistingTabIsReopened() + { + var manager = new TabManagerService(); + + TabDescriptor first = manager.OpenFormEntryTab("form-1", "Customer Form", initialRecordId: 10L); + TabDescriptor second = manager.OpenFormEntryTab( + "form-1", + "Customer Form", + initialRecordId: 42L, + initialMode: "view", + initialFilterExpression: "[Status] = @status", + initialFilterParameters: new Dictionary { ["status"] = "Open" }); + + Assert.Same(first, second); + Assert.Equal(42L, second.InitialRecordId); + Assert.Equal("view", second.InitialFormEntryMode); + Assert.Equal("[Status] = @status", second.InitialFilterExpression); + Assert.Equal("Open", second.InitialFilterParameters!["status"]); + } + [Fact] public void CloseTabsForForm_ClosesDesignerAndEntryTabs() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs index 34f002cf..e1bd334c 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs @@ -79,6 +79,33 @@ public async Task GoToPageAsync_StandaloneGridLoadsRequestedPage() Assert.Equal(Enumerable.Range(126, 5).Select(i => (long)i).ToArray(), ReadRows(component).Select(row => (long)row["OrderId"]!).ToArray()); } + [Fact] + public async Task OnParametersSetAsync_StandaloneGridAppliesFilterExpression() + { + var service = new TableSpecificRecordService(); + var component = new ChildDataGrid(); + SetProperty(component, "RecordService", service); + + SetProperty(component, nameof(ChildDataGrid.ChildTableName), "Orders"); + SetProperty(component, nameof(ChildDataGrid.ForeignKeyField), ""); + SetProperty(component, nameof(ChildDataGrid.ParentKeyValue), null); + SetProperty(component, nameof(ChildDataGrid.IsStandalone), true); + SetProperty(component, nameof(ChildDataGrid.ChildFormTableDefinition), CreateTableDefinition("Orders", "OrderId", "CustomerId")); + SetProperty(component, nameof(ChildDataGrid.FilterExpression), "[CustomerId] = @customerId"); + SetProperty( + component, + nameof(ChildDataGrid.FilterParameters), + new Dictionary { ["customerId"] = 7L }); + + await InvokeNonPublicAsync(component, "OnParametersSetAsync"); + + Assert.Empty(service.RequestedPages); + Assert.Equal(["Orders"], service.RequestedAllTables); + Assert.Equal(15, ReadIntField(component, "_totalCount")); + Assert.Equal(Enumerable.Range(101, 30).Where(id => id % 2 != 0).Select(id => (long)id).ToArray(), + ReadRows(component).Select(row => (long)row["OrderId"]!).ToArray()); + } + [Fact] public async Task AddRow_StandaloneGridDoesNotSeedForeignKey() { @@ -122,7 +149,8 @@ private static FormTableDefinition CreateTableDefinition(string tableName, strin $"sig:{tableName}", [ new FormFieldDefinition(primaryKeyField, FieldDataType.Int64, false, false), - new FormFieldDefinition(foreignKeyField, FieldDataType.Int64, false, false) + new FormFieldDefinition(foreignKeyField, FieldDataType.Int64, false, false), + new FormFieldDefinition("Status", FieldDataType.String, false, false) ], [primaryKeyField], []); @@ -217,7 +245,8 @@ public Task SearchRecordPageAsync(FormTableDefinition table, str new Dictionary(StringComparer.OrdinalIgnoreCase) { ["OrderId"] = 101L, - [filterField] = filterValue + [filterField] = filterValue, + ["Status"] = "Open" } ], "Payments" => @@ -225,7 +254,8 @@ public Task SearchRecordPageAsync(FormTableDefinition table, str new Dictionary(StringComparer.OrdinalIgnoreCase) { ["PaymentId"] = 201L, - [filterField] = filterValue + [filterField] = filterValue, + ["Status"] = "Open" } ], _ => [] @@ -257,7 +287,8 @@ public Task DeleteRecordAsync(FormTableDefinition table, object pkValue, Cancell .Select(id => new Dictionary(StringComparer.OrdinalIgnoreCase) { ["OrderId"] = (long)id, - ["CustomerId"] = id % 2 == 0 ? 9L : 7L + ["CustomerId"] = id % 2 == 0 ? 9L : 7L, + ["Status"] = id % 3 == 0 ? "Closed" : "Open" }) .ToList(), _ => [] diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs index 43d894a3..3e0d3dfb 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -134,6 +134,55 @@ public void UpdateActionSequences_ReplacesReusableActionSequences() Assert.Equal("Ready.", step.Message); } + [Fact] + public void ToFormDefinition_PreservesControlRules() + { + var state = new DesignerState(); + var form = CreateForm() with + { + Rules = + [ + new ControlRuleDefinition( + "closed-state", + "[Status] = 'Closed'", + [new ControlRuleEffect("status", "visible", false)]), + ], + }; + + state.LoadForm(form); + + FormDefinition saved = state.ToFormDefinition(); + + ControlRuleDefinition rule = Assert.Single(saved.Rules!); + Assert.Equal("closed-state", rule.RuleId); + Assert.Equal("[Status] = 'Closed'", rule.Condition); + ControlRuleEffect effect = Assert.Single(rule.Effects); + Assert.Equal("status", effect.ControlId); + Assert.Equal("visible", effect.Property); + Assert.False(Assert.IsType(effect.Value)); + } + + [Fact] + public void UpdateRules_ReplacesControlRules() + { + var state = new DesignerState(); + state.LoadForm(CreateForm()); + + state.UpdateRules( + [ + new ControlRuleDefinition( + "readonly-closed", + "[Status] = 'Closed'", + [new ControlRuleEffect("status", "readOnly", true)]), + ]); + + FormDefinition saved = state.ToFormDefinition(); + + ControlRuleDefinition rule = Assert.Single(saved.Rules!); + Assert.Equal("readonly-closed", rule.RuleId); + Assert.Equal("readOnly", Assert.Single(rule.Effects).Property); + } + [Fact] public void SetLayoutMode_UpdatesSavedLayout() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs index 9573e59f..cc436eb9 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs @@ -46,6 +46,33 @@ Name TEXT NOT NULL Assert.Equal(2, recordService.ListRecordPageCalls); } + [Fact] + public async Task InitialRecordId_LoadsRequestedRecord() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Events ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + INSERT INTO Events VALUES (1, 'Alpha'); + INSERT INTO Events VALUES (2, 'Beta'); + INSERT INTO Events VALUES (3, 'Gamma'); + """); + + var recordService = new CountingFormRecordService(new DbFormRecordService(db.Client)); + DataEntry component = await CreateComponentAsync( + form: CreateForm("events-form", "Events"), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: recordService, + initialRecordId: 3L); + + Assert.Equal(3L, ReadCurrentRecord(component)["Id"]); + Assert.Equal("Gamma", ReadCurrentRecord(component)["Name"]); + Assert.Equal(1, recordService.GetRecordOrdinalCalls); + } + [Fact] public async Task SaveRecord_UpdatePatchesVisibleRowWithoutReloadingPage() { @@ -452,6 +479,65 @@ Status TEXT NOT NULL Assert.Equal([1L, 2L, 3L], ReadRecords(component).Select(row => (long)row["Id"]!).ToArray()); } + [Fact] + public async Task Phase8Runtime_AppliesAndClearsDataGridFilter() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Products ( + Id INTEGER PRIMARY KEY, + Name TEXT NOT NULL + ); + CREATE TABLE Orders ( + OrderId INTEGER PRIMARY KEY, + ProductId INTEGER NOT NULL, + Status TEXT NOT NULL + ); + INSERT INTO Products VALUES (1, 'Widget'); + INSERT INTO Orders VALUES (101, 1, 'Open'); + """); + + ControlDefinition grid = new( + "ordersGrid", + "datagrid", + new Rect(0, 0, 320, 160), + Binding: null, + Props: new PropertyBag(new Dictionary + { + ["childTable"] = "Orders", + ["dataGridMode"] = "related", + ["foreignKeyField"] = "ProductId", + ["parentKeyField"] = "Id", + }), + ValidationOverride: null); + DataEntry component = await CreateComponentAsync( + form: CreateForm("products-form", "Products", [grid]), + schemaProvider: new DbSchemaProvider(db.Client), + recordService: new DbFormRecordService(db.Client)); + + FormEventDispatchResult result = await ((IFormActionRuntime)component).ApplyFilterAsync( + CreateRuntimeContext(component), + "ordersGrid", + "[Status] = @status", + new Dictionary { ["status"] = "Open" }, + CancellationToken.None); + + Assert.True(result.Succeeded); + var filters = GetField>(component, "_controlFilters"); + ControlFilterState filter = Assert.Single(filters).Value; + Assert.Equal("[Status] = @status", filter.FilterExpression); + Assert.Equal("Open", filter.Parameters["status"]); + + result = await ((IFormActionRuntime)component).ClearFilterAsync( + CreateRuntimeContext(component), + "ordersGrid", + CancellationToken.None); + + Assert.True(result.Succeeded); + Assert.Empty(filters); + } + [Fact] public async Task Phase8Runtime_SetsControlPropertyAndBoundValue() { @@ -586,7 +672,11 @@ private static async Task CreateComponentAsync( FormDefinition form, ISchemaProvider schemaProvider, IFormRecordService recordService, - IFormEventDispatcher? formEvents = null) + IFormEventDispatcher? formEvents = null, + object? initialRecordId = null, + string? initialMode = null, + string? initialFilterExpression = null, + IReadOnlyDictionary? initialFilterParameters = null) { var component = new DataEntry(); SetProperty(component, "FormRepository", new StaticFormRepository(form)); @@ -596,6 +686,10 @@ private static async Task CreateComponentAsync( SetProperty(component, "FormEvents", formEvents ?? NullFormEventDispatcher.Instance); SetProperty(component, "JS", new StubJsRuntime()); SetProperty(component, nameof(DataEntry.FormId), form.FormId); + SetProperty(component, nameof(DataEntry.InitialRecordId), initialRecordId); + SetProperty(component, nameof(DataEntry.InitialMode), initialMode); + SetProperty(component, nameof(DataEntry.InitialFilterExpression), initialFilterExpression); + SetProperty(component, nameof(DataEntry.InitialFilterParameters), initialFilterParameters); await InvokeNonPublicAsync(component, "OnParametersSetAsync"); return component; diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs new file mode 100644 index 00000000..79fb7552 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs @@ -0,0 +1,81 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Tests.Services; + +public sealed class FormActionDiagnosticsTests +{ + [Fact] + public async Task DispatchActionSequence_EmitsDiagnosticEvent() + { + List diagnostics = []; + using IDisposable subscription = FormActionDiagnostics.Listener.Subscribe(new ActionObserver(diagnostics)); + var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty); + FormDefinition form = CreateForm( + new DbActionSequence( + [ + new DbActionStep(DbActionKind.ShowMessage, Message: "Loaded."), + ], + Name: "NotifyLoad")); + + FormEventDispatchResult result = await dispatcher.DispatchAsync( + form, + FormEventKind.OnLoad, + new Dictionary { ["Id"] = 42L }, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + FormActionInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.FormId == "orders-form" && diagnostic.ActionKind == DbActionKind.ShowMessage); + Assert.Equal(DbActionKind.ShowMessage, diagnostic.ActionKind); + Assert.Equal("orders-form", diagnostic.FormId); + Assert.Equal("Orders", diagnostic.TableName); + Assert.Equal("OnLoad", diagnostic.EventName); + Assert.Equal("NotifyLoad", diagnostic.ActionSequenceName); + Assert.Equal(0, diagnostic.StepIndex); + Assert.Equal("actionSequences.NotifyLoad.steps[0]", diagnostic.Location); + Assert.True(diagnostic.Succeeded); + Assert.False(diagnostic.Canceled); + Assert.Equal("Loaded.", diagnostic.ResultMessage); + Assert.Null(diagnostic.ExceptionMessage); + Assert.True(diagnostic.Elapsed >= TimeSpan.Zero); + } + + private static FormDefinition CreateForm(DbActionSequence sequence) + => new( + "orders-form", + "Orders Form", + "Orders", + DefinitionVersion: 1, + SourceSchemaSignature: "sig:orders", + Layout: new LayoutDefinition("absolute", 8, SnapToGrid: false, []), + Controls: [], + EventBindings: + [ + new FormEventBinding(FormEventKind.OnLoad, string.Empty, ActionSequence: sequence), + ]); + + private sealed class ActionObserver(List diagnostics) + : IObserver> + { + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(KeyValuePair value) + { + if (value.Key == FormActionDiagnostics.InvocationEventName && + value.Value is FormActionInvocationDiagnostic diagnostic) + { + diagnostics.Add(diagnostic); + } + } + } +} From 1c27c49a248ebaab516ed34e1e14c15e320da31a Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Wed, 29 Apr 2026 23:20:17 -0700 Subject: [PATCH 22/39] feat: Add support for document collections - Introduced CollectionJsonFormatter for formatting and previewing JSON documents. - Implemented CollectionNameValidator for validating collection names. - Updated TabDescriptor to include a new TabKind for collection data. - Enhanced DatabaseClientHolder with methods for managing collections, including dropping collections. - Added new endpoints for collection management in the API. - Implemented collection browsing and document management features in the client. - Updated styles for collection-related UI components. - Added integration tests for collection operations, including create, update, delete, and drop functionalities. - Enhanced existing tests to cover new collection features and ensure stability. --- docs/admin-collections-ui/README.md | 59 ++ .../Components/Layout/CommandPalette.razor | 47 +- .../Components/Layout/MainLayout.razor | 5 + .../Components/Layout/NavMenu.razor | 141 ++++- .../Components/Tabs/CollectionTab.razor | 510 ++++++++++++++++++ .../Helpers/CollectionJsonFormatter.cs | 127 +++++ .../Helpers/CollectionNameValidator.cs | 16 + src/CSharpDB.Admin/Models/TabDescriptor.cs | 3 +- src/CSharpDB.Admin/README.md | 8 +- .../Services/DatabaseClientHolder.cs | 1 + .../Services/TabManagerService.cs | 10 + src/CSharpDB.Admin/wwwroot/css/app.css | 205 +++++++ .../Endpoints/CollectionEndpoints.cs | 7 + src/CSharpDB.Client/CSharpDbClient.cs | 1 + src/CSharpDB.Client/ICSharpDbClient.cs | 1 + .../Internal/EngineTransportClient.cs | 6 + .../Internal/GrpcTransportClient.cs | 3 + .../Internal/HttpTransportClient.cs | 10 + src/CSharpDB.Client/Protos/csharpdb_rpc.proto | 1 + .../Grpc/CSharpDbRpcService.cs | 3 + .../CollectionDocumentCodec.cs | 58 +- src/CSharpDB.Engine/Database.cs | 69 +++ .../Admin/TabManagerServiceTests.cs | 40 ++ .../Helpers/CollectionJsonFormatterTests.cs | 58 ++ .../CollectionClientIntegrationTests.cs | 46 ++ .../HttpTransportClientTests.cs | 5 +- .../CSharpDB.Daemon.Tests/GrpcClientTests.cs | 3 + .../CollectionJsonElementTests.cs | 45 ++ tests/CSharpDB.Tests/CollectionTests.cs | 27 + 29 files changed, 1481 insertions(+), 34 deletions(-) create mode 100644 docs/admin-collections-ui/README.md create mode 100644 src/CSharpDB.Admin/Components/Tabs/CollectionTab.razor create mode 100644 src/CSharpDB.Admin/Helpers/CollectionJsonFormatter.cs create mode 100644 src/CSharpDB.Admin/Helpers/CollectionNameValidator.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Helpers/CollectionJsonFormatterTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Services/CollectionClientIntegrationTests.cs diff --git a/docs/admin-collections-ui/README.md b/docs/admin-collections-ui/README.md new file mode 100644 index 00000000..57e11a15 --- /dev/null +++ b/docs/admin-collections-ui/README.md @@ -0,0 +1,59 @@ +# Admin Collections UI + +## Summary + +The Admin app now treats document collections as first-class objects alongside +tables, views, forms, and reports. The first implementation is a browser and +JSON editor built on the existing collection client APIs: + +- list collection names +- browse documents by page +- fetch one document by exact key +- create or update a document +- delete a document + +This deliberately avoids new engine, HTTP, gRPC, or client contracts. Collection +path-index management, document-content search, collection rename, and +collection drop remain future work. + +## User Experience + +- Object Explorer has a `Collections` filter chip and group. +- The collection group menu supports `New Collection...` and `Refresh`. +- Collection item menus support `Open`, `New Document...`, and `Copy Name`. +- The command palette includes `New Collection` plus one entry for each existing + collection. +- Each collection opens in a dedicated `collection:{name}` tab using the same + toolbar, pager, grid, and detail-panel language as table data tabs. + +## Collection Tab Behavior + +- The grid shows row number, document key, JSON kind, and a compact preview. +- The detail panel shows indented JSON for the selected document. +- Existing document keys are read-only. +- New documents require a nonblank key and valid JSON before save is enabled. +- Save writes through `PutDocumentAsync(collectionName, key, document)`. +- Delete writes through `DeleteDocumentAsync(collectionName, key)` after + confirmation. +- Successful writes notify Admin change listeners and refresh the current page. +- Exact-key lookup uses `GetDocumentAsync(collectionName, key)`. + +## Defaults + +- Default page size is `25`. +- Supported page sizes are `10`, `25`, `50`, and `100`. +- Collection names use the same simple identifier shape as the direct client: + `^[A-Za-z_][A-Za-z0-9_]*$`. +- Deleting all documents leaves the collection itself in place because there is + no drop-collection API today. +- Generated collection model metadata is not surfaced; documents are edited as + raw JSON. + +## Verification + +Run the focused Admin checks after collection UI changes: + +```powershell +dotnet build src/CSharpDB.Admin/CSharpDB.Admin.csproj +dotnet test tests/CSharpDB.Admin.Forms.Tests/CSharpDB.Admin.Forms.Tests.csproj +``` diff --git a/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor b/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor index 5c333b94..09d26790 100644 --- a/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor +++ b/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor @@ -3,6 +3,8 @@ @inject IReportRepository ReportRepository @inject TabManagerService TabManager @inject DatabaseChangeService Changes +@inject ModalService Modal +@inject ToastService Toast @inject IJSRuntime JS @implements IDisposable @@ -16,7 +18,7 @@ value="@_query" @oninput="OnQueryChanged" @onkeydown="HandleKeyDown" - placeholder="Search commands, tables, forms, reports..." /> + placeholder="Search commands, tables, collections, forms, reports..." /> Esc
@@ -130,6 +132,19 @@ () => { TabManager.OpenViewTab(captured); return Task.CompletedTask; })); } + IReadOnlyCollection collections = await DbClient.GetCollectionNamesAsync(); + foreach (string collectionName in collections.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)) + { + string captured = collectionName; + _items.Add(new PaletteItem( + "Collection", + captured, + "Open collection documents", + "bi-braces", + "icon-view", + () => { TabManager.OpenCollectionTab(captured); return Task.CompletedTask; })); + } + foreach (ProcedureDefinition procedure in (await DbClient.GetProceduresAsync()).OrderBy(procedure => procedure.Name)) { string captured = procedure.Name; @@ -198,6 +213,7 @@ { _items.Add(new PaletteItem("Command", "New Query", "Open a SQL editor", "bi-terminal", "icon-system", () => { TabManager.OpenQueryTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Table", "Open table designer", "bi-table", "icon-table", () => { TabManager.OpenTableDesignerTab(); return Task.CompletedTask; })); + _items.Add(new PaletteItem("Command", "New Collection", "Create or open a document collection", "bi-braces", "icon-view", OpenNewCollectionAsync)); _items.Add(new PaletteItem("Command", "New Form", "Open form designer", "bi-ui-checks-grid", "icon-form", () => { TabManager.OpenFormDesignerTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Report", "Open report designer", "bi-file-earmark-richtext", "icon-report", () => { TabManager.OpenReportDesignerTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Procedure", "Open procedure editor", "bi-gear-wide-connected", "icon-trigger", () => { TabManager.OpenNewProcedureTab(); return Task.CompletedTask; })); @@ -205,6 +221,35 @@ _items.Add(new PaletteItem("Command", "Storage Inspector", "Open storage diagnostics", "bi-hdd-stack", "icon-system", () => { TabManager.OpenStorageTab(); return Task.CompletedTask; })); } + private async Task OpenNewCollectionAsync() + { + string? enteredName = await Modal.PromptAsync( + "New Collection", + CollectionNameValidator.HelpText, + "Create", + "collection_name"); + if (enteredName is null) + return; + + string collectionName = CollectionNameValidator.Normalize(enteredName); + if (!CollectionNameValidator.IsValid(collectionName)) + { + Toast.Error(CollectionNameValidator.HelpText); + return; + } + + try + { + await DbClient.BrowseCollectionAsync(collectionName, page: 1, pageSize: 1); + TabManager.OpenCollectionTab(collectionName); + Changes.NotifyChanged(); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + private Task OnQueryChanged(ChangeEventArgs e) { _query = e.Value?.ToString() ?? string.Empty; diff --git a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor index 54a700ba..6c37d99f 100644 --- a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor +++ b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor @@ -42,6 +42,11 @@ ObjectName="@(tab.ObjectName ?? string.Empty)" IsView="true" /> break; + case TabKind.CollectionData: + + break; case TabKind.Procedure: diff --git a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor index 58ab4be2..560c21b0 100644 --- a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor +++ b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor @@ -36,6 +36,7 @@ @@ -202,6 +203,38 @@
} + @* Collections *@ + @if (ShouldShowGroup("collections")) + { +
+
+ + + Collections + @(_collectionNames?.Count ?? 0) +
+ @if (_expandedGroups.Contains("collections") && _collectionNames is not null) + { +
+ @foreach (var name in FilterCollections()) + { + var collectionName = name; +
+ + @collectionName +
+ } +
+ } +
+ } + @if (ShouldShowGroup("forms")) {
@@ -561,6 +594,7 @@ private IReadOnlyCollection? _userTableNames; private IReadOnlyCollection? _systemTableNames; private IReadOnlyCollection? _viewNames; + private IReadOnlyCollection? _collectionNames; private IReadOnlyList? _indexes; private IReadOnlyList? _triggers; private IReadOnlyList? _procedures; @@ -568,7 +602,7 @@ private IReadOnlyList? _reports; private string _searchQuery = string.Empty; private string _objectFilter = "all"; - private readonly HashSet _expandedGroups = new() { "tables", "forms", "reports", "system", "views", "procedures" }; + private readonly HashSet _expandedGroups = new() { "tables", "collections", "forms", "reports", "system", "views", "procedures" }; private readonly HashSet _expandedTableNodes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tableSchemas = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _tableSchemaLoadFailures = new(StringComparer.OrdinalIgnoreCase); @@ -592,6 +626,12 @@ items.Add(new SidebarShortcut(captured.Name, "bi-ui-checks-grid", "icon-form", () => TabManager.OpenFormDesignerTab(captured.FormId, title: captured.Name))); } + foreach (string collectionName in (_collectionNames ?? []).Take(1)) + { + string captured = collectionName; + items.Add(new SidebarShortcut(captured, "bi-braces", "icon-view", () => TabManager.OpenCollectionTab(captured))); + } + return items; } } @@ -635,6 +675,7 @@ _userTableNames = allTables.Where(static n => !IsSystemTableName(n)).ToList(); _systemTableNames = allTables.Where(IsSystemTableName).ToList(); _viewNames = (await DbClient.GetViewNamesAsync()).OrderBy(n => n).ToList(); + _collectionNames = (await DbClient.GetCollectionNamesAsync()).OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList(); _indexes = (await DbClient.GetIndexesAsync()).OrderBy(i => i.IndexName).ToList(); _triggers = (await DbClient.GetTriggersAsync()).OrderBy(t => t.TriggerName).ToList(); _procedures = (await DbClient.GetProceduresAsync()).OrderBy(p => p.Name).ToList(); @@ -652,6 +693,7 @@ _userTableNames = null; _systemTableNames = null; _viewNames = null; + _collectionNames = null; _indexes = null; _triggers = null; _procedures = null; @@ -697,6 +739,7 @@ { "all" => true, "tables" => group is "tables" or "views", + "collections" => group == "collections", "forms" => group == "forms", "reports" => group == "reports", _ => string.Equals(group, _objectFilter, StringComparison.OrdinalIgnoreCase), @@ -709,6 +752,14 @@ return items.Where(n => n.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase)); } + private IEnumerable FilterCollections() + { + if (_collectionNames is null) + return Enumerable.Empty(); + + return FilterItems(_collectionNames); + } + private IEnumerable FilterTableNames(IReadOnlyCollection tableNames) { if (string.IsNullOrWhiteSpace(_searchQuery)) @@ -952,6 +1003,29 @@ }); } + private void ShowCollectionsGroupMenu(MouseEventArgs e) + { + ShowContextMenu(e, new List + { + new() { Label = "New Collection...", Icon = "bi-plus-lg", OnClick = async () => await OpenNewCollectionAsync() }, + ContextMenuItem.Separator(), + new() { Label = "Refresh", Icon = "bi-arrow-clockwise", OnClick = async () => { await LoadObjects(); await InvokeAsync(StateHasChanged); } }, + }); + } + + private void ShowCollectionItemMenu(MouseEventArgs e, string collectionName) + { + ShowContextMenu(e, new List + { + new() { Label = "Open", Icon = "bi-braces", OnClick = () => TabManager.OpenCollectionTab(collectionName) }, + new() { Label = "New Document...", Icon = "bi-plus-lg", OnClick = () => OpenNewDocumentForCollection(collectionName) }, + ContextMenuItem.Separator(), + new() { Label = "Copy Name", Icon = "bi-clipboard", OnClick = async () => await CopyCollectionNameAsync(collectionName) }, + ContextMenuItem.Separator(), + new() { Label = "Drop Collection", Icon = "bi-trash3", IsDanger = true, OnClick = async () => await ConfirmDropCollectionAsync(collectionName) }, + }); + } + private void ShowTableItemMenu(MouseEventArgs e, string tableName) { ShowContextMenu(e, new List @@ -1075,6 +1149,57 @@ }); } + private async Task OpenNewCollectionAsync() + { + string? enteredName = await Modal.PromptAsync( + "New Collection", + CollectionNameValidator.HelpText, + "Create", + "collection_name"); + if (enteredName is null) + return; + + string collectionName = CollectionNameValidator.Normalize(enteredName); + if (!CollectionNameValidator.IsValid(collectionName)) + { + Toast.Error(CollectionNameValidator.HelpText); + return; + } + + try + { + await DbClient.BrowseCollectionAsync(collectionName, page: 1, pageSize: 1); + TabManager.OpenCollectionTab(collectionName); + Changes.NotifyChanged(); + await LoadObjects(); + await InvokeAsync(StateHasChanged); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + + private void OpenNewDocumentForCollection(string collectionName) + { + var tab = TabManager.OpenCollectionTab(collectionName); + tab.State["StartNewDocument"] = true; + TabManager.ActivateTab(tab.Id); + } + + private async Task CopyCollectionNameAsync(string collectionName) + { + try + { + await JS.InvokeVoidAsync("clipboardInterop.writeText", collectionName); + Toast.Success($"Copied '{collectionName}'."); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + private async Task ConfirmDropTableAsync(string tableName) { var confirmed = await Modal.ConfirmAsync("Drop Table", $"Are you sure you want to drop table '{tableName}'? This action cannot be undone.", "Drop", isDanger: true); @@ -1089,6 +1214,20 @@ catch (Exception ex) { Toast.Error(ex.Message); } } + private async Task ConfirmDropCollectionAsync(string collectionName) + { + var confirmed = await Modal.ConfirmAsync("Drop Collection", $"Are you sure you want to drop collection '{collectionName}'? All documents and collection indexes will be deleted. This action cannot be undone.", "Drop", isDanger: true); + if (!confirmed) return; + try + { + await DbClient.DropCollectionAsync(collectionName); + Toast.Success($"Dropped collection '{collectionName}'."); + TabManager.CloseTabsForObject(collectionName); + Changes.NotifyChanged(); + } + catch (Exception ex) { Toast.Error(ex.Message); } + } + private async Task ConfirmDropViewAsync(string viewName) { var confirmed = await Modal.ConfirmAsync("Drop View", $"Are you sure you want to drop view '{viewName}'? This action cannot be undone.", "Drop", isDanger: true); diff --git a/src/CSharpDB.Admin/Components/Tabs/CollectionTab.razor b/src/CSharpDB.Admin/Components/Tabs/CollectionTab.razor new file mode 100644 index 00000000..20f7e8e0 --- /dev/null +++ b/src/CSharpDB.Admin/Components/Tabs/CollectionTab.razor @@ -0,0 +1,510 @@ +@using System.Text.Json +@inject ICSharpDbClient DbClient +@inject DatabaseChangeService Changes +@inject TabManagerService TabManager +@inject ToastService Toast +@inject ModalService Modal + +
+
+
+ + / + @CollectionName +
+ +
+ + + + + +
+ +
+ + + +
+ +
+ @GetRangeText() + + + +
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) + { +
+ + @_error +
+ } + +
+
+ + + + + + + + + + + @if (_loading) + { + + + + } + else if (_documents.Count == 0) + { + + + + } + else + { + @for (int i = 0; i < _documents.Count; i++) + { + var document = _documents[i]; + int rowNumber = ((_page - 1) * _pageSize) + i + 1; + + + + + + + } + } + +
#KeyKindPreview
Loading documents...
No documents in this collection
@rowNumber@document.Key@CollectionJsonFormatter.GetKindLabel(document.Document)@CollectionJsonFormatter.GetPreview(document.Document)
+
+ +
+
+
+ @(_isNewDocument ? "New Document" : "Document") + @(string.IsNullOrWhiteSpace(_editorKey) ? "No document selected" : _editorKey) +
+ @if (_dirty) + { + Unsaved + } +
+ + @if (HasEditor) + { + + + + + @if (!string.IsNullOrWhiteSpace(_validationMessage)) + { +
+ + @_validationMessage +
+ } + } + else + { +
+ + Select a document or create a new one. +
+ } +
+
+
+ +@code { + [Parameter] public TabDescriptor? Tab { get; set; } + [Parameter] public string CollectionName { get; set; } = string.Empty; + + private readonly List _documents = new(); + private string? _loadedCollectionName; + private string? _selectedKey; + private string _editorKey = string.Empty; + private string _editorJson = string.Empty; + private string _originalJson = string.Empty; + private string _lookupKey = string.Empty; + private string? _validationMessage; + private string? _error; + private bool _loading; + private bool _isNewDocument; + private bool _dirty; + private int _page = 1; + private int _pageSize = 25; + private int _totalCount; + + private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalCount / (double)_pageSize)); + private bool HasEditor => _isNewDocument || !string.IsNullOrWhiteSpace(_selectedKey); + private bool CanSave => HasEditor && _dirty && string.IsNullOrWhiteSpace(_validationMessage) && !string.IsNullOrWhiteSpace(_editorKey); + private bool CanDiscard => HasEditor && _dirty; + private bool CanDelete => HasEditor && !_isNewDocument && !string.IsNullOrWhiteSpace(_selectedKey); + private bool CanGoToPreviousPage => !_loading && _page > 1; + private bool CanGoToNextPage => !_loading && _page < TotalPages; + + protected override async Task OnParametersSetAsync() + { + bool startNewDocument = Tab?.State.Remove("StartNewDocument") == true; + if (string.Equals(_loadedCollectionName, CollectionName, StringComparison.Ordinal)) + { + if (startNewDocument) + await StartNewDocumentAsync(); + return; + } + + _loadedCollectionName = CollectionName; + _page = 1; + ClearEditor(); + await LoadPageAsync(); + + if (startNewDocument) + await StartNewDocumentAsync(); + } + + private async Task RefreshAsync() + { + if (!await ConfirmDiscardIfDirtyAsync()) + return; + + await LoadPageAsync(_selectedKey); + } + + private async Task ChangePageSizeAsync(ChangeEventArgs e) + { + if (!await ConfirmDiscardIfDirtyAsync()) + return; + + if (!int.TryParse(e.Value?.ToString(), out int pageSize)) + pageSize = 25; + + _pageSize = pageSize; + _page = 1; + await LoadPageAsync(); + } + + private async Task GoToPreviousPageAsync() + { + if (!CanGoToPreviousPage || !await ConfirmDiscardIfDirtyAsync()) + return; + + _page--; + await LoadPageAsync(); + } + + private async Task GoToNextPageAsync() + { + if (!CanGoToNextPage || !await ConfirmDiscardIfDirtyAsync()) + return; + + _page++; + await LoadPageAsync(); + } + + private async Task LoadPageAsync(string? selectKey = null) + { + if (string.IsNullOrWhiteSpace(CollectionName)) + { + _error = "Collection name is required."; + return; + } + + _loading = true; + _error = null; + try + { + var result = await DbClient.BrowseCollectionAsync(CollectionName, _page, _pageSize); + _totalCount = result.TotalCount; + _documents.Clear(); + _documents.AddRange(result.Documents); + + if (_page > TotalPages) + { + _page = TotalPages; + result = await DbClient.BrowseCollectionAsync(CollectionName, _page, _pageSize); + _totalCount = result.TotalCount; + _documents.Clear(); + _documents.AddRange(result.Documents); + } + + string? keyToSelect = selectKey ?? _selectedKey; + var selected = !string.IsNullOrWhiteSpace(keyToSelect) + ? _documents.FirstOrDefault(document => string.Equals(document.Key, keyToSelect, StringComparison.Ordinal)) + : null; + + selected ??= _documents.FirstOrDefault(); + if (selected is not null) + SelectDocument(selected, isDirty: false); + else + ClearEditor(); + } + catch (Exception ex) + { + _error = ex.Message; + _documents.Clear(); + _totalCount = 0; + ClearEditor(); + } + finally + { + _loading = false; + } + } + + private async Task SelectDocumentAsync(CollectionDocument document) + { + if (!await ConfirmDiscardIfDirtyAsync()) + return; + + SelectDocument(document, isDirty: false); + } + + private void SelectDocument(CollectionDocument document, bool isDirty) + { + _selectedKey = document.Key; + _editorKey = document.Key; + _editorJson = CollectionJsonFormatter.Format(document.Document); + _originalJson = _editorJson; + _isNewDocument = false; + _dirty = isDirty; + _validationMessage = null; + } + + private async Task StartNewDocumentAsync() + { + if (!await ConfirmDiscardIfDirtyAsync()) + return; + + _selectedKey = null; + _editorKey = string.Empty; + _editorJson = "{}"; + _originalJson = string.Empty; + _isNewDocument = true; + _dirty = true; + _validationMessage = null; + } + + private async Task SaveDocumentAsync() + { + ValidateEditorJson(); + if (!CanSave) + return; + + string key = _editorKey.Trim(); + if (!CollectionJsonFormatter.TryClone(_editorJson, out JsonElement document, out string? error)) + { + _validationMessage = error; + return; + } + + try + { + await DbClient.PutDocumentAsync(CollectionName, key, document); + Toast.Success($"Saved document '{key}'."); + Changes.NotifyChanged(); + _selectedKey = key; + _isNewDocument = false; + _dirty = false; + await LoadPageAsync(key); + } + catch (Exception ex) + { + _validationMessage = ex.Message; + } + } + + private async Task DeleteDocumentAsync() + { + if (!CanDelete || _selectedKey is null) + return; + + string key = _selectedKey; + bool confirmed = await Modal.ConfirmAsync( + "Delete Document", + $"Are you sure you want to delete document '{key}' from collection '{CollectionName}'?", + "Delete", + isDanger: true); + if (!confirmed) + return; + + try + { + bool deleted = await DbClient.DeleteDocumentAsync(CollectionName, key); + if (deleted) + { + Toast.Success($"Deleted document '{key}'."); + Changes.NotifyChanged(); + } + else + { + Toast.Info($"Document '{key}' was not found."); + } + + _selectedKey = null; + ClearEditor(); + await LoadPageAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + private Task DiscardChangesAsync() + { + if (!HasEditor) + return Task.CompletedTask; + + if (_isNewDocument) + { + ClearEditor(); + return Task.CompletedTask; + } + + _editorJson = _originalJson; + _dirty = false; + _validationMessage = null; + return Task.CompletedTask; + } + + private async Task FindByKeyAsync() + { + if (string.IsNullOrWhiteSpace(_lookupKey) || !await ConfirmDiscardIfDirtyAsync()) + return; + + string key = _lookupKey.Trim(); + try + { + JsonElement? document = await DbClient.GetDocumentAsync(CollectionName, key); + if (document is null) + { + Toast.Info($"Document '{key}' was not found."); + return; + } + + SelectDocument(new CollectionDocument { Key = key, Document = document.Value }, isDirty: false); + } + catch (Exception ex) + { + _error = ex.Message; + } + } + + private async Task HandleLookupKeyDown(KeyboardEventArgs e) + { + if (e.Key == "Enter") + await FindByKeyAsync(); + } + + private void OnEditorKeyChanged(ChangeEventArgs e) + { + _editorKey = e.Value?.ToString() ?? string.Empty; + _dirty = true; + } + + private void OnEditorJsonChanged(ChangeEventArgs e) + { + _editorJson = e.Value?.ToString() ?? string.Empty; + _dirty = true; + ValidateEditorJson(); + } + + private void ValidateEditorJson() + { + if (string.IsNullOrWhiteSpace(_editorJson)) + { + _validationMessage = "Document JSON is required."; + return; + } + + _validationMessage = CollectionJsonFormatter.TryClone(_editorJson, out _, out string? error) + ? null + : error; + } + + private async Task ConfirmDiscardIfDirtyAsync() + { + if (!_dirty) + return true; + + return await Modal.ConfirmAsync( + "Discard Changes", + "Discard unsaved collection document changes?", + "Discard", + isDanger: true); + } + + private void ClearEditor() + { + _selectedKey = null; + _editorKey = string.Empty; + _editorJson = string.Empty; + _originalJson = string.Empty; + _validationMessage = null; + _isNewDocument = false; + _dirty = false; + } + + private bool IsSelected(string key) + => !_isNewDocument && string.Equals(_selectedKey, key, StringComparison.Ordinal); + + private string GetRangeText() + { + if (_loading) + return "Loading..."; + + if (_totalCount == 0) + return "0 documents"; + + int first = ((_page - 1) * _pageSize) + 1; + int last = Math.Min(_totalCount, first + _documents.Count - 1); + return $"{first}-{last} of {_totalCount}"; + } +} diff --git a/src/CSharpDB.Admin/Helpers/CollectionJsonFormatter.cs b/src/CSharpDB.Admin/Helpers/CollectionJsonFormatter.cs new file mode 100644 index 00000000..068e7629 --- /dev/null +++ b/src/CSharpDB.Admin/Helpers/CollectionJsonFormatter.cs @@ -0,0 +1,127 @@ +using System.Text.Json; + +namespace CSharpDB.Admin.Helpers; + +public static class CollectionJsonFormatter +{ + private static readonly JsonSerializerOptions s_indentedOptions = new() + { + WriteIndented = true, + }; + + public static string Format(JsonElement document) + => JsonSerializer.Serialize(document, s_indentedOptions); + + public static bool TryFormat(string json, out string formatted, out string? error) + { + try + { + using var document = JsonDocument.Parse(json); + formatted = Format(document.RootElement); + error = null; + return true; + } + catch (JsonException ex) + { + formatted = json; + error = ex.Message; + return false; + } + } + + public static bool TryClone(string json, out JsonElement document, out string? error) + { + try + { + using var parsed = JsonDocument.Parse(json); + document = parsed.RootElement.Clone(); + error = null; + return true; + } + catch (JsonException ex) + { + document = default; + error = ex.Message; + return false; + } + } + + public static string GetKindLabel(JsonElement document) + => document.ValueKind switch + { + JsonValueKind.Object => "object", + JsonValueKind.Array => "array", + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True => "boolean", + JsonValueKind.False => "boolean", + JsonValueKind.Null => "null", + _ => "value", + }; + + public static string GetPreview(JsonElement document, int maxLength = 120) + { + string preview = document.ValueKind switch + { + JsonValueKind.Object => BuildObjectPreview(document), + JsonValueKind.Array => BuildArrayPreview(document), + JsonValueKind.String => document.GetString() ?? string.Empty, + JsonValueKind.Number => document.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + _ => document.GetRawText(), + }; + + preview = NormalizeWhitespace(preview); + if (preview.Length <= maxLength) + return preview; + + return maxLength <= 3 + ? preview[..maxLength] + : string.Concat(preview.AsSpan(0, maxLength - 3), "..."); + } + + private static string BuildObjectPreview(JsonElement document) + { + var parts = new List(); + foreach (var property in document.EnumerateObject().Take(4)) + parts.Add($"{property.Name}: {GetScalarPreview(property.Value)}"); + + return parts.Count == 0 + ? "{}" + : string.Join(", ", parts); + } + + private static string BuildArrayPreview(JsonElement document) + { + int count = 0; + var parts = new List(); + foreach (var item in document.EnumerateArray()) + { + count++; + if (parts.Count < 4) + parts.Add(GetScalarPreview(item)); + } + + return count == 0 + ? "[]" + : $"{count} item{(count == 1 ? string.Empty : "s")}: {string.Join(", ", parts)}"; + } + + private static string GetScalarPreview(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Object => "{...}", + JsonValueKind.Array => "[...]", + JsonValueKind.String => value.GetString() ?? string.Empty, + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => "null", + _ => value.GetRawText(), + }; + + private static string NormalizeWhitespace(string value) + => string.Join(" ", value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); +} diff --git a/src/CSharpDB.Admin/Helpers/CollectionNameValidator.cs b/src/CSharpDB.Admin/Helpers/CollectionNameValidator.cs new file mode 100644 index 00000000..84d5e623 --- /dev/null +++ b/src/CSharpDB.Admin/Helpers/CollectionNameValidator.cs @@ -0,0 +1,16 @@ +using System.Text.RegularExpressions; + +namespace CSharpDB.Admin.Helpers; + +public static partial class CollectionNameValidator +{ + public static bool IsValid(string? name) + => !string.IsNullOrWhiteSpace(name) && CollectionNamePattern().IsMatch(name.Trim()); + + public static string Normalize(string name) => name.Trim(); + + public const string HelpText = "Use letters, numbers, and underscores. The first character must be a letter or underscore."; + + [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$")] + private static partial Regex CollectionNamePattern(); +} diff --git a/src/CSharpDB.Admin/Models/TabDescriptor.cs b/src/CSharpDB.Admin/Models/TabDescriptor.cs index 28d3d759..0c00063a 100644 --- a/src/CSharpDB.Admin/Models/TabDescriptor.cs +++ b/src/CSharpDB.Admin/Models/TabDescriptor.cs @@ -6,6 +6,7 @@ public enum TabKind Query, TableData, ViewData, + CollectionData, Procedure, Pipeline, Storage, @@ -34,7 +35,7 @@ public TabDescriptor(string id, string title, string icon, TabKind kind, bool cl Closable = closable; } - /// Get the object name for data/view tabs (e.g. table name, view name). + /// Get the object name for data/view/collection tabs (e.g. table name, view name, collection name). public string? ObjectName { get => State.TryGetValue("ObjectName", out var v) ? v as string : null; diff --git a/src/CSharpDB.Admin/README.md b/src/CSharpDB.Admin/README.md index 2d72b59a..74905a91 100644 --- a/src/CSharpDB.Admin/README.md +++ b/src/CSharpDB.Admin/README.md @@ -8,10 +8,12 @@ database objects. ## What This Project Provides -- object explorer for user tables, system tables, forms, reports, views, +- object explorer for user tables, system tables, document collections, forms, reports, views, triggers, saved queries, and procedures - table browsing with insert, update, delete, per-column `LIKE` placement and `=` filters, and schema views +- collection browsing with paged JSON document inspection, create, update, + delete, and exact-key lookup - table designer for creating tables - SQL query tabs with paged results and guided SQL completions - procedure editor and execution surface @@ -105,8 +107,8 @@ dotnet run --project src/CSharpDB.Admin/CSharpDB.Admin.csproj - `Program.cs` - Blazor host startup and database client wiring - `Components/Layout` - shell layout, object explorer, tabs, and status UI -- `Components/Tabs` - table, query, procedure, storage, pipeline, form, and - report tab surfaces +- `Components/Tabs` - table, collection, query, procedure, storage, pipeline, + form, and report tab surfaces - `Components/Shared` - shared grid, editor, modal, context menu, and toast UI - `Services` - tab manager, database holder, change notifications, modal, toast, and theme services diff --git a/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs b/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs index 3c3652c3..9f9e9423 100644 --- a/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs +++ b/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs @@ -105,6 +105,7 @@ public async Task SwitchAsync(string databasePath) public Task GetDocumentAsync(string collectionName, string key, CancellationToken ct = default) => _inner.GetDocumentAsync(collectionName, key, ct); public Task PutDocumentAsync(string collectionName, string key, JsonElement document, CancellationToken ct = default) => _inner.PutDocumentAsync(collectionName, key, document, ct); public Task DeleteDocumentAsync(string collectionName, string key, CancellationToken ct = default) => _inner.DeleteDocumentAsync(collectionName, key, ct); + public Task DropCollectionAsync(string collectionName, CancellationToken ct = default) => _inner.DropCollectionAsync(collectionName, ct); public Task CheckpointAsync(CancellationToken ct = default) => _inner.CheckpointAsync(ct); public Task BackupAsync(BackupRequest request, CancellationToken ct = default) => _inner.BackupAsync(request, ct); public Task RestoreAsync(RestoreRequest request, CancellationToken ct = default) => _inner.RestoreAsync(request, ct); diff --git a/src/CSharpDB.Admin/Services/TabManagerService.cs b/src/CSharpDB.Admin/Services/TabManagerService.cs index 565d99ff..5e2e3cee 100644 --- a/src/CSharpDB.Admin/Services/TabManagerService.cs +++ b/src/CSharpDB.Admin/Services/TabManagerService.cs @@ -90,6 +90,16 @@ public TabDescriptor OpenViewTab(string viewName) return _tabs.First(t => t.Id == tab.Id); } + public TabDescriptor OpenCollectionTab(string collectionName) + { + var tab = new TabDescriptor($"collection:{collectionName}", collectionName, "bi-braces", TabKind.CollectionData) + { + ObjectName = collectionName + }; + OpenTab(tab); + return _tabs.First(t => t.Id == tab.Id); + } + public TabDescriptor OpenQueryTab(string? initialSql = null) { int num = Interlocked.Increment(ref _queryCounter); diff --git a/src/CSharpDB.Admin/wwwroot/css/app.css b/src/CSharpDB.Admin/wwwroot/css/app.css index d3e054c9..c3666e2e 100644 --- a/src/CSharpDB.Admin/wwwroot/css/app.css +++ b/src/CSharpDB.Admin/wwwroot/css/app.css @@ -1873,6 +1873,14 @@ body { letter-spacing: 0.3px; } +.schema-field > span { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.3px; +} + /* Input / select / textarea */ .schema-input { width: 100%; @@ -4142,6 +4150,203 @@ body { cursor: not-allowed; } +.collection-tab { + gap: 12px; +} + +.collection-key-lookup { + min-height: 28px; + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 220px; + padding: 0 8px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + color: var(--text-secondary); + flex-shrink: 0; +} + +.collection-key-lookup input { + min-width: 0; + width: 150px; + border: 0; + outline: 0; + color: var(--text-primary); + background: transparent; + font: inherit; + font-size: 11px; +} + +.collection-key-lookup button { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 0; + color: var(--text-secondary); + background: transparent; + cursor: pointer; +} + +.collection-key-lookup button:hover:not(:disabled) { + color: var(--text-primary); +} + +.collection-key-lookup button:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +.data-pager select { + min-height: 24px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + color: var(--text-secondary); + font-family: var(--font-ui); + font-size: 11px; +} + +.collection-browser { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: minmax(360px, 1fr) minmax(360px, 0.8fr); + gap: 12px; + padding: 0 16px 16px; +} + +.collection-list, +.collection-detail { + min-height: 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + overflow: hidden; +} + +.collection-list { + overflow: auto; +} + +.collection-grid tbody tr { + cursor: pointer; +} + +.collection-key-cell { + width: 28%; + font-family: var(--font-mono); + color: var(--accent-cyan); +} + +.collection-preview-cell { + max-width: 520px; + overflow: hidden; + color: var(--text-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.collection-detail { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; +} + +.collection-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.collection-detail-header > div { + min-width: 0; + display: grid; + gap: 2px; +} + +.collection-detail-header strong { + overflow: hidden; + font-family: var(--font-mono); + text-overflow: ellipsis; + white-space: nowrap; +} + +.collection-detail-label { + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.collection-dirty-badge { + flex: 0 0 auto; + padding: 2px 8px; + border: 1px solid rgba(224, 175, 104, 0.35); + border-radius: var(--radius-sm); + color: var(--accent-yellow); + background: rgba(224, 175, 104, 0.1); + font-size: 11px; +} + +.collection-json-field { + flex: 1; + min-height: 0; +} + +.collection-json-editor { + flex: 1; + min-height: 280px; + font-family: var(--font-mono); +} + +.collection-alert { + display: flex; + align-items: center; + gap: 8px; + margin: 0 16px; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.collection-alert.error { + border-color: rgba(247, 118, 142, 0.35); + color: var(--accent-red); + background: rgba(247, 118, 142, 0.08); +} + +.collection-detail .collection-alert { + margin: 0; +} + +.collection-empty-detail { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); +} + +@media (max-width: 980px) { + .collection-browser { + grid-template-columns: 1fr; + } + + .collection-key-lookup { + min-width: 0; + width: 100%; + } +} + .data-filter-bar { min-height: 36px; display: flex; diff --git a/src/CSharpDB.Api/Endpoints/CollectionEndpoints.cs b/src/CSharpDB.Api/Endpoints/CollectionEndpoints.cs index e21b121b..22ef3121 100644 --- a/src/CSharpDB.Api/Endpoints/CollectionEndpoints.cs +++ b/src/CSharpDB.Api/Endpoints/CollectionEndpoints.cs @@ -14,6 +14,7 @@ public static RouteGroupBuilder MapCollectionEndpoints(this RouteGroupBuilder gr group.MapGet("/collections/{name}/document", GetDocument); group.MapPut("/collections/{name}/document", PutDocument); group.MapDelete("/collections/{name}/document", DeleteDocument); + group.MapDelete("/collections/{name}", DropCollection); return group; } @@ -65,4 +66,10 @@ private static async Task DeleteDocument(string name, string key, ICSha ? Results.NoContent() : Results.NotFound(new { error = $"Document '{key}' was not found in collection '{name}'." }); } + + private static async Task DropCollection(string name, ICSharpDbClient db) + { + await db.DropCollectionAsync(name); + return Results.NoContent(); + } } diff --git a/src/CSharpDB.Client/CSharpDbClient.cs b/src/CSharpDB.Client/CSharpDbClient.cs index e237a6fb..545c95bd 100644 --- a/src/CSharpDB.Client/CSharpDbClient.cs +++ b/src/CSharpDB.Client/CSharpDbClient.cs @@ -75,6 +75,7 @@ public static ICSharpDbClient Create(CSharpDbClientOptions options) public Task GetDocumentAsync(string collectionName, string key, CancellationToken ct = default) => _inner.GetDocumentAsync(collectionName, key, ct); public Task PutDocumentAsync(string collectionName, string key, System.Text.Json.JsonElement document, CancellationToken ct = default) => _inner.PutDocumentAsync(collectionName, key, document, ct); public Task DeleteDocumentAsync(string collectionName, string key, CancellationToken ct = default) => _inner.DeleteDocumentAsync(collectionName, key, ct); + public Task DropCollectionAsync(string collectionName, CancellationToken ct = default) => _inner.DropCollectionAsync(collectionName, ct); public Task CheckpointAsync(CancellationToken ct = default) => _inner.CheckpointAsync(ct); public Task BackupAsync(BackupRequest request, CancellationToken ct = default) => _inner.BackupAsync(request, ct); public Task RestoreAsync(RestoreRequest request, CancellationToken ct = default) => _inner.RestoreAsync(request, ct); diff --git a/src/CSharpDB.Client/ICSharpDbClient.cs b/src/CSharpDB.Client/ICSharpDbClient.cs index 7eb8c5d5..c510af90 100644 --- a/src/CSharpDB.Client/ICSharpDbClient.cs +++ b/src/CSharpDB.Client/ICSharpDbClient.cs @@ -72,6 +72,7 @@ public interface ICSharpDbClient : IAsyncDisposable Task GetDocumentAsync(string collectionName, string key, CancellationToken ct = default); Task PutDocumentAsync(string collectionName, string key, JsonElement document, CancellationToken ct = default); Task DeleteDocumentAsync(string collectionName, string key, CancellationToken ct = default); + Task DropCollectionAsync(string collectionName, CancellationToken ct = default); Task CheckpointAsync(CancellationToken ct = default); Task BackupAsync(BackupRequest request, CancellationToken ct = default); diff --git a/src/CSharpDB.Client/Internal/EngineTransportClient.cs b/src/CSharpDB.Client/Internal/EngineTransportClient.cs index 650e3ee1..08fe12be 100644 --- a/src/CSharpDB.Client/Internal/EngineTransportClient.cs +++ b/src/CSharpDB.Client/Internal/EngineTransportClient.cs @@ -415,6 +415,12 @@ public async Task DeleteDocumentAsync(string collectionName, string key, C return await collection.DeleteAsync(key, ct); } + public async Task DropCollectionAsync(string collectionName, CancellationToken ct = default) + { + string normalizedName = RequireIdentifier(collectionName, nameof(collectionName)); + await (await GetDatabaseAsync(ct)).DropCollectionAsync(normalizedName, ct); + } + public async Task CheckpointAsync(CancellationToken ct = default) => await (await GetDatabaseAsync(ct)).CheckpointAsync(ct); diff --git a/src/CSharpDB.Client/Internal/GrpcTransportClient.cs b/src/CSharpDB.Client/Internal/GrpcTransportClient.cs index 85ab777f..4d9c3f02 100644 --- a/src/CSharpDB.Client/Internal/GrpcTransportClient.cs +++ b/src/CSharpDB.Client/Internal/GrpcTransportClient.cs @@ -344,6 +344,9 @@ public Task DeleteDocumentAsync(string collectionName, string key, Cancell Key = key, }, cancellationToken: ct), response => response.Value, ct); + public Task DropCollectionAsync(string collectionName, CancellationToken ct = default) + => CallEmptyAsync(_client.DropCollectionAsync(new CollectionNameRequest { CollectionName = collectionName }, cancellationToken: ct), ct); + public Task CheckpointAsync(CancellationToken ct = default) => CallEmptyAsync(_client.CheckpointAsync(EmptyRequest, cancellationToken: ct), ct); diff --git a/src/CSharpDB.Client/Internal/HttpTransportClient.cs b/src/CSharpDB.Client/Internal/HttpTransportClient.cs index 949e150a..cae3aa88 100644 --- a/src/CSharpDB.Client/Internal/HttpTransportClient.cs +++ b/src/CSharpDB.Client/Internal/HttpTransportClient.cs @@ -550,6 +550,16 @@ public async Task DeleteDocumentAsync(string collectionName, string key, C return true; } + public async Task DropCollectionAsync(string collectionName, CancellationToken ct = default) + { + using var response = await SendAsync( + HttpMethod.Delete, + BuildUri($"api/collections/{Escape(collectionName)}"), + payload: null, + ct); + await EnsureSuccessAsync(response, ct); + } + public async Task CheckpointAsync(CancellationToken ct = default) { using var response = await SendAsync(HttpMethod.Post, BuildUri("api/maintenance/checkpoint"), payload: null, ct); diff --git a/src/CSharpDB.Client/Protos/csharpdb_rpc.proto b/src/CSharpDB.Client/Protos/csharpdb_rpc.proto index dc6b4fd8..027594a1 100644 --- a/src/CSharpDB.Client/Protos/csharpdb_rpc.proto +++ b/src/CSharpDB.Client/Protos/csharpdb_rpc.proto @@ -71,6 +71,7 @@ service CSharpDbRpc { rpc GetDocument (GetDocumentRequest) returns (OptionalVariantValueResponse); rpc PutDocument (PutDocumentRequest) returns (google.protobuf.Empty); rpc DeleteDocument (DeleteDocumentRequest) returns (google.protobuf.BoolValue); + rpc DropCollection (CollectionNameRequest) returns (google.protobuf.Empty); rpc Checkpoint (google.protobuf.Empty) returns (google.protobuf.Empty); rpc Backup (BackupRequestMessage) returns (BackupResultMessage); diff --git a/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs index fbfc5fbd..ac421966 100644 --- a/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs +++ b/src/CSharpDB.Daemon/Grpc/CSharpDbRpcService.cs @@ -257,6 +257,9 @@ public override Task PutDocument(PutDocumentRequest request, ServerCallCo public override Task DeleteDocument(DeleteDocumentRequest request, ServerCallContext context) => ExecuteAsync(context, ct => client.DeleteDocumentAsync(request.CollectionName, request.Key, ct), value => new BoolValue { Value = value }); + public override Task DropCollection(CollectionNameRequest request, ServerCallContext context) + => ExecuteEmptyAsync(context, ct => client.DropCollectionAsync(request.CollectionName, ct)); + public override Task Checkpoint(Empty request, ServerCallContext context) => ExecuteEmptyAsync(context, ct => client.CheckpointAsync(ct)); diff --git a/src/CSharpDB.Engine/CollectionDocumentCodec.cs b/src/CSharpDB.Engine/CollectionDocumentCodec.cs index 65c8795b..05320c5a 100644 --- a/src/CSharpDB.Engine/CollectionDocumentCodec.cs +++ b/src/CSharpDB.Engine/CollectionDocumentCodec.cs @@ -86,10 +86,7 @@ public byte[] Encode(string key, T document) try { - T document = header.Format == CollectionPayloadCodec.CollectionPayloadFormat.Binary - ? CollectionBinaryDocumentCodec.Decode(documentPayload) - : JsonSerializer.Deserialize(documentPayload, s_jsonOptions)!; - + T document = DecodeDirectDocumentPayload(documentPayload, header.Format); return (key, document); } catch (Exception ex) when (IsFastHeaderFallbackCandidate(ex)) @@ -112,28 +109,14 @@ public T DecodeDocument(ReadOnlySpan payload) CollectionPayloadCodec.TryReadFastHeader(payload, out var header)) { ReadOnlySpan documentPayload = CollectionPayloadCodec.GetDocumentPayload(payload, header); - if (header.Format == CollectionPayloadCodec.CollectionPayloadFormat.Binary) + try { - try - { - return CollectionBinaryDocumentCodec.Decode(documentPayload); - } - catch (Exception ex) when (IsFastHeaderFallbackCandidate(ex)) - { - // Fall through to the slower validated / legacy path if the fast binary probe - // was triggered by a non-direct payload that happens to share the marker bytes. - } + return DecodeDirectDocumentPayload(documentPayload, header.Format); } - else + catch (Exception ex) when (IsFastHeaderFallbackCandidate(ex)) { - try - { - return JsonSerializer.Deserialize(documentPayload, s_jsonOptions)!; - } - catch (Exception ex) when (IsFastHeaderFallbackCandidate(ex)) - { - // Fall through to the slower validated / legacy path. - } + // Fall through to the slower validated / legacy path if the fast probe + // was triggered by a non-direct payload that happens to share the marker bytes. } } @@ -141,10 +124,7 @@ public T DecodeDocument(ReadOnlySpan payload) CollectionPayloadCodec.TryReadValidatedHeader(payload, out header)) { ReadOnlySpan documentPayload = CollectionPayloadCodec.GetDocumentPayload(payload, header); - if (header.Format == CollectionPayloadCodec.CollectionPayloadFormat.Binary) - return CollectionBinaryDocumentCodec.Decode(documentPayload); - - return JsonSerializer.Deserialize(documentPayload, s_jsonOptions)!; + return DecodeDirectDocumentPayload(documentPayload, header.Format); } return DecodeLegacy(payload).Document; @@ -254,6 +234,30 @@ private string DecodeLegacyKey(ReadOnlySpan payload) return values[0].AsText; } + [RequiresUnreferencedCode("Collection JSON deserialization requires reflection. Use SQL API for NativeAOT scenarios.")] + [RequiresDynamicCode("Collection JSON deserialization requires runtime code generation. Use SQL API for NativeAOT scenarios.")] + private static T DecodeDirectDocumentPayload( + ReadOnlySpan documentPayload, + CollectionPayloadCodec.CollectionPayloadFormat format) + { + if (format == CollectionPayloadCodec.CollectionPayloadFormat.Binary) + { + if (typeof(T) == typeof(JsonElement)) + return (T)(object)DecodeBinaryDocumentAsJsonElement(documentPayload); + + return CollectionBinaryDocumentCodec.Decode(documentPayload); + } + + return JsonSerializer.Deserialize(documentPayload, s_jsonOptions)!; + } + + private static JsonElement DecodeBinaryDocumentAsJsonElement(ReadOnlySpan documentPayload) + { + byte[] jsonUtf8 = CollectionBinaryDocumentCodec.EncodeJsonUtf8(documentPayload); + using JsonDocument document = JsonDocument.Parse(jsonUtf8); + return document.RootElement.Clone(); + } + private static bool IsFastHeaderFallbackCandidate(Exception ex) => ex is CSharpDbException or JsonException or DecoderFallbackException or ArgumentOutOfRangeException or IndexOutOfRangeException or OverflowException; } diff --git a/src/CSharpDB.Engine/Database.cs b/src/CSharpDB.Engine/Database.cs index 9dfb215a..e357faca 100644 --- a/src/CSharpDB.Engine/Database.cs +++ b/src/CSharpDB.Engine/Database.cs @@ -1410,6 +1410,12 @@ private static string BuildCollectionCacheKey(string catalogName, bool generated ? catalogName + GeneratedCollectionCacheSuffix : catalogName; + private void RemoveCachedCollection(string catalogName) + { + _collectionCache.Remove(catalogName); + _collectionCache.Remove(catalogName + GeneratedCollectionCacheSuffix); + } + /// /// Returns the names of all document collections in the database. /// @@ -1421,6 +1427,69 @@ public IReadOnlyCollection GetCollectionNames() .ToArray(); } + /// + /// Drop a document collection and its collection indexes. + /// + public async ValueTask DropCollectionAsync(string name, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + IDisposable? writeScope = _inTransaction + ? WriteOperationScope.NoOp + : await AcquireWriteOperationScopeAsync(ct); + try + { + InvalidateCachesIfSchemaChanged(); + + string catalogName = $"{CollectionPrefix}{name}"; + if (_catalog.GetTable(catalogName) is null) + { + throw new CSharpDbException( + ErrorCode.TableNotFound, + $"Collection '{name}' not found."); + } + + PagerCommitResult commit = PagerCommitResult.Completed; + bool completeCommit = false; + bool needsTx = !_inTransaction; + if (needsTx) + await _pager.BeginTransactionAsync(ct); + + try + { + await _catalog.DropTableAsync(catalogName, ct); + _pendingCollectionCatalogMutations.Remove(catalogName); + RemoveCachedCollection(catalogName); + _statementCache.Clear(); + _observedSchemaVersion = _catalog.SchemaVersion; + + if (needsTx) + { + commit = await BeginCommitWithCatalogSyncAsync(ct); + completeCommit = true; + writeScope?.Dispose(); + writeScope = WriteOperationScope.NoOp; + } + } + catch + { + if (needsTx) + await RecoverCatalogStateAfterFailedCommitAsync(); + throw; + } + + if (completeCommit) + { + await WaitForCommitOrRecoverAsync(commit); + await PersistHybridStateAsync(HybridPersistenceTriggers.Commit, ct); + } + } + finally + { + writeScope?.Dispose(); + } + } + private Statement ParseCached(string sql) => _statementCache.GetOrAdd( sql, diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs index 4c492960..3d3f3cdb 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs @@ -55,6 +55,46 @@ public void OpenFormEntryTab_DeduplicatesByFormId() Assert.Equal(TabKind.FormEntry, second.Kind); } + [Fact] + public void OpenCollectionTab_CreatesCollectionDataTab() + { + var manager = new TabManagerService(); + + TabDescriptor tab = manager.OpenCollectionTab("profiles"); + + Assert.Equal("collection:profiles", tab.Id); + Assert.Equal("profiles", tab.Title); + Assert.Equal("profiles", tab.ObjectName); + Assert.Equal(TabKind.CollectionData, tab.Kind); + Assert.Equal(tab, manager.ActiveTab); + } + + [Fact] + public void OpenCollectionTab_DeduplicatesByCollectionName() + { + var manager = new TabManagerService(); + + TabDescriptor first = manager.OpenCollectionTab("profiles"); + TabDescriptor second = manager.OpenCollectionTab("profiles"); + + Assert.Same(first, second); + Assert.Equal(2, manager.Tabs.Count); + Assert.Equal(second, manager.ActiveTab); + } + + [Fact] + public void CloseTabsForObject_ClosesCollectionTab() + { + var manager = new TabManagerService(); + manager.OpenCollectionTab("profiles"); + manager.OpenTableTab("customers"); + + manager.CloseTabsForObject("profiles"); + + Assert.Equal(["welcome", "table:customers"], manager.Tabs.Select(tab => tab.Id).ToArray()); + Assert.Equal("table:customers", manager.ActiveTab!.Id); + } + [Fact] public void OpenFormEntryTab_UpdatesInitialStateWhenExistingTabIsReopened() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Helpers/CollectionJsonFormatterTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Helpers/CollectionJsonFormatterTests.cs new file mode 100644 index 00000000..ee5085f1 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Helpers/CollectionJsonFormatterTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using CSharpDB.Admin.Helpers; + +namespace CSharpDB.Admin.Forms.Tests.Helpers; + +public sealed class CollectionJsonFormatterTests +{ + [Theory] + [InlineData("""{"name":"Ada","active":true}""", "object")] + [InlineData("""["alpha","beta"]""", "array")] + [InlineData("42", "number")] + [InlineData("null", "null")] + public void Format_PreservesSupportedJsonKinds(string json, string expectedKind) + { + using var document = JsonDocument.Parse(json); + + string formatted = CollectionJsonFormatter.Format(document.RootElement); + + using var formattedDocument = JsonDocument.Parse(formatted); + Assert.Equal(expectedKind, CollectionJsonFormatter.GetKindLabel(formattedDocument.RootElement)); + } + + [Fact] + public void TryClone_RejectsInvalidJson() + { + bool result = CollectionJsonFormatter.TryClone("{ invalid", out _, out string? error); + + Assert.False(result); + Assert.False(string.IsNullOrWhiteSpace(error)); + } + + [Fact] + public void GetPreview_ProducesStableObjectPreview() + { + using var document = JsonDocument.Parse(""" + { + "name": "Ada", + "active": true, + "score": 42, + "tags": ["admin"] + } + """); + + string preview = CollectionJsonFormatter.GetPreview(document.RootElement); + + Assert.Equal("name: Ada, active: true, score: 42, tags: [...]", preview); + } + + [Fact] + public void GetPreview_TruncatesLongPreview() + { + using var document = JsonDocument.Parse("""{"name":"abcdefghijklmnopqrstuvwxyz"}"""); + + string preview = CollectionJsonFormatter.GetPreview(document.RootElement, maxLength: 12); + + Assert.Equal("name: abc...", preview); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/CollectionClientIntegrationTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/CollectionClientIntegrationTests.cs new file mode 100644 index 00000000..c190d478 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/CollectionClientIntegrationTests.cs @@ -0,0 +1,46 @@ +using System.Text.Json; + +namespace CSharpDB.Admin.Forms.Tests.Services; + +public sealed class CollectionClientIntegrationTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task CollectionClient_CreateBrowseUpdateAndDeleteDocument() + { + await using var db = await TestDatabaseScope.CreateAsync("admin_collections"); + + JsonElement initialDocument; + using (var json = JsonDocument.Parse("""{"name":"Ada","active":true}""")) + initialDocument = json.RootElement.Clone(); + + await db.Client.PutDocumentAsync("profiles", "user-1", initialDocument, Ct); + + var collectionNames = await db.Client.GetCollectionNamesAsync(Ct); + Assert.Contains("profiles", collectionNames); + + var page = await db.Client.BrowseCollectionAsync("profiles", page: 1, pageSize: 25, ct: Ct); + var browsed = Assert.Single(page.Documents); + Assert.Equal("user-1", browsed.Key); + Assert.Equal("Ada", browsed.Document.GetProperty("name").GetString()); + + JsonElement updatedDocument; + using (var json = JsonDocument.Parse("""{"name":"Ada Lovelace","active":false}""")) + updatedDocument = json.RootElement.Clone(); + + await db.Client.PutDocumentAsync("profiles", "user-1", updatedDocument, Ct); + + JsonElement? loaded = await db.Client.GetDocumentAsync("profiles", "user-1", Ct); + Assert.NotNull(loaded); + Assert.Equal("Ada Lovelace", loaded.Value.GetProperty("name").GetString()); + Assert.False(loaded.Value.GetProperty("active").GetBoolean()); + + Assert.True(await db.Client.DeleteDocumentAsync("profiles", "user-1", Ct)); + Assert.Null(await db.Client.GetDocumentAsync("profiles", "user-1", Ct)); + + await db.Client.DropCollectionAsync("profiles", Ct); + collectionNames = await db.Client.GetCollectionNamesAsync(Ct); + Assert.DoesNotContain("profiles", collectionNames); + } +} diff --git a/tests/CSharpDB.Api.Tests/HttpTransportClientTests.cs b/tests/CSharpDB.Api.Tests/HttpTransportClientTests.cs index 49571e4e..7386ebc5 100644 --- a/tests/CSharpDB.Api.Tests/HttpTransportClientTests.cs +++ b/tests/CSharpDB.Api.Tests/HttpTransportClientTests.cs @@ -106,13 +106,16 @@ public async Task HttpTransport_SupportsTransactionsCollectionsSavedQueriesAndCh Assert.True(await _client.DeleteDocumentAsync("profiles", "user-1", Ct)); Assert.Null(await _client.GetDocumentAsync("profiles", "user-1", Ct)); Assert.False(await _client.DeleteDocumentAsync("profiles", "missing", Ct)); + await _client.DropCollectionAsync("profiles", Ct); + collections = await _client.GetCollectionNamesAsync(Ct); + Assert.DoesNotContain("profiles", collections); await _client.CheckpointAsync(Ct); var info = await _client.GetInfoAsync(Ct); Assert.True(info.TableCount >= 1); Assert.True(info.SavedQueryCount >= 1); - Assert.True(info.CollectionCount >= 1); + Assert.Equal(0, info.CollectionCount); } [Fact] diff --git a/tests/CSharpDB.Daemon.Tests/GrpcClientTests.cs b/tests/CSharpDB.Daemon.Tests/GrpcClientTests.cs index b482a475..919678cc 100644 --- a/tests/CSharpDB.Daemon.Tests/GrpcClientTests.cs +++ b/tests/CSharpDB.Daemon.Tests/GrpcClientTests.cs @@ -274,6 +274,9 @@ public async Task GrpcClient_Collections_RoundTripNestedDocuments() Assert.Single(browse.Documents); Assert.Equal("doc-1", browse.Documents[0].Key); Assert.Equal("proto", browse.Documents[0].Document.GetProperty("tags")[1].GetString()); + + await client.DropCollectionAsync("grpc_docs", Ct); + Assert.DoesNotContain("grpc_docs", await client.GetCollectionNamesAsync(Ct)); } [Fact] diff --git a/tests/CSharpDB.Tests/CollectionJsonElementTests.cs b/tests/CSharpDB.Tests/CollectionJsonElementTests.cs index 4ec9fa48..30aef2f0 100644 --- a/tests/CSharpDB.Tests/CollectionJsonElementTests.cs +++ b/tests/CSharpDB.Tests/CollectionJsonElementTests.cs @@ -5,6 +5,15 @@ namespace CSharpDB.Tests; public sealed class CollectionJsonElementTests { + private sealed record TypedCollectionDocument( + string Name, + int Count, + bool Active, + string[] Tags, + TypedCollectionNested Nested); + + private sealed record TypedCollectionNested(string City); + [Fact] public async Task JsonElementCollection_RoundTripsDirectPayloadDocuments() { @@ -22,4 +31,40 @@ public async Task JsonElementCollection_RoundTripsDirectPayloadDocuments() Assert.Equal("json", loaded.GetProperty("name").GetString()); Assert.Equal(2, loaded.GetProperty("meta").GetProperty("count").GetInt32()); } + + [Fact] + public async Task JsonElementCollection_ReadsBinaryTypedDocuments() + { + var ct = TestContext.Current.CancellationToken; + await using var db = await Database.OpenInMemoryAsync(ct); + var typedCollection = await db.GetCollectionAsync("typed_docs", ct); + + await typedCollection.PutAsync( + "doc-1", + new TypedCollectionDocument( + "scanner", + 2, + true, + ["alpha", "beta"], + new TypedCollectionNested("Seattle")), + ct); + + var jsonCollection = await db.GetCollectionAsync("typed_docs", ct); + + JsonElement loaded = await jsonCollection.GetAsync("doc-1", ct); + Assert.Equal(JsonValueKind.Object, loaded.ValueKind); + Assert.Equal("scanner", loaded.GetProperty("name").GetString()); + Assert.Equal(2, loaded.GetProperty("count").GetInt32()); + Assert.True(loaded.GetProperty("active").GetBoolean()); + Assert.Equal("beta", loaded.GetProperty("tags")[1].GetString()); + Assert.Equal("Seattle", loaded.GetProperty("nested").GetProperty("city").GetString()); + + var scanned = new List>(); + await foreach (var item in jsonCollection.ScanAsync(ct)) + scanned.Add(item); + + var row = Assert.Single(scanned); + Assert.Equal("doc-1", row.Key); + Assert.Equal("scanner", row.Value.GetProperty("name").GetString()); + } } diff --git a/tests/CSharpDB.Tests/CollectionTests.cs b/tests/CSharpDB.Tests/CollectionTests.cs index a13f21dd..6436852d 100644 --- a/tests/CSharpDB.Tests/CollectionTests.cs +++ b/tests/CSharpDB.Tests/CollectionTests.cs @@ -353,6 +353,33 @@ public async Task GetCollectionNames() Assert.Equal(new[] { "logs", "products", "users" }, names); } + [Fact] + public async Task DropCollection_RemovesCollectionAndIndexes() + { + var ct = TestContext.Current.CancellationToken; + var users = await _db.GetCollectionAsync("users", ct); + + await users.PutAsync("u:1", new User("Alice", 30, "alice@example.com"), ct); + await users.EnsureIndexAsync(user => user.Name, ct); + + Assert.Contains("users", _db.GetCollectionNames()); + Assert.Contains(_db.GetIndexes(), index => index.TableName == "_col_users"); + + await _db.DropCollectionAsync("users", ct); + + Assert.DoesNotContain("users", _db.GetCollectionNames()); + Assert.DoesNotContain(_db.GetIndexes(), index => index.TableName == "_col_users"); + + await ReopenDatabaseAsync(ct); + + Assert.DoesNotContain("users", _db.GetCollectionNames()); + Assert.DoesNotContain(_db.GetIndexes(), index => index.TableName == "_col_users"); + + var ex = await Assert.ThrowsAsync( + async () => await _db.DropCollectionAsync("users", ct)); + Assert.Equal(ErrorCode.TableNotFound, ex.Code); + } + [Fact] public async Task GetCollectionAsync_CachedCollectionLookup_ReleasesWriteGate() { From 526783012d21e8f1fd22197c8836ccbe0d0063ee Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Thu, 30 Apr 2026 18:49:35 -0700 Subject: [PATCH 23/39] Checkpoint phase 9 extension host work --- CSharpDB.slnx | 1 + src/CSharpDB.Admin/CSharpDB.Admin.csproj | 1 + .../Components/Layout/CommandPalette.razor | 44 ++ .../Components/Layout/MainLayout.razor | 4 + .../Components/Layout/NavMenu.razor | 138 ++++- .../Components/Tabs/CallbacksTab.razor | 485 ++++++++++++++++++ .../Configuration/AdminHostDatabaseOptions.cs | 29 +- src/CSharpDB.Admin/Models/TabDescriptor.cs | 3 +- src/CSharpDB.Admin/Program.cs | 12 +- .../Services/AdminHostCallbacks.cs | 99 ++++ .../Services/DatabaseClientHolder.cs | 10 +- .../Services/HostCallbackCatalogService.cs | 190 +++++++ .../Services/HostCallbackPolicyService.cs | 25 + .../Services/TabManagerService.cs | 31 ++ src/CSharpDB.Admin/wwwroot/css/app.css | 321 +++++++++++- src/CSharpDB.Primitives/DbCommands.cs | 14 +- src/CSharpDB.Primitives/DbExtensions.cs | 249 +++++++++ src/CSharpDB.Primitives/DbFunctions.cs | 15 +- src/CSharpDB.Primitives/DbHostCallbacks.cs | 63 +++ .../Admin/AdminClientOptionsBuilderTests.cs | 59 +++ .../Admin/AdminHostCallbacksTests.cs | 65 +++ .../Admin/HostCallbackCatalogServiceTests.cs | 265 ++++++++++ .../Admin/HostCallbackPolicyServiceTests.cs | 73 +++ .../Admin/TabManagerServiceTests.cs | 31 ++ .../CSharpDB.ExtensionSandbox.Worker.csproj | 11 + .../Program.cs | 141 +++++ tests/CSharpDB.Tests/CSharpDB.Tests.csproj | 1 + .../DbExtensionPolicyTests.cs | 266 ++++++++++ .../OutOfProcessCommandSandboxPrototype.cs | 460 +++++++++++++++++ .../OutOfProcessSandboxMeasurementTests.cs | 75 +++ .../OutOfProcessSandboxPrototypeTests.cs | 81 +++ .../TrustedCommandRegistryTests.cs | 24 +- .../TrustedScalarFunctionTests.cs | 24 +- 33 files changed, 3288 insertions(+), 22 deletions(-) create mode 100644 src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor create mode 100644 src/CSharpDB.Admin/Services/AdminHostCallbacks.cs create mode 100644 src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs create mode 100644 src/CSharpDB.Admin/Services/HostCallbackPolicyService.cs create mode 100644 src/CSharpDB.Primitives/DbExtensions.cs create mode 100644 src/CSharpDB.Primitives/DbHostCallbacks.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Admin/AdminHostCallbacksTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs create mode 100644 tests/CSharpDB.ExtensionSandbox.Worker/CSharpDB.ExtensionSandbox.Worker.csproj create mode 100644 tests/CSharpDB.ExtensionSandbox.Worker/Program.cs create mode 100644 tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs create mode 100644 tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessCommandSandboxPrototype.cs create mode 100644 tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxMeasurementTests.cs create mode 100644 tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxPrototypeTests.cs diff --git a/CSharpDB.slnx b/CSharpDB.slnx index f4685ad9..6882d38d 100644 --- a/CSharpDB.slnx +++ b/CSharpDB.slnx @@ -54,6 +54,7 @@ + diff --git a/src/CSharpDB.Admin/CSharpDB.Admin.csproj b/src/CSharpDB.Admin/CSharpDB.Admin.csproj index 2bafa2c4..2c073bb4 100644 --- a/src/CSharpDB.Admin/CSharpDB.Admin.csproj +++ b/src/CSharpDB.Admin/CSharpDB.Admin.csproj @@ -9,6 +9,7 @@ + diff --git a/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor b/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor index 09d26790..40fb3611 100644 --- a/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor +++ b/src/CSharpDB.Admin/Components/Layout/CommandPalette.razor @@ -1,7 +1,9 @@ +@using CSharpDB.Primitives @inject ICSharpDbClient DbClient @inject IFormRepository FormRepository @inject IReportRepository ReportRepository @inject TabManagerService TabManager +@inject HostCallbackCatalogService CallbackCatalog @inject DatabaseChangeService Changes @inject ModalService Modal @inject ToastService Toast @@ -98,6 +100,7 @@ { _items.Clear(); AddCoreCommands(); + await AddCallbackCommandsAsync(); IReadOnlyCollection tables = await DbClient.GetTableNamesAsync(); foreach (string tableName in tables.Where(static name => !name.StartsWith("_", StringComparison.Ordinal)).OrderBy(name => name)) @@ -214,6 +217,7 @@ _items.Add(new PaletteItem("Command", "New Query", "Open a SQL editor", "bi-terminal", "icon-system", () => { TabManager.OpenQueryTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Table", "Open table designer", "bi-table", "icon-table", () => { TabManager.OpenTableDesignerTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Collection", "Create or open a document collection", "bi-braces", "icon-view", OpenNewCollectionAsync)); + _items.Add(new PaletteItem("Command", "Callbacks", "Open host callback catalog", "bi-plug", "icon-system", () => { TabManager.OpenCallbacksTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Form", "Open form designer", "bi-ui-checks-grid", "icon-form", () => { TabManager.OpenFormDesignerTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Report", "Open report designer", "bi-file-earmark-richtext", "icon-report", () => { TabManager.OpenReportDesignerTab(); return Task.CompletedTask; })); _items.Add(new PaletteItem("Command", "New Procedure", "Open procedure editor", "bi-gear-wide-connected", "icon-trigger", () => { TabManager.OpenNewProcedureTab(); return Task.CompletedTask; })); @@ -221,6 +225,25 @@ _items.Add(new PaletteItem("Command", "Storage Inspector", "Open storage diagnostics", "bi-hdd-stack", "icon-system", () => { TabManager.OpenStorageTab(); return Task.CompletedTask; })); } + private async Task AddCallbackCommandsAsync() + { + foreach (HostCallbackCatalogEntry callback in await CallbackCatalog.GetEntriesAsync()) + { + HostCallbackCatalogEntry captured = callback; + _items.Add(new PaletteItem( + captured.IsMissingRegistration ? "Missing callback" : GetCallbackKindLabel(captured.Kind), + captured.Name, + FormatCallbackSubtitle(captured), + captured.IsMissingRegistration ? "bi-exclamation-triangle" : "bi-plug", + captured.IsMissingRegistration ? "icon-report" : "icon-system", + () => + { + TabManager.OpenCallbacksTab(captured.Name, captured.Kind.ToString(), captured.Arity); + return Task.CompletedTask; + })); + } + } + private async Task OpenNewCollectionAsync() { string? enteredName = await Modal.PromptAsync( @@ -291,6 +314,27 @@ Changes.Changed -= OnChanged; } + private static string GetCallbackKindLabel(AutomationCallbackKind kind) + => kind switch + { + AutomationCallbackKind.ScalarFunction => "Scalar function", + AutomationCallbackKind.Command => "Command", + _ => kind.ToString(), + }; + + private static string FormatCallbackSubtitle(HostCallbackCatalogEntry callback) + { + if (callback.IsMissingRegistration) + return $"Missing registration, referenced {callback.References.Count} time{(callback.References.Count == 1 ? "" : "s")}"; + + if (!string.IsNullOrWhiteSpace(callback.Descriptor?.Description)) + return callback.Descriptor.Description; + + return callback.Descriptor is null || callback.Descriptor.Capabilities.Count == 0 + ? callback.Descriptor?.Runtime.ToString() ?? "Callback" + : $"{callback.Descriptor.Runtime} · {string.Join(", ", callback.Descriptor.Capabilities.Select(static capability => capability.Name))}"; + } + private sealed record PaletteItem( string Kind, string Title, diff --git a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor index 6c37d99f..9b77d2d7 100644 --- a/src/CSharpDB.Admin/Components/Layout/MainLayout.razor +++ b/src/CSharpDB.Admin/Components/Layout/MainLayout.razor @@ -47,6 +47,10 @@ Tab="tab" CollectionName="@(tab.ObjectName ?? string.Empty)" /> break; + case TabKind.HostCallbacks: + + break; case TabKind.Procedure: diff --git a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor index 560c21b0..7566f5e5 100644 --- a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor +++ b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor @@ -4,6 +4,7 @@ @inject IFormRepository FormRepository @inject IReportRepository ReportRepository @inject TabManagerService TabManager +@inject HostCallbackCatalogService CallbackCatalog @inject ToastService Toast @inject ModalService Modal @inject IJSRuntime JS @@ -37,6 +38,7 @@ +
@@ -561,6 +563,39 @@ }
} + + @* Host callbacks *@ + @if (ShouldShowGroup("callbacks")) + { +
+
+ + + Callbacks + @_callbacks.Count +
+ @if (_expandedGroups.Contains("callbacks")) + { +
+ @foreach (HostCallbackCatalogEntry callback in FilterCallbacks()) + { + var currentCallback = callback; +
+ + @currentCallback.Name + @(currentCallback.IsMissingRegistration ? "missing" : GetCallbackKindLabel(currentCallback.Kind)) +
+ } +
+ } +
+ }
@@ -598,11 +633,12 @@ private IReadOnlyList? _indexes; private IReadOnlyList? _triggers; private IReadOnlyList? _procedures; + private IReadOnlyList _callbacks = []; private IReadOnlyList? _forms; private IReadOnlyList? _reports; private string _searchQuery = string.Empty; private string _objectFilter = "all"; - private readonly HashSet _expandedGroups = new() { "tables", "collections", "forms", "reports", "system", "views", "procedures" }; + private readonly HashSet _expandedGroups = new() { "tables", "collections", "forms", "reports", "system", "views", "procedures", "callbacks" }; private readonly HashSet _expandedTableNodes = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _tableSchemas = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _tableSchemaLoadFailures = new(StringComparer.OrdinalIgnoreCase); @@ -669,6 +705,8 @@ private async Task LoadObjects() { + _callbacks = await CallbackCatalog.GetEntriesAsync(); + try { var allTables = (await DbClient.GetTableNamesAsync()).OrderBy(n => n).ToList(); @@ -740,6 +778,7 @@ "all" => true, "tables" => group is "tables" or "views", "collections" => group == "collections", + "callbacks" => group == "callbacks", "forms" => group == "forms", "reports" => group == "reports", _ => string.Equals(group, _objectFilter, StringComparison.OrdinalIgnoreCase), @@ -891,6 +930,14 @@ private static string GetTriggerTypeLabel(TriggerSchema trigger) => trigger.Timing.ToString().ToUpperInvariant(); + private static string GetCallbackKindLabel(CSharpDB.Primitives.AutomationCallbackKind kind) + => kind switch + { + CSharpDB.Primitives.AutomationCallbackKind.ScalarFunction => "scalar", + CSharpDB.Primitives.AutomationCallbackKind.Command => "command", + _ => kind.ToString().ToLowerInvariant(), + }; + private IEnumerable FilterIndexes() { if (_indexes is null) return Enumerable.Empty(); @@ -915,6 +962,30 @@ || (p.Description?.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ?? false)); } + private IEnumerable FilterCallbacks() + { + if (string.IsNullOrWhiteSpace(_searchQuery)) + return _callbacks; + + return _callbacks.Where(CallbackMatchesSearch); + } + + private bool CallbackMatchesSearch(HostCallbackCatalogEntry callback) + { + string query = _searchQuery.Trim(); + return callback.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || callback.Kind.ToString().Contains(query, StringComparison.OrdinalIgnoreCase) + || (callback.IsMissingRegistration && "missing".Contains(query, StringComparison.OrdinalIgnoreCase)) + || (callback.Descriptor?.Runtime.ToString().Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) + || (callback.Descriptor?.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) + || (callback.Descriptor?.Capabilities.Any(capability => capability.Name.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)) ?? false) + || callback.References.Any(reference => + reference.OwnerName.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.OwnerKind.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.Surface.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.Location.Contains(query, StringComparison.OrdinalIgnoreCase)); + } + private IEnumerable FilterForms() { if (_forms is null) return Enumerable.Empty(); @@ -946,6 +1017,9 @@ private void OpenSystemCatalogTab(SystemCatalogItem item) => TabManager.OpenSystemCatalogTab(item.Name, item.Sql); + private void OpenCallback(HostCallbackCatalogEntry callback) + => TabManager.OpenCallbacksTab(callback.Name, callback.Kind.ToString(), callback.Arity); + private bool IsActive(string type, string name) { var activeTab = TabManager.ActiveTab; @@ -960,6 +1034,35 @@ return activeTab.Id == $"{type}:{name}"; } + private bool IsActiveCallback(HostCallbackCatalogEntry callback) + { + var activeTab = TabManager.ActiveTab; + if (activeTab?.Id != "callbacks:host") + return false; + + if (!activeTab.State.TryGetValue("SelectedCallbackName", out object? nameValue) + || nameValue is not string selectedName + || !selectedName.Equals(callback.Name, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (activeTab.State.TryGetValue("SelectedCallbackKind", out object? kindValue) + && kindValue is string selectedKind + && !selectedKind.Equals(callback.Kind.ToString(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (activeTab.State.TryGetValue("SelectedCallbackArity", out object? arityValue) + && arityValue is int selectedArity) + { + return callback.Arity == selectedArity; + } + + return true; + } + private bool IsActiveForm(string formId) { var activeTab = TabManager.ActiveTab; @@ -1149,6 +1252,26 @@ }); } + private void ShowCallbacksGroupMenu(MouseEventArgs e) + { + ShowContextMenu(e, new List + { + new() { Label = "Open", Icon = "bi-plug", OnClick = () => TabManager.OpenCallbacksTab() }, + ContextMenuItem.Separator(), + new() { Label = "Refresh", Icon = "bi-arrow-clockwise", OnClick = async () => { await LoadObjects(); await InvokeAsync(StateHasChanged); } }, + }); + } + + private void ShowCallbackItemMenu(MouseEventArgs e, HostCallbackCatalogEntry callback) + { + ShowContextMenu(e, new List + { + new() { Label = "Open", Icon = "bi-plug", OnClick = () => OpenCallback(callback) }, + ContextMenuItem.Separator(), + new() { Label = "Copy Name", Icon = "bi-clipboard", OnClick = async () => await CopyCallbackNameAsync(callback.Name) }, + }); + } + private async Task OpenNewCollectionAsync() { string? enteredName = await Modal.PromptAsync( @@ -1200,6 +1323,19 @@ } } + private async Task CopyCallbackNameAsync(string callbackName) + { + try + { + await JS.InvokeVoidAsync("clipboardInterop.writeText", callbackName); + Toast.Success($"Copied '{callbackName}'."); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + private async Task ConfirmDropTableAsync(string tableName) { var confirmed = await Modal.ConfirmAsync("Drop Table", $"Are you sure you want to drop table '{tableName}'? This action cannot be undone.", "Drop", isDanger: true); diff --git a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor new file mode 100644 index 00000000..c8bb7e4f --- /dev/null +++ b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor @@ -0,0 +1,485 @@ +@using CSharpDB.Primitives +@inject HostCallbackCatalogService CallbackCatalog +@inject HostCallbackPolicyService CallbackPolicy + +
+
+
+ + / + callbacks +
+ +
+ +
+ +
+ + + +
+ +
+ @FilteredEntries.Count of @_entries.Count callbacks +
+
+ +
+
+ @if (_entries.Count == 0) + { +
No host callbacks registered or referenced.
+ } + else if (FilteredEntries.Count == 0) + { +
No matching callbacks.
+ } + else + { + + + + + + + + + + + + + + @foreach (HostCallbackCatalogEntry entry in FilteredEntries) + { + string key = GetEntryKey(entry); + + + + + + + + + + } + +
NameKindRuntimeArityRefsCapabilitiesStatus
+ @if (entry.IsMissingRegistration) + { + + } + @entry.Name + @GetKindLabel(entry.Kind)@FormatRuntime(entry)@FormatArity(entry.Arity)@entry.References.Count@FormatCapabilitySummary(entry)@GetEntryStatus(entry)
+ } +
+ +
+ @if (SelectedEntry is { } selected) + { +
+
+
@GetKindLabel(selected.Kind)
+

@selected.Name

+
+ @GetEntryStatus(selected) +
+ + @if (selected.Descriptor is { } descriptor) + { + DbExtensionPolicyDecision policy = CallbackPolicy.Evaluate(descriptor); + + @if (!string.IsNullOrWhiteSpace(descriptor.Description)) + { +

@descriptor.Description

+ } + + + + + + + + + + + + + + + +
RegistrationRegistered
References@selected.References.Count
Policy@GetPolicyStatus(policy)
Policy Reason@(policy.DenialReason ?? "Granted by host policy.")
Runtime@descriptor.Runtime
Return Type@(descriptor.ReturnType?.ToString() ?? "-")
Arity@FormatArity(descriptor.Arity)
Timeout@FormatTimeout(descriptor.Timeout)
Effective Timeout@FormatTimeout(policy.Timeout)
Max Memory@FormatBytes(policy.MaxMemoryBytes)
Flags@FormatFlags(descriptor)
+ +
+

Capability Decisions

+ @if (policy.Capabilities.Count == 0) + { +
None
+ } + else + { +
+ @foreach (DbExtensionCapabilityDecision capability in policy.Capabilities) + { +
+
+ @capability.Name + @(capability.PolicySource ?? "No policy source") +

@(capability.Reason ?? "No policy reason.")

+
+ @capability.Status +
+ } +
+ } +
+ +
+

Capability Requests

+ @if (descriptor.Capabilities.Count == 0) + { +
None
+ } + else + { +
+ @foreach (DbExtensionCapabilityRequest capability in descriptor.Capabilities) + { + @FormatCapability(capability) + } +
+ } +
+ +
+

References

+ @if (selected.References.Count == 0) + { +
None
+ } + else + { +
+ @foreach (HostCallbackReference reference in selected.References) + { +
+ @reference.OwnerKind: @reference.OwnerName + @reference.Surface + @reference.Location +
+ } +
+ } +
+ +
+

Metadata

+ @if (descriptor.Metadata is { Count: > 0 } metadata) + { + + + @foreach (var item in metadata.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + { + + + + + } + + + } + else + { +
None
+ } +
+ } + else + { +

+ This callback is referenced by saved database metadata, but it is not registered in the current Admin host. +

+ + + + + + + + +
RegistrationMissing
Kind@GetKindLabel(selected.Kind)
Arity@FormatArity(selected.Arity)
References@selected.References.Count
+ +
+

References

+ @if (selected.References.Count == 0) + { +
None
+ } + else + { +
+ @foreach (HostCallbackReference reference in selected.References) + { +
+ @reference.OwnerKind: @reference.OwnerName + @reference.Surface + @reference.Location +
+ } +
+ } +
+ } + } + else + { +
Select a callback.
+ } +
+
+
+ +@code { + [Parameter] public TabDescriptor Tab { get; set; } = null!; + + private IReadOnlyList _entries = []; + private string _query = string.Empty; + private string _kindFilter = string.Empty; + private string _statusFilter = string.Empty; + private string? _selectedKey; + + private IReadOnlyList FilteredEntries + => _entries.Where(MatchesFilter).ToArray(); + + private HostCallbackCatalogEntry? SelectedEntry + => _entries.FirstOrDefault(entry => GetEntryKey(entry) == _selectedKey); + + protected override async Task OnInitializedAsync() + { + await RefreshAsync(); + } + + protected override void OnParametersSet() + { + ApplyTabSelection(); + } + + private async Task RefreshAsync() + { + _entries = await CallbackCatalog.GetEntriesAsync(); + ApplyTabSelection(); + + if (_selectedKey is null || !_entries.Any(entry => GetEntryKey(entry) == _selectedKey)) + _selectedKey = _entries.Count > 0 ? GetEntryKey(_entries[0]) : null; + } + + private void ApplyTabSelection() + { + if (Tab.State.TryGetValue("SelectedCallbackName", out object? nameValue) && nameValue is string name && !string.IsNullOrWhiteSpace(name)) + { + string? kind = Tab.State.TryGetValue("SelectedCallbackKind", out object? kindValue) ? kindValue as string : null; + int? arity = Tab.State.TryGetValue("SelectedCallbackArity", out object? arityValue) && arityValue is int value ? value : null; + + HostCallbackCatalogEntry? match = _entries.FirstOrDefault(entry => + entry.Name.Equals(name, StringComparison.OrdinalIgnoreCase) + && (string.IsNullOrWhiteSpace(kind) || entry.Kind.ToString().Equals(kind, StringComparison.OrdinalIgnoreCase)) + && (!arity.HasValue || entry.Arity == arity)); + + if (match is not null) + _selectedKey = GetEntryKey(match); + } + } + + private void SelectEntry(HostCallbackCatalogEntry entry) + { + _selectedKey = GetEntryKey(entry); + Tab.State["SelectedCallbackName"] = entry.Name; + Tab.State["SelectedCallbackKind"] = entry.Kind.ToString(); + Tab.State["SelectedCallbackArity"] = entry.Arity; + } + + private void OnQueryChanged(ChangeEventArgs args) + { + _query = args.Value?.ToString() ?? string.Empty; + } + + private void OnKindFilterChanged(ChangeEventArgs args) + { + _kindFilter = args.Value?.ToString() ?? string.Empty; + } + + private void OnStatusFilterChanged(ChangeEventArgs args) + { + _statusFilter = args.Value?.ToString() ?? string.Empty; + } + + private bool MatchesFilter(HostCallbackCatalogEntry entry) + { + if (!string.IsNullOrWhiteSpace(_kindFilter) + && !entry.Kind.ToString().Equals(_kindFilter, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(_statusFilter)) + { + bool matchesStatus = _statusFilter switch + { + "missing" => entry.IsMissingRegistration, + "registered" => entry.IsRegistered, + "referenced" => entry.IsReferenced, + _ => true, + }; + + if (!matchesStatus) + return false; + } + + if (string.IsNullOrWhiteSpace(_query)) + return true; + + string query = _query.Trim(); + return entry.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || entry.Kind.ToString().Contains(query, StringComparison.OrdinalIgnoreCase) + || FormatRuntime(entry).Contains(query, StringComparison.OrdinalIgnoreCase) + || GetEntryStatus(entry).Contains(query, StringComparison.OrdinalIgnoreCase) + || (entry.Descriptor?.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) + || entry.References.Any(reference => ReferenceMatchesSearch(reference, query)) + || (entry.Descriptor?.Capabilities.Any(capability => FormatCapability(capability).Contains(query, StringComparison.OrdinalIgnoreCase)) ?? false); + } + + private static bool ReferenceMatchesSearch(HostCallbackReference reference, string query) + => reference.Surface.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.Location.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.OwnerKind.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.OwnerName.Contains(query, StringComparison.OrdinalIgnoreCase) + || reference.OwnerId.Contains(query, StringComparison.OrdinalIgnoreCase); + + private static string GetEntryKey(HostCallbackCatalogEntry entry) + => $"{entry.Kind}:{entry.Name}:{entry.Arity?.ToString() ?? "-"}"; + + private static string GetKindLabel(AutomationCallbackKind kind) + => kind switch + { + AutomationCallbackKind.ScalarFunction => "Scalar function", + AutomationCallbackKind.Command => "Command", + _ => kind.ToString(), + }; + + private static string FormatRuntime(HostCallbackCatalogEntry entry) + => entry.Descriptor?.Runtime.ToString() ?? "Missing"; + + private static string FormatArity(int? arity) + => arity?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"; + + private static string FormatTimeout(TimeSpan? timeout) + { + if (timeout is null) + return "-"; + + return timeout.Value.TotalMilliseconds < 1000 + ? $"{timeout.Value.TotalMilliseconds:0.###}ms" + : $"{timeout.Value.TotalSeconds:0.###}s"; + } + + private static string FormatBytes(long? bytes) + { + if (bytes is null) + return "-"; + + const decimal kilo = 1024m; + decimal value = bytes.Value; + string[] units = ["B", "KB", "MB", "GB"]; + int unitIndex = 0; + while (value >= kilo && unitIndex < units.Length - 1) + { + value /= kilo; + unitIndex++; + } + + return $"{value:0.##} {units[unitIndex]}"; + } + + private static string FormatFlags(DbHostCallbackDescriptor callback) + { + List flags = []; + if (callback.IsDeterministic) + flags.Add("deterministic"); + if (callback.NullPropagating) + flags.Add("null-propagating"); + if (callback.IsLongRunning) + flags.Add("long-running"); + + return flags.Count == 0 ? "-" : string.Join(", ", flags); + } + + private static string FormatCapabilitySummary(HostCallbackCatalogEntry entry) + => entry.Descriptor is null || entry.Descriptor.Capabilities.Count == 0 + ? "-" + : string.Join(", ", entry.Descriptor.Capabilities.Select(static capability => capability.Name)); + + private static string FormatCapability(DbExtensionCapabilityRequest capability) + { + List parts = [capability.Name.ToString()]; + + if (capability.Exports is { Count: > 0 }) + parts.Add($"exports={string.Join("|", capability.Exports)}"); + if (capability.Tables is { Count: > 0 }) + parts.Add($"tables={string.Join("|", capability.Tables)}"); + if (capability.Scope is { Count: > 0 }) + parts.Add($"scope={string.Join("|", capability.Scope.OrderBy(static pair => pair.Key, StringComparer.OrdinalIgnoreCase).Select(static pair => $"{pair.Key}:{pair.Value}"))}"); + + return string.Join(" ", parts); + } + + private string GetEntryStatus(HostCallbackCatalogEntry entry) + { + if (entry.IsMissingRegistration) + return "Missing"; + + return entry.Descriptor is { } descriptor + ? GetPolicyStatus(CallbackPolicy.Evaluate(descriptor)) + : "Unknown"; + } + + private string GetEntryStatusBadgeClass(HostCallbackCatalogEntry entry) + { + if (entry.IsMissingRegistration) + return "callbacks-policy-badge missing"; + + return entry.Descriptor is { } descriptor + ? GetPolicyBadgeClass(CallbackPolicy.Evaluate(descriptor)) + : "callbacks-policy-badge denied"; + } + + private static string GetPolicyStatus(DbExtensionPolicyDecision decision) + => decision.Allowed ? "Allowed" : "Denied"; + + private static string GetPolicyBadgeClass(DbExtensionPolicyDecision decision) + => decision.Allowed + ? "callbacks-policy-badge allowed" + : "callbacks-policy-badge denied"; + + private static string GetCapabilityBadgeClass(DbExtensionCapabilityDecision decision) + => decision.Status == DbExtensionCapabilityGrantStatus.Granted + ? "callbacks-policy-badge allowed" + : "callbacks-policy-badge denied"; +} diff --git a/src/CSharpDB.Admin/Configuration/AdminHostDatabaseOptions.cs b/src/CSharpDB.Admin/Configuration/AdminHostDatabaseOptions.cs index f4c7f08a..48b70a49 100644 --- a/src/CSharpDB.Admin/Configuration/AdminHostDatabaseOptions.cs +++ b/src/CSharpDB.Admin/Configuration/AdminHostDatabaseOptions.cs @@ -1,5 +1,6 @@ using CSharpDB.Client; using CSharpDB.Engine; +using CSharpDB.Primitives; using Microsoft.Extensions.Configuration; namespace CSharpDB.Admin.Configuration; @@ -49,7 +50,8 @@ public static CSharpDbClientOptions Build( IConfiguration configuration, AdminHostDatabaseOptions hostDatabaseOptions, CSharpDbTransport? transport, - string? endpoint) + string? endpoint, + DbFunctionRegistry? functions = null) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(hostDatabaseOptions); @@ -58,7 +60,7 @@ public static CSharpDbClientOptions Build( { if (transport == CSharpDbTransport.Direct || (transport is null && EndpointLooksLikeDirectPath(endpoint))) { - return BuildDirectEndpoint(endpoint, hostDatabaseOptions, transport); + return BuildDirectEndpoint(endpoint, hostDatabaseOptions, transport, functions); } return new CSharpDbClientOptions @@ -79,12 +81,14 @@ public static CSharpDbClientOptions Build( return BuildDirectConnectionString( configuration.GetConnectionString("CSharpDB") ?? FallbackConnectionString, hostDatabaseOptions, - transport); + transport, + functions); } public static CSharpDbClientOptions BuildDirectDataSource( string dataSource, - AdminHostDatabaseOptions hostDatabaseOptions) + AdminHostDatabaseOptions hostDatabaseOptions, + DbFunctionRegistry? functions = null) { ArgumentException.ThrowIfNullOrWhiteSpace(dataSource); ArgumentNullException.ThrowIfNull(hostDatabaseOptions); @@ -93,17 +97,20 @@ public static CSharpDbClientOptions BuildDirectDataSource( { Transport = CSharpDbTransport.Direct, DataSource = dataSource, - DirectDatabaseOptions = BuildDirectDatabaseOptions(hostDatabaseOptions), + DirectDatabaseOptions = BuildDirectDatabaseOptions(hostDatabaseOptions, functions), HybridDatabaseOptions = BuildHybridDatabaseOptionsOrNull(hostDatabaseOptions), }; } - public static DatabaseOptions BuildDirectDatabaseOptions(AdminHostDatabaseOptions hostDatabaseOptions) + public static DatabaseOptions BuildDirectDatabaseOptions( + AdminHostDatabaseOptions hostDatabaseOptions, + DbFunctionRegistry? functions = null) { ArgumentNullException.ThrowIfNull(hostDatabaseOptions); var options = new DatabaseOptions { + Functions = functions ?? DbFunctionRegistry.Empty, ImplicitInsertExecutionMode = hostDatabaseOptions.ImplicitInsertExecutionMode, }; @@ -131,13 +138,14 @@ public static DatabaseOptions BuildDirectDatabaseOptions(AdminHostDatabaseOption private static CSharpDbClientOptions BuildDirectConnectionString( string connectionString, AdminHostDatabaseOptions hostDatabaseOptions, - CSharpDbTransport? transport) + CSharpDbTransport? transport, + DbFunctionRegistry? functions) { return new CSharpDbClientOptions { Transport = transport, ConnectionString = connectionString, - DirectDatabaseOptions = BuildDirectDatabaseOptions(hostDatabaseOptions), + DirectDatabaseOptions = BuildDirectDatabaseOptions(hostDatabaseOptions, functions), HybridDatabaseOptions = BuildHybridDatabaseOptionsOrNull(hostDatabaseOptions), }; } @@ -145,13 +153,14 @@ private static CSharpDbClientOptions BuildDirectConnectionString( private static CSharpDbClientOptions BuildDirectEndpoint( string endpoint, AdminHostDatabaseOptions hostDatabaseOptions, - CSharpDbTransport? transport) + CSharpDbTransport? transport, + DbFunctionRegistry? functions) { return new CSharpDbClientOptions { Transport = transport, Endpoint = endpoint, - DirectDatabaseOptions = BuildDirectDatabaseOptions(hostDatabaseOptions), + DirectDatabaseOptions = BuildDirectDatabaseOptions(hostDatabaseOptions, functions), HybridDatabaseOptions = BuildHybridDatabaseOptionsOrNull(hostDatabaseOptions), }; } diff --git a/src/CSharpDB.Admin/Models/TabDescriptor.cs b/src/CSharpDB.Admin/Models/TabDescriptor.cs index 0c00063a..c2848441 100644 --- a/src/CSharpDB.Admin/Models/TabDescriptor.cs +++ b/src/CSharpDB.Admin/Models/TabDescriptor.cs @@ -7,6 +7,7 @@ public enum TabKind TableData, ViewData, CollectionData, + HostCallbacks, Procedure, Pipeline, Storage, @@ -35,7 +36,7 @@ public TabDescriptor(string id, string title, string icon, TabKind kind, bool cl Closable = closable; } - /// Get the object name for data/view/collection tabs (e.g. table name, view name, collection name). + /// Get the object name for table, view, collection, and other object-backed tabs. public string? ObjectName { get => State.TryGetValue("ObjectName", out var v) ? v as string : null; diff --git a/src/CSharpDB.Admin/Program.cs b/src/CSharpDB.Admin/Program.cs index 0adde517..a220db35 100644 --- a/src/CSharpDB.Admin/Program.cs +++ b/src/CSharpDB.Admin/Program.cs @@ -4,6 +4,7 @@ using CSharpDB.Admin.Reports.Services; using CSharpDB.Admin.Services; using CSharpDB.Client; +using CSharpDB.Primitives; var builder = WebApplication.CreateBuilder(args); @@ -12,10 +13,14 @@ builder.Services.AddSingleton(sp => AdminClientOptionsBuilder.BindHostDatabaseOptions(sp.GetRequiredService())); +builder.Services.AddSingleton(AdminHostCallbacks.CreateFunctionRegistry()); +builder.Services.AddSingleton(AdminHostCallbacks.CreateCommandRegistry()); +builder.Services.AddSingleton(AdminHostCallbacks.CreatePolicy()); builder.Services.AddSingleton(sp => { var configuration = sp.GetRequiredService(); var hostDatabaseOptions = sp.GetRequiredService(); + var functions = sp.GetRequiredService(); string? endpoint = configuration["CSharpDB:Endpoint"]; CSharpDbTransport? transport = ParseTransport(configuration["CSharpDB:Transport"]); @@ -23,9 +28,10 @@ configuration, hostDatabaseOptions, transport, - endpoint); + endpoint, + functions); - return new DatabaseClientHolder(CSharpDbClient.Create(options), hostDatabaseOptions); + return new DatabaseClientHolder(CSharpDbClient.Create(options), hostDatabaseOptions, functions); }); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddScoped(); @@ -33,6 +39,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddCSharpDbAdminForms(); builder.Services.AddCSharpDbAdminReports(); diff --git a/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs b/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs new file mode 100644 index 00000000..1f2119cb --- /dev/null +++ b/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs @@ -0,0 +1,99 @@ +using System.Text; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Services; + +public static class AdminHostCallbacks +{ + private const string PolicySource = "CSharpDB.Admin host callback policy"; + + public static DbFunctionRegistry CreateFunctionRegistry() + => DbFunctionRegistry.Create(functions => + { + functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true, + Description: "Formats text as a lowercase URL slug.", + Metadata: CreateMetadata("CSharpDB.Admin")), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); + }); + + public static DbCommandRegistry CreateCommandRegistry() + => DbCommandRegistry.Create(commands => + { + commands.AddCommand( + "EchoAutomationEvent", + new DbCommandOptions( + Description: "Returns a small host-owned acknowledgement for automation command wiring.", + Metadata: CreateMetadata("CSharpDB.Admin")), + static context => + { + string eventName = context.Metadata.TryGetValue("event", out string? value) + ? value + : "manual"; + string surface = context.Metadata.TryGetValue("surface", out string? surfaceValue) + ? surfaceValue + : "unknown"; + string message = $"Received {surface}.{eventName}."; + + return DbCommandResult.Success(message, DbValue.FromText(message)); + }); + }); + + public static DbExtensionPolicy CreatePolicy() + => new( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ScalarFunctions, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Admin host registered scalar functions.", + PolicySource: PolicySource), + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Admin host registered trusted commands.", + PolicySource: PolicySource), + ], + DefaultTimeout: TimeSpan.FromSeconds(5), + RequireSignature: true, + AllowedHostModes: DbExtensionHostMode.Embedded); + + private static Dictionary CreateMetadata(string value) + => new(StringComparer.OrdinalIgnoreCase) + { + ["host"] = value, + }; + + private static string Slugify(string text) + { + var builder = new StringBuilder(text.Length); + bool wroteSeparator = false; + + foreach (char ch in text.Trim().ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(ch); + wroteSeparator = false; + continue; + } + + if (builder.Length == 0 || wroteSeparator) + continue; + + builder.Append('-'); + wroteSeparator = true; + } + + if (builder.Length > 0 && builder[^1] == '-') + builder.Length--; + + return builder.ToString(); + } +} diff --git a/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs b/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs index 9f9e9423..17676d45 100644 --- a/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs +++ b/src/CSharpDB.Admin/Services/DatabaseClientHolder.cs @@ -3,6 +3,7 @@ using CSharpDB.Client; using CSharpDB.Client.Models; using CSharpDB.Storage.Diagnostics; +using DbFunctionRegistry = CSharpDB.Primitives.DbFunctionRegistry; namespace CSharpDB.Admin.Services; @@ -15,20 +16,25 @@ public sealed class DatabaseClientHolder : ICSharpDbClient { private ICSharpDbClient _inner; private readonly AdminHostDatabaseOptions _hostDatabaseOptions; + private readonly DbFunctionRegistry _functions; private readonly object _lock = new(); public event Action? DatabaseChanged; - public DatabaseClientHolder(ICSharpDbClient initial, AdminHostDatabaseOptions hostDatabaseOptions) + public DatabaseClientHolder( + ICSharpDbClient initial, + AdminHostDatabaseOptions hostDatabaseOptions, + DbFunctionRegistry functions) { _inner = initial; _hostDatabaseOptions = hostDatabaseOptions; + _functions = functions; } public async Task SwitchAsync(string databasePath) { var newClient = CSharpDbClient.Create( - AdminClientOptionsBuilder.BuildDirectDataSource(databasePath, _hostDatabaseOptions)); + AdminClientOptionsBuilder.BuildDirectDataSource(databasePath, _hostDatabaseOptions, _functions)); // Verify the new database is accessible before swapping. await newClient.GetInfoAsync(); diff --git a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs new file mode 100644 index 00000000..ee512917 --- /dev/null +++ b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs @@ -0,0 +1,190 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Primitives; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Services; + +public sealed record HostCallbackReference( + AutomationCallbackKind Kind, + string Name, + int? Arity, + string Surface, + string Location, + string OwnerKind, + string OwnerId, + string OwnerName); + +public sealed record HostCallbackCatalogEntry( + AutomationCallbackKind Kind, + string Name, + int? Arity, + DbHostCallbackDescriptor? Descriptor, + IReadOnlyList References) +{ + public bool IsRegistered => Descriptor is not null; + public bool IsReferenced => References.Count > 0; + public bool IsMissingRegistration => IsReferenced && !IsRegistered; +} + +public sealed class HostCallbackCatalogService +{ + private readonly IServiceProvider _services; + + public HostCallbackCatalogService(IServiceProvider services) + { + _services = services; + } + + public IReadOnlyList GetCallbacks() + { + DbFunctionRegistry functions = _services.GetService() ?? DbFunctionRegistry.Empty; + DbCommandRegistry commands = _services.GetService() ?? DbCommandRegistry.Empty; + + return functions.Callbacks + .Concat(commands.Callbacks) + .OrderBy(static callback => callback.Kind) + .ThenBy(static callback => callback.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static callback => callback.Arity ?? -1) + .ToArray(); + } + + public async Task> GetEntriesAsync() + { + IReadOnlyList registered = GetCallbacks(); + IReadOnlyList references = await GetReferencesAsync(); + + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DbHostCallbackDescriptor descriptor in registered) + { + string key = GetEntryKey(descriptor.Kind, descriptor.Name, descriptor.Arity); + entries[key] = new HostCallbackCatalogEntry( + descriptor.Kind, + descriptor.Name, + descriptor.Arity, + descriptor, + []); + } + + foreach (HostCallbackReference reference in references) + { + string key = GetEntryKey(reference.Kind, reference.Name, reference.Arity); + if (entries.TryGetValue(key, out HostCallbackCatalogEntry? existing)) + { + entries[key] = existing with + { + References = existing.References.Concat([reference]).ToArray() + }; + continue; + } + + entries[key] = new HostCallbackCatalogEntry( + reference.Kind, + reference.Name, + reference.Arity, + Descriptor: null, + References: [reference]); + } + + return entries.Values + .Select(static entry => entry with + { + References = entry.References + .OrderBy(static reference => reference.OwnerKind, StringComparer.OrdinalIgnoreCase) + .ThenBy(static reference => reference.OwnerName, StringComparer.OrdinalIgnoreCase) + .ThenBy(static reference => reference.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static reference => reference.Location, StringComparer.OrdinalIgnoreCase) + .ToArray() + }) + .OrderByDescending(static entry => entry.IsMissingRegistration) + .ThenBy(static entry => entry.Kind) + .ThenBy(static entry => entry.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static entry => entry.Arity ?? -1) + .ToArray(); + } + + private async Task> GetReferencesAsync() + { + var references = new List(); + + if (_services.GetService() is { } formRepository) + { + try + { + IReadOnlyList forms = await formRepository.ListAsync(); + foreach (FormDefinition form in forms) + AddReferences(references, form.Automation, "Form", form.FormId, form.Name); + } + catch + { + // Keep the host callback catalog usable even if saved form metadata is unavailable. + } + } + + if (_services.GetService() is { } reportRepository) + { + try + { + IReadOnlyList reports = await reportRepository.ListAsync(); + foreach (ReportDefinition report in reports) + AddReferences(references, report.Automation, "Report", report.ReportId, report.Name); + } + catch + { + // Keep the host callback catalog usable even if saved report metadata is unavailable. + } + } + + return references + .GroupBy( + static reference => GetReferenceKey(reference), + StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .ToArray(); + } + + private static void AddReferences( + List references, + DbAutomationMetadata? metadata, + string ownerKind, + string ownerId, + string ownerName) + { + if (metadata is null) + return; + + foreach (DbAutomationCommandReference command in metadata.Commands ?? []) + { + references.Add(new HostCallbackReference( + AutomationCallbackKind.Command, + command.Name, + Arity: null, + command.Surface, + command.Location, + ownerKind, + ownerId, + ownerName)); + } + + foreach (DbAutomationScalarFunctionReference function in metadata.ScalarFunctions ?? []) + { + references.Add(new HostCallbackReference( + AutomationCallbackKind.ScalarFunction, + function.Name, + function.Arity, + function.Surface, + function.Location, + ownerKind, + ownerId, + ownerName)); + } + } + + private static string GetEntryKey(AutomationCallbackKind kind, string name, int? arity) + => $"{kind}\u001f{name.Trim()}\u001f{arity?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"}"; + + private static string GetReferenceKey(HostCallbackReference reference) + => $"{GetEntryKey(reference.Kind, reference.Name, reference.Arity)}\u001f{reference.Surface}\u001f{reference.Location}\u001f{reference.OwnerKind}\u001f{reference.OwnerId}"; +} diff --git a/src/CSharpDB.Admin/Services/HostCallbackPolicyService.cs b/src/CSharpDB.Admin/Services/HostCallbackPolicyService.cs new file mode 100644 index 00000000..e8863b3a --- /dev/null +++ b/src/CSharpDB.Admin/Services/HostCallbackPolicyService.cs @@ -0,0 +1,25 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Services; + +public sealed class HostCallbackPolicyService +{ + private readonly DbExtensionPolicy _policy; + + public HostCallbackPolicyService(DbExtensionPolicy policy) + { + _policy = policy; + } + + public DbExtensionPolicy Policy => _policy; + + public DbExtensionPolicyDecision Evaluate(DbHostCallbackDescriptor callback) + { + ArgumentNullException.ThrowIfNull(callback); + + return DbExtensionPolicyEvaluator.Evaluate( + callback, + _policy, + DbExtensionHostMode.Embedded); + } +} diff --git a/src/CSharpDB.Admin/Services/TabManagerService.cs b/src/CSharpDB.Admin/Services/TabManagerService.cs index 5e2e3cee..179fac0f 100644 --- a/src/CSharpDB.Admin/Services/TabManagerService.cs +++ b/src/CSharpDB.Admin/Services/TabManagerService.cs @@ -100,6 +100,23 @@ public TabDescriptor OpenCollectionTab(string collectionName) return _tabs.First(t => t.Id == tab.Id); } + public TabDescriptor OpenCallbacksTab(string? selectedCallbackName = null, string? selectedCallbackKind = null, int? selectedCallbackArity = null) + { + const string tabId = "callbacks:host"; + TabDescriptor? existing = _tabs.FirstOrDefault(t => t.Id == tabId); + if (existing is not null) + { + ApplyCallbackSelection(existing, selectedCallbackName, selectedCallbackKind, selectedCallbackArity); + ActivateTab(existing.Id); + return existing; + } + + var tab = new TabDescriptor(tabId, "Callbacks", "bi-plug", TabKind.HostCallbacks); + ApplyCallbackSelection(tab, selectedCallbackName, selectedCallbackKind, selectedCallbackArity); + OpenTab(tab); + return _tabs.First(t => t.Id == tab.Id); + } + public TabDescriptor OpenQueryTab(string? initialSql = null) { int num = Interlocked.Increment(ref _queryCounter); @@ -230,6 +247,20 @@ private static void ApplyFormEntryInitialState( tab.InitialFilterParameters = initialFilterParameters; } + private static void ApplyCallbackSelection( + TabDescriptor tab, + string? selectedCallbackName, + string? selectedCallbackKind, + int? selectedCallbackArity) + { + if (string.IsNullOrWhiteSpace(selectedCallbackName)) + return; + + tab.State["SelectedCallbackName"] = selectedCallbackName.Trim(); + tab.State["SelectedCallbackKind"] = string.IsNullOrWhiteSpace(selectedCallbackKind) ? null : selectedCallbackKind.Trim(); + tab.State["SelectedCallbackArity"] = selectedCallbackArity; + } + /// Open a table tab and switch it to Schema view. public TabDescriptor OpenTableSchemaTab(string tableName) { diff --git a/src/CSharpDB.Admin/wwwroot/css/app.css b/src/CSharpDB.Admin/wwwroot/css/app.css index c3666e2e..308958df 100644 --- a/src/CSharpDB.Admin/wwwroot/css/app.css +++ b/src/CSharpDB.Admin/wwwroot/css/app.css @@ -3505,22 +3505,27 @@ body { } .sidebar-filter-chips { - display: flex; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--sidebar-border); } .sidebar-filter-chips button { - flex: 1; min-width: 0; + width: 100%; height: 24px; + padding: 0 8px; border: 1px solid var(--sidebar-btn-border); border-radius: 999px; background: transparent; color: var(--sidebar-text-muted); font-size: 11px; font-family: var(--font-ui); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; cursor: pointer; } @@ -4975,6 +4980,309 @@ body { font-size: 18px; } +/* Host callback catalog */ +.callbacks-tab { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + +.callbacks-filter-bar { + min-width: 280px; + flex: 1; +} + +.callbacks-filter, +.callbacks-search { + height: 28px; + min-width: 0; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 12px; +} + +.callbacks-filter { + width: 150px; + padding: 0 8px; +} + +.callbacks-filter.status { + width: 128px; +} + +.callbacks-search { + width: min(360px, 100%); + padding: 0 10px; +} + +.callbacks-layout { + display: grid; + grid-template-columns: minmax(420px, 1fr) minmax(340px, 420px); + flex: 1; + min-height: 0; + border-top: 1px solid var(--border-color); +} + +.callbacks-list-panel, +.callbacks-detail-panel { + min-width: 0; + min-height: 0; + overflow: auto; + background: var(--bg-secondary); +} + +.callbacks-detail-panel { + border-left: 1px solid var(--border-color); + padding: 14px; +} + +.callbacks-empty { + padding: 28px 16px; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} + +.callbacks-empty.small { + padding: 8px 0; + text-align: left; +} + +.callbacks-table { + border: none; + border-radius: 0; + min-width: 760px; +} + +.callbacks-table th, +.callbacks-table td { + overflow-wrap: normal; + word-break: normal; +} + +.callbacks-table th { + white-space: nowrap; +} + +.callbacks-table .mono-cell { + overflow-wrap: anywhere; +} + +.callbacks-row { + cursor: pointer; +} + +.callbacks-row:hover td, +.callbacks-row.selected td { + background: var(--bg-hover); +} + +.callbacks-row.missing td:first-child { + color: var(--accent-yellow); +} + +.callbacks-row.selected td:first-child { + box-shadow: inset 3px 0 0 var(--accent-blue); +} + +.callbacks-missing-icon { + margin-right: 6px; + color: var(--accent-yellow); +} + +.callbacks-capability-cell { + color: var(--text-secondary); + font-size: 11px; +} + +.callbacks-policy-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 20px; + padding: 2px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} + +.callbacks-policy-badge.allowed { + border-color: rgba(158, 206, 106, 0.4); + background: rgba(158, 206, 106, 0.12); + color: var(--accent-green); +} + +.callbacks-policy-badge.denied { + border-color: rgba(247, 118, 142, 0.4); + background: rgba(247, 118, 142, 0.12); + color: var(--accent-red); +} + +.callbacks-policy-badge.missing { + border-color: rgba(224, 175, 104, 0.45); + background: rgba(224, 175, 104, 0.14); + color: var(--accent-yellow); +} + +.callbacks-detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.callbacks-detail-head h3 { + margin: 2px 0 0; + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.callbacks-eyebrow, +.callbacks-runtime { + color: var(--text-muted); + font-size: 11px; + text-transform: uppercase; +} + +.callbacks-runtime { + padding: 3px 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-tertiary); + white-space: nowrap; +} + +.callbacks-description { + margin: 12px 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} + +.callbacks-detail-table { + margin-top: 12px; +} + +.callbacks-section { + margin-top: 16px; +} + +.callbacks-section h4 { + margin: 0 0 8px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.callbacks-capabilities { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.callbacks-chip { + max-width: 100%; + padding: 4px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 11px; + overflow-wrap: anywhere; +} + +.callbacks-policy-list { + display: grid; + gap: 8px; +} + +.callbacks-policy-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 10px; + padding: 9px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); +} + +.callbacks-policy-main { + min-width: 0; +} + +.callbacks-policy-main strong { + display: block; + color: var(--text-primary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.callbacks-policy-main span { + display: block; + margin-top: 2px; + color: var(--text-muted); + font-size: 11px; + overflow-wrap: anywhere; +} + +.callbacks-policy-main p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.callbacks-reference-list { + display: grid; + gap: 8px; +} + +.callbacks-reference-item { + display: grid; + gap: 4px; + padding: 9px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-tertiary); +} + +.callbacks-reference-item strong { + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.callbacks-reference-item span { + color: var(--text-muted); + font-size: 11px; +} + +.callbacks-reference-item code { + color: var(--text-secondary); + font-size: 11px; + white-space: normal; + overflow-wrap: anywhere; +} + +.callbacks-metadata-table th, +.callbacks-metadata-table td { + overflow-wrap: anywhere; +} + @media (max-width: 1200px) { .titlebar-toolbar .titlebar-action-btn span, .titlebar-action-btn { @@ -5042,4 +5350,13 @@ body { .heavy-rail { display: none; } + + .callbacks-layout { + grid-template-columns: minmax(0, 1fr); + } + + .callbacks-detail-panel { + border-left: none; + border-top: 1px solid var(--border-color); + } } diff --git a/src/CSharpDB.Primitives/DbCommands.cs b/src/CSharpDB.Primitives/DbCommands.cs index a2582641..1265a44c 100644 --- a/src/CSharpDB.Primitives/DbCommands.cs +++ b/src/CSharpDB.Primitives/DbCommands.cs @@ -12,7 +12,9 @@ public sealed record DbCommandContext( public sealed record DbCommandOptions( string? Description = null, TimeSpan? Timeout = null, - bool IsLongRunning = false); + bool IsLongRunning = false, + IReadOnlyList? AdditionalCapabilities = null, + IReadOnlyDictionary? Metadata = null); public sealed record DbCommandResult( bool Succeeded, @@ -41,6 +43,7 @@ internal DbCommandDefinition( { Name = name; Options = options; + Descriptor = DbHostCallbackDescriptorFactory.CreateCommand(name, options); _invoke = invoke; } @@ -48,6 +51,8 @@ internal DbCommandDefinition( public DbCommandOptions Options { get; } + public DbHostCallbackDescriptor Descriptor { get; } + public ValueTask InvokeAsync( IReadOnlyDictionary? arguments = null, IReadOnlyDictionary? metadata = null, @@ -186,6 +191,7 @@ public sealed class DbCommandRegistry { private readonly Dictionary _commands; private readonly DbCommandDefinition[] _commandList; + private readonly DbHostCallbackDescriptor[] _callbackList; public static DbCommandRegistry Empty { get; } = new(); @@ -193,6 +199,7 @@ private DbCommandRegistry() { _commands = new Dictionary(StringComparer.OrdinalIgnoreCase); _commandList = []; + _callbackList = []; } internal DbCommandRegistry(Dictionary commands) @@ -201,10 +208,15 @@ internal DbCommandRegistry(Dictionary commands) _commandList = commands.Values .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); + _callbackList = _commandList + .Select(static definition => definition.Descriptor) + .ToArray(); } public IReadOnlyCollection Commands => _commandList; + public IReadOnlyCollection Callbacks => _callbackList; + public static DbCommandRegistry Create(Action configure) { ArgumentNullException.ThrowIfNull(configure); diff --git a/src/CSharpDB.Primitives/DbExtensions.cs b/src/CSharpDB.Primitives/DbExtensions.cs new file mode 100644 index 00000000..1ab1d810 --- /dev/null +++ b/src/CSharpDB.Primitives/DbExtensions.cs @@ -0,0 +1,249 @@ +namespace CSharpDB.Primitives; + +public enum DbExtensionRuntimeKind +{ + HostCallback = 0, + OutOfProcess = 1, +} + +public enum DbExtensionExportKind +{ + ScalarFunction = 0, + Command = 1, + PipelineTransform = 2, + ValidationRule = 3, +} + +public enum DbExtensionCapability +{ + ScalarFunctions = 0, + Commands = 1, + PipelineTransforms = 2, + ValidationRules = 3, + ReadDatabase = 4, + WriteDatabase = 5, + RunSql = 6, + Network = 7, + FileRead = 8, + FileWrite = 9, + Clock = 10, + Random = 11, + EnvironmentVariables = 12, +} + +public enum DbExtensionCapabilityGrantStatus +{ + Denied = 0, + Granted = 1, +} + +[Flags] +public enum DbExtensionHostMode +{ + None = 0, + Embedded = 1, + Daemon = 2, + All = Embedded | Daemon, +} + +public sealed record DbExtensionManifest( + string Id, + string Name, + string Version, + DbExtensionRuntimeKind Runtime, + string Entrypoint, + IReadOnlyList Exports, + IReadOnlyList Capabilities, + string? RequiredCSharpDbVersion = null, + string? ArtifactSha256 = null, + string? Signature = null, + string? Publisher = null, + IReadOnlyDictionary? Metadata = null); + +public sealed record DbExtensionExport( + DbExtensionExportKind Kind, + string Name, + int? Arity = null, + DbType? ReturnType = null, + IReadOnlyDictionary? Metadata = null); + +public sealed record DbExtensionCapabilityRequest( + DbExtensionCapability Name, + string? Reason = null, + IReadOnlyList? Exports = null, + IReadOnlyList? Tables = null, + IReadOnlyDictionary? Scope = null); + +public sealed record DbExtensionCapabilityGrant( + DbExtensionCapability Name, + DbExtensionCapabilityGrantStatus Status, + string? Reason = null, + IReadOnlyList? Exports = null, + IReadOnlyList? Tables = null, + IReadOnlyDictionary? Scope = null, + string? PolicySource = null, + DateTimeOffset? GrantedAt = null); + +public sealed record DbExtensionPolicy( + bool AllowExtensions, + IReadOnlyList? Grants = null, + TimeSpan? DefaultTimeout = null, + long? MaxMemoryBytes = null, + bool RequireSignature = true, + DbExtensionHostMode AllowedHostModes = DbExtensionHostMode.All); + +public sealed record DbExtensionPolicyDecision( + bool Allowed, + string? DenialReason, + IReadOnlyList Capabilities, + TimeSpan Timeout, + long? MaxMemoryBytes); + +public sealed record DbExtensionCapabilityDecision( + DbExtensionCapability Name, + DbExtensionCapabilityGrantStatus Status, + string? Reason, + string? PolicySource); + +public sealed record DbExtensionInvocationRequest( + string ExtensionId, + DbExtensionExportKind Kind, + string Name, + IReadOnlyDictionary Arguments, + IReadOnlyDictionary? Metadata = null, + string? CorrelationId = null, + DateTimeOffset? Deadline = null, + string? UserIdentity = null); + +public sealed record DbExtensionInvocationResult( + bool Succeeded, + string? Message = null, + DbValue Value = default, + IReadOnlyList? Diagnostics = null, + IReadOnlyList? Logs = null, + string? ErrorCode = null); + +public static class DbExtensionPolicyEvaluator +{ + private static readonly TimeSpan DefaultExecutionTimeout = TimeSpan.FromSeconds(5); + + public static DbExtensionPolicyDecision Evaluate( + DbExtensionManifest manifest, + DbExtensionPolicy policy, + DbExtensionHostMode hostMode) + { + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(policy); + + IReadOnlyList capabilityDecisions = + EvaluateCapabilities(manifest.Capabilities, policy.Grants); + + string? denialReason = null; + if (!policy.AllowExtensions) + { + denialReason = "Extension execution is disabled by host policy."; + } + else if ((policy.AllowedHostModes & hostMode) == 0) + { + denialReason = $"Extension execution is not allowed in {hostMode} mode."; + } + else if (RequiresArtifactSignature(manifest) && + policy.RequireSignature && + string.IsNullOrWhiteSpace(manifest.Signature)) + { + denialReason = "Extension policy requires a signature."; + } + else + { + DbExtensionCapabilityDecision? deniedCapability = capabilityDecisions + .FirstOrDefault(static decision => decision.Status != DbExtensionCapabilityGrantStatus.Granted); + if (deniedCapability is not null) + denialReason = $"Capability '{deniedCapability.Name}' is not granted."; + } + + return new DbExtensionPolicyDecision( + Allowed: denialReason is null, + denialReason, + capabilityDecisions, + policy.DefaultTimeout ?? DefaultExecutionTimeout, + policy.MaxMemoryBytes); + } + + public static DbExtensionPolicyDecision Evaluate( + DbHostCallbackDescriptor callback, + DbExtensionPolicy policy, + DbExtensionHostMode hostMode) + { + ArgumentNullException.ThrowIfNull(callback); + ArgumentNullException.ThrowIfNull(policy); + + IReadOnlyList capabilityDecisions = + EvaluateCapabilities(callback.Capabilities, policy.Grants); + + string? denialReason = null; + if (!policy.AllowExtensions) + { + denialReason = "Extension execution is disabled by host policy."; + } + else if ((policy.AllowedHostModes & hostMode) == 0) + { + denialReason = $"Extension execution is not allowed in {hostMode} mode."; + } + else + { + DbExtensionCapabilityDecision? deniedCapability = capabilityDecisions + .FirstOrDefault(static decision => decision.Status != DbExtensionCapabilityGrantStatus.Granted); + if (deniedCapability is not null) + denialReason = $"Capability '{deniedCapability.Name}' is not granted."; + } + + TimeSpan timeout = callback.Timeout ?? policy.DefaultTimeout ?? DefaultExecutionTimeout; + return new DbExtensionPolicyDecision( + Allowed: denialReason is null, + denialReason, + capabilityDecisions, + timeout, + policy.MaxMemoryBytes); + } + + private static bool RequiresArtifactSignature(DbExtensionManifest manifest) + => manifest.Runtime == DbExtensionRuntimeKind.OutOfProcess; + + private static IReadOnlyList EvaluateCapabilities( + IReadOnlyList? requests, + IReadOnlyList? grants) + { + if (requests is null || requests.Count == 0) + return []; + + var grantByCapability = (grants ?? []) + .GroupBy(static grant => grant.Name) + .ToDictionary( + static group => group.Key, + static group => group.Last(), + EqualityComparer.Default); + + var decisions = new DbExtensionCapabilityDecision[requests.Count]; + for (int i = 0; i < requests.Count; i++) + { + DbExtensionCapabilityRequest request = requests[i]; + if (!grantByCapability.TryGetValue(request.Name, out DbExtensionCapabilityGrant? grant)) + { + decisions[i] = new DbExtensionCapabilityDecision( + request.Name, + DbExtensionCapabilityGrantStatus.Denied, + "No matching capability grant.", + PolicySource: null); + continue; + } + + decisions[i] = new DbExtensionCapabilityDecision( + request.Name, + grant.Status, + grant.Reason, + grant.PolicySource); + } + + return decisions; + } +} diff --git a/src/CSharpDB.Primitives/DbFunctions.cs b/src/CSharpDB.Primitives/DbFunctions.cs index 4c681c2c..a5b5966f 100644 --- a/src/CSharpDB.Primitives/DbFunctions.cs +++ b/src/CSharpDB.Primitives/DbFunctions.cs @@ -26,7 +26,10 @@ private static class EmptyStringDictionary public sealed record DbScalarFunctionOptions( DbType? ReturnType = null, bool IsDeterministic = false, - bool NullPropagating = false); + bool NullPropagating = false, + string? Description = null, + IReadOnlyList? AdditionalCapabilities = null, + IReadOnlyDictionary? Metadata = null); public sealed class DbScalarFunctionDefinition { @@ -41,6 +44,7 @@ internal DbScalarFunctionDefinition( Name = name; Arity = arity; Options = options; + Descriptor = DbHostCallbackDescriptorFactory.CreateScalar(name, arity, options); _invoke = invoke; } @@ -50,6 +54,8 @@ internal DbScalarFunctionDefinition( public DbScalarFunctionOptions Options { get; } + public DbHostCallbackDescriptor Descriptor { get; } + public DbValue Invoke(ReadOnlySpan arguments) => Invoke(arguments, metadata: null); @@ -106,6 +112,7 @@ public sealed class DbFunctionRegistry { private readonly Dictionary> _scalarFunctions; private readonly DbScalarFunctionDefinition[] _scalarFunctionList; + private readonly DbHostCallbackDescriptor[] _callbackList; public static DbFunctionRegistry Empty { get; } = new(); @@ -113,6 +120,7 @@ private DbFunctionRegistry() { _scalarFunctions = new Dictionary>(StringComparer.OrdinalIgnoreCase); _scalarFunctionList = []; + _callbackList = []; } internal DbFunctionRegistry(Dictionary> scalarFunctions) @@ -123,10 +131,15 @@ internal DbFunctionRegistry(Dictionary definition.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(static definition => definition.Arity) .ToArray(); + _callbackList = _scalarFunctionList + .Select(static definition => definition.Descriptor) + .ToArray(); } public IReadOnlyCollection ScalarFunctions => _scalarFunctionList; + public IReadOnlyCollection Callbacks => _callbackList; + public static DbFunctionRegistry Create(Action configure) { ArgumentNullException.ThrowIfNull(configure); diff --git a/src/CSharpDB.Primitives/DbHostCallbacks.cs b/src/CSharpDB.Primitives/DbHostCallbacks.cs new file mode 100644 index 00000000..9ea916be --- /dev/null +++ b/src/CSharpDB.Primitives/DbHostCallbacks.cs @@ -0,0 +1,63 @@ +namespace CSharpDB.Primitives; + +public sealed record DbHostCallbackDescriptor( + AutomationCallbackKind Kind, + string Name, + DbExtensionRuntimeKind Runtime, + IReadOnlyList Capabilities, + int? Arity = null, + DbType? ReturnType = null, + string? Description = null, + bool IsDeterministic = false, + bool NullPropagating = false, + TimeSpan? Timeout = null, + bool IsLongRunning = false, + IReadOnlyDictionary? Metadata = null); + +internal static class DbHostCallbackDescriptorFactory +{ + public static DbHostCallbackDescriptor CreateScalar( + string name, + int arity, + DbScalarFunctionOptions options) + => new( + AutomationCallbackKind.ScalarFunction, + name, + DbExtensionRuntimeKind.HostCallback, + CreateCapabilities(DbExtensionCapability.ScalarFunctions, name, options.AdditionalCapabilities), + Arity: arity, + ReturnType: options.ReturnType, + Description: options.Description, + IsDeterministic: options.IsDeterministic, + NullPropagating: options.NullPropagating, + Metadata: options.Metadata); + + public static DbHostCallbackDescriptor CreateCommand( + string name, + DbCommandOptions options) + => new( + AutomationCallbackKind.Command, + name, + DbExtensionRuntimeKind.HostCallback, + CreateCapabilities(DbExtensionCapability.Commands, name, options.AdditionalCapabilities), + Description: options.Description, + Timeout: options.Timeout, + IsLongRunning: options.IsLongRunning, + Metadata: options.Metadata); + + private static IReadOnlyList CreateCapabilities( + DbExtensionCapability baseCapability, + string exportName, + IReadOnlyList? additionalCapabilities) + { + var capabilities = new List + { + new(baseCapability, Exports: [exportName]), + }; + + if (additionalCapabilities is { Count: > 0 }) + capabilities.AddRange(additionalCapabilities); + + return capabilities; + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminClientOptionsBuilderTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminClientOptionsBuilderTests.cs index 52e90d92..b1598985 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminClientOptionsBuilderTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminClientOptionsBuilderTests.cs @@ -1,6 +1,7 @@ using CSharpDB.Admin.Configuration; using CSharpDB.Client; using CSharpDB.Engine; +using CSharpDB.Primitives; using Microsoft.Extensions.Configuration; namespace CSharpDB.Admin.Forms.Tests.Admin; @@ -97,6 +98,26 @@ public void Build_DirectEndpointUsesHybridOptions() Assert.NotNull(options.HybridDatabaseOptions); } + [Fact] + public void Build_DirectConnectionStringAttachesFunctionRegistry() + { + IConfiguration configuration = CreateConfiguration(new Dictionary + { + ["ConnectionStrings:CSharpDB"] = "Data Source=admin.db", + }); + AdminHostDatabaseOptions hostOptions = AdminClientOptionsBuilder.BindHostDatabaseOptions(configuration); + DbFunctionRegistry functions = CreateFunctionRegistry(); + + CSharpDbClientOptions options = AdminClientOptionsBuilder.Build( + configuration, + hostOptions, + CSharpDbTransport.Direct, + endpoint: null, + functions); + + Assert.Same(functions, options.DirectDatabaseOptions!.Functions); + } + [Fact] public void BuildDirectDataSource_UsesHybridOptionsForDatabaseSwitches() { @@ -113,8 +134,46 @@ public void BuildDirectDataSource_UsesHybridOptionsForDatabaseSwitches() Assert.Equal(HybridPersistenceMode.IncrementalDurable, options.HybridDatabaseOptions.PersistenceMode); } + [Fact] + public void BuildDirectDataSource_AttachesFunctionRegistry() + { + AdminHostDatabaseOptions hostOptions = new(); + DbFunctionRegistry functions = CreateFunctionRegistry(); + + CSharpDbClientOptions options = AdminClientOptionsBuilder.BuildDirectDataSource( + @"C:\data\switched.db", + hostOptions, + functions); + + Assert.Same(functions, options.DirectDatabaseOptions!.Functions); + } + + [Fact] + public void Build_RemoteEndpointIgnoresFunctionRegistry() + { + IConfiguration configuration = CreateConfiguration(new Dictionary()); + AdminHostDatabaseOptions hostOptions = AdminClientOptionsBuilder.BindHostDatabaseOptions(configuration); + + CSharpDbClientOptions options = AdminClientOptionsBuilder.Build( + configuration, + hostOptions, + CSharpDbTransport.Grpc, + "http://127.0.0.1:5820", + CreateFunctionRegistry()); + + Assert.Null(options.DirectDatabaseOptions); + } + private static IConfiguration CreateConfiguration(Dictionary values) => new ConfigurationBuilder() .AddInMemoryCollection(values) .Build(); + + private static DbFunctionRegistry CreateFunctionRegistry() + => DbFunctionRegistry.Create(functions => + functions.AddScalar( + "AddOne", + 1, + new DbScalarFunctionOptions(DbType.Integer), + static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1))); } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminHostCallbacksTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminHostCallbacksTests.cs new file mode 100644 index 00000000..f3a07aef --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/AdminHostCallbacksTests.cs @@ -0,0 +1,65 @@ +using CSharpDB.Admin.Configuration; +using CSharpDB.Admin.Services; +using CSharpDB.Client; + +namespace CSharpDB.Admin.Forms.Tests.Admin; + +public sealed class AdminHostCallbacksTests +{ + [Fact] + public async Task DefaultFunctionRegistry_IsAvailableToDirectClientSql() + { + var ct = TestContext.Current.CancellationToken; + string dbPath = Path.Combine(Path.GetTempPath(), $"csharpdb_admin_callbacks_{Guid.NewGuid():N}.db"); + + try + { + await using ICSharpDbClient client = CSharpDbClient.Create( + AdminClientOptionsBuilder.BuildDirectDataSource( + dbPath, + new AdminHostDatabaseOptions { OpenMode = AdminHostOpenMode.Direct }, + AdminHostCallbacks.CreateFunctionRegistry())); + + Assert.Null((await client.ExecuteSqlAsync("CREATE TABLE inputs (value TEXT);", ct)).Error); + Assert.Null((await client.ExecuteSqlAsync("INSERT INTO inputs VALUES ('Hello From Admin');", ct)).Error); + + var result = await client.ExecuteSqlAsync("SELECT Slugify(value) FROM inputs;", ct); + + Assert.Null(result.Error); + Assert.NotNull(result.Rows); + Assert.Equal("hello-from-admin", result.Rows![0][0]); + } + finally + { + DeleteIfExists(dbPath); + DeleteIfExists(dbPath + ".wal"); + } + } + + [Fact] + public async Task DefaultCommandRegistry_ProvidesExecutableEchoCommand() + { + var ct = TestContext.Current.CancellationToken; + var registry = AdminHostCallbacks.CreateCommandRegistry(); + + Assert.True(registry.TryGetCommand("EchoAutomationEvent", out var command)); + + var result = await command.InvokeAsync( + metadata: new Dictionary + { + ["surface"] = "AdminForms", + ["event"] = "BeforeInsert", + }, + ct: ct); + + Assert.True(result.Succeeded); + Assert.Equal("Received AdminForms.BeforeInsert.", result.Message); + Assert.Equal("Received AdminForms.BeforeInsert.", result.Value.AsText); + } + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs new file mode 100644 index 00000000..ed39a108 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs @@ -0,0 +1,265 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Admin.Services; +using CSharpDB.Primitives; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Forms.Tests.Admin; + +public class HostCallbackCatalogServiceTests +{ + [Fact] + public void GetCallbacks_ReturnsRegisteredFunctionsAndCommandsInStableOrder() + { + DbFunctionRegistry functions = DbFunctionRegistry.Create(builder => + builder.AddScalar( + "normalize_name", + 1, + new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + Description: "Normalize a display name."), + static (_, _) => DbValue.Null)); + + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + builder.AddCommand( + "refresh_cache", + new DbCommandOptions( + Description: "Refresh cached projections.", + Timeout: TimeSpan.FromSeconds(2), + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.ReadDatabase, Reason: "Read source rows.") + ]), + static _ => DbCommandResult.Success())); + + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(functions) + .AddSingleton(commands) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + IReadOnlyList callbacks = catalog.GetCallbacks(); + + Assert.Collection( + callbacks, + scalar => + { + Assert.Equal(AutomationCallbackKind.ScalarFunction, scalar.Kind); + Assert.Equal("normalize_name", scalar.Name); + Assert.Equal(1, scalar.Arity); + Assert.Equal(DbType.Text, scalar.ReturnType); + Assert.True(scalar.IsDeterministic); + Assert.Contains(scalar.Capabilities, capability => + capability.Name == DbExtensionCapability.ScalarFunctions + && capability.Exports is not null + && capability.Exports.Contains("normalize_name")); + }, + command => + { + Assert.Equal(AutomationCallbackKind.Command, command.Kind); + Assert.Equal("refresh_cache", command.Name); + Assert.Equal(TimeSpan.FromSeconds(2), command.Timeout); + Assert.Contains(command.Capabilities, capability => capability.Name == DbExtensionCapability.Commands); + Assert.Contains(command.Capabilities, capability => capability.Name == DbExtensionCapability.ReadDatabase); + }); + } + + [Fact] + public void GetCallbacks_AdminHostDefaultsExposeScalarAndCommandCallbacks() + { + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(AdminHostCallbacks.CreateFunctionRegistry()) + .AddSingleton(AdminHostCallbacks.CreateCommandRegistry()) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + IReadOnlyList callbacks = catalog.GetCallbacks(); + + DbHostCallbackDescriptor slugify = Assert.Single( + callbacks, + callback => callback.Kind == AutomationCallbackKind.ScalarFunction + && callback.Name == "Slugify"); + Assert.Equal(DbExtensionRuntimeKind.HostCallback, slugify.Runtime); + Assert.Equal(DbType.Text, slugify.ReturnType); + Assert.True(slugify.IsDeterministic); + Assert.True(slugify.NullPropagating); + + DbHostCallbackDescriptor echo = Assert.Single( + callbacks, + callback => callback.Kind == AutomationCallbackKind.Command + && callback.Name == "EchoAutomationEvent"); + Assert.Equal(DbExtensionRuntimeKind.HostCallback, echo.Runtime); + Assert.Contains(echo.Capabilities, capability => capability.Name == DbExtensionCapability.Commands); + } + + [Fact] + public void GetCallbacks_WhenRegistriesAreMissing_ReturnsEmptyList() + { + using ServiceProvider provider = new ServiceCollection().BuildServiceProvider(); + var catalog = new HostCallbackCatalogService(provider); + + Assert.Empty(catalog.GetCallbacks()); + } + + [Fact] + public async Task GetEntriesAsync_ReturnsRegisteredAndReferencedCallbacks() + { + DbFunctionRegistry functions = DbFunctionRegistry.Create(builder => + builder.AddScalar("Slugify", 1, (_, _) => DbValue.Null)); + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + builder.AddCommand("EchoAutomationEvent", _ => DbCommandResult.Success())); + + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(functions) + .AddSingleton(commands) + .AddSingleton(new StubFormRepository( + [ + CreateForm("orders-form", "Orders") with + { + Automation = new DbAutomationMetadata( + Commands: + [ + new DbAutomationCommandReference("EchoAutomationEvent", "admin.forms", "form.events.OnLoad"), + new DbAutomationCommandReference("MissingFormCommand", "admin.forms", "controls.submit.commandButton.click"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + ]), + }, + ])) + .AddSingleton(new StubReportRepository( + [ + CreateReport("orders-report", "Orders") with + { + Automation = new DbAutomationMetadata( + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("MissingReportFunction", 2, "admin.reports", "bands.detail.controls.total.expression"), + ]), + }, + ])) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + IReadOnlyList entries = await catalog.GetEntriesAsync(); + + HostCallbackCatalogEntry missingCommand = Assert.Single(entries, entry => entry.Name == "MissingFormCommand"); + Assert.True(missingCommand.IsMissingRegistration); + Assert.Equal(AutomationCallbackKind.Command, missingCommand.Kind); + HostCallbackReference commandReference = Assert.Single(missingCommand.References); + Assert.Equal("Form", commandReference.OwnerKind); + Assert.Equal("orders-form", commandReference.OwnerId); + Assert.Equal("controls.submit.commandButton.click", commandReference.Location); + + HostCallbackCatalogEntry missingFunction = Assert.Single(entries, entry => entry.Name == "MissingReportFunction"); + Assert.True(missingFunction.IsMissingRegistration); + Assert.Equal(2, missingFunction.Arity); + HostCallbackReference functionReference = Assert.Single(missingFunction.References); + Assert.Equal("Report", functionReference.OwnerKind); + Assert.Equal("bands.detail.controls.total.expression", functionReference.Location); + + HostCallbackCatalogEntry registeredFunction = Assert.Single(entries, entry => entry.Name == "Slugify"); + Assert.True(registeredFunction.IsRegistered); + Assert.False(registeredFunction.IsMissingRegistration); + Assert.Single(registeredFunction.References); + + HostCallbackCatalogEntry registeredCommand = Assert.Single(entries, entry => entry.Name == "EchoAutomationEvent"); + Assert.True(registeredCommand.IsRegistered); + Assert.False(registeredCommand.IsMissingRegistration); + Assert.Single(registeredCommand.References); + } + + [Fact] + public async Task GetEntriesAsync_DeduplicatesRepeatedReferences() + { + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(DbFunctionRegistry.Empty) + .AddSingleton(DbCommandRegistry.Empty) + .AddSingleton(new StubFormRepository( + [ + CreateForm("orders-form", "Orders") with + { + Automation = new DbAutomationMetadata( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.OnLoad"), + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.OnLoad"), + ]), + }, + ])) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + HostCallbackCatalogEntry entry = Assert.Single(await catalog.GetEntriesAsync()); + + Assert.Equal("AuditOrder", entry.Name); + Assert.True(entry.IsMissingRegistration); + Assert.Single(entry.References); + } + + private static FormDefinition CreateForm(string formId, string tableName) + => new( + formId, + $"{tableName} Form", + tableName, + 1, + $"sig:{tableName}:v1", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + []); + + private static ReportDefinition CreateReport(string reportId, string sourceName) + => new( + reportId, + $"{sourceName} Report", + new ReportSourceReference(ReportSourceKind.Table, sourceName), + 1, + $"sig:{sourceName}:v1", + ReportPageSettings.DefaultLetterPortrait, + [], + [], + [new ReportBandDefinition("detail", ReportBandKind.Detail, 28, null, [])]); + + private sealed class StubFormRepository : IFormRepository + { + private readonly IReadOnlyList _forms; + + public StubFormRepository(IReadOnlyList forms) + { + _forms = forms; + } + + public Task GetAsync(string formId) => throw new NotSupportedException(); + public Task CreateAsync(FormDefinition form) => throw new NotSupportedException(); + public Task TryUpdateAsync(string formId, int expectedVersion, FormDefinition updated) => throw new NotSupportedException(); + public Task> ListAsync(string? tableName = null) => Task.FromResult(_forms); + public Task DeleteAsync(string formId) => throw new NotSupportedException(); + } + + private sealed class StubReportRepository : IReportRepository + { + private readonly IReadOnlyList _reports; + + public StubReportRepository(IReadOnlyList reports) + { + _reports = reports; + } + + public Task GetAsync(string reportId) => throw new NotSupportedException(); + public Task CreateAsync(ReportDefinition report) => throw new NotSupportedException(); + public Task TryUpdateAsync(string reportId, int expectedVersion, ReportDefinition updated) => throw new NotSupportedException(); + public Task> ListAsync(ReportSourceKind? sourceKind = null, string? sourceName = null) => Task.FromResult(_reports); + public Task DeleteAsync(string reportId) => throw new NotSupportedException(); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs new file mode 100644 index 00000000..052fbd3d --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs @@ -0,0 +1,73 @@ +using CSharpDB.Admin.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Tests.Admin; + +public sealed class HostCallbackPolicyServiceTests +{ + [Fact] + public void Evaluate_DefaultPolicyAllowsAdminHostCallbacks() + { + var service = new HostCallbackPolicyService(AdminHostCallbacks.CreatePolicy()); + DbHostCallbackDescriptor[] callbacks = + [ + .. AdminHostCallbacks.CreateFunctionRegistry().Callbacks, + .. AdminHostCallbacks.CreateCommandRegistry().Callbacks, + ]; + + Assert.NotEmpty(callbacks); + Assert.All(callbacks, callback => + { + DbExtensionPolicyDecision decision = service.Evaluate(callback); + + Assert.True(decision.Allowed, decision.DenialReason); + Assert.Null(decision.DenialReason); + Assert.All(decision.Capabilities, capability => + Assert.Equal(DbExtensionCapabilityGrantStatus.Granted, capability.Status)); + }); + } + + [Fact] + public void Evaluate_DefaultPolicyDeniesUnapprovedAdditionalCapabilities() + { + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + builder.AddCommand( + "NotifyExternalSystem", + new DbCommandOptions( + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Network), + ]), + static _ => DbCommandResult.Success())); + DbHostCallbackDescriptor callback = Assert.Single(commands.Callbacks); + var service = new HostCallbackPolicyService(AdminHostCallbacks.CreatePolicy()); + + DbExtensionPolicyDecision decision = service.Evaluate(callback); + + Assert.False(decision.Allowed); + Assert.Equal("Capability 'Network' is not granted.", decision.DenialReason); + Assert.Contains(decision.Capabilities, capability => + capability.Name == DbExtensionCapability.Commands + && capability.Status == DbExtensionCapabilityGrantStatus.Granted); + Assert.Contains(decision.Capabilities, capability => + capability.Name == DbExtensionCapability.Network + && capability.Status == DbExtensionCapabilityGrantStatus.Denied); + } + + [Fact] + public void Evaluate_UsesCallbackTimeoutBeforeDefaultPolicyTimeout() + { + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + builder.AddCommand( + "LongCommand", + new DbCommandOptions(Timeout: TimeSpan.FromSeconds(17)), + static _ => DbCommandResult.Success())); + DbHostCallbackDescriptor callback = Assert.Single(commands.Callbacks); + var service = new HostCallbackPolicyService(AdminHostCallbacks.CreatePolicy()); + + DbExtensionPolicyDecision decision = service.Evaluate(callback); + + Assert.True(decision.Allowed); + Assert.Equal(TimeSpan.FromSeconds(17), decision.Timeout); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs index 3d3f3cdb..b3e86962 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/TabManagerServiceTests.cs @@ -82,6 +82,37 @@ public void OpenCollectionTab_DeduplicatesByCollectionName() Assert.Equal(second, manager.ActiveTab); } + [Fact] + public void OpenCallbacksTab_CreatesHostCallbacksTab() + { + var manager = new TabManagerService(); + + TabDescriptor tab = manager.OpenCallbacksTab("normalize_name", "ScalarFunction", 1); + + Assert.Equal("callbacks:host", tab.Id); + Assert.Equal("Callbacks", tab.Title); + Assert.Equal(TabKind.HostCallbacks, tab.Kind); + Assert.Equal("normalize_name", tab.State["SelectedCallbackName"]); + Assert.Equal("ScalarFunction", tab.State["SelectedCallbackKind"]); + Assert.Equal(1, tab.State["SelectedCallbackArity"]); + Assert.Equal(tab, manager.ActiveTab); + } + + [Fact] + public void OpenCallbacksTab_DeduplicatesAndUpdatesSelection() + { + var manager = new TabManagerService(); + + TabDescriptor first = manager.OpenCallbacksTab("normalize_name", "ScalarFunction", 1); + TabDescriptor second = manager.OpenCallbacksTab("refresh_cache", "Command"); + + Assert.Same(first, second); + Assert.Equal(2, manager.Tabs.Count); + Assert.Equal("refresh_cache", second.State["SelectedCallbackName"]); + Assert.Equal("Command", second.State["SelectedCallbackKind"]); + Assert.Equal(second, manager.ActiveTab); + } + [Fact] public void CloseTabsForObject_ClosesCollectionTab() { diff --git a/tests/CSharpDB.ExtensionSandbox.Worker/CSharpDB.ExtensionSandbox.Worker.csproj b/tests/CSharpDB.ExtensionSandbox.Worker/CSharpDB.ExtensionSandbox.Worker.csproj new file mode 100644 index 00000000..e1645b65 --- /dev/null +++ b/tests/CSharpDB.ExtensionSandbox.Worker/CSharpDB.ExtensionSandbox.Worker.csproj @@ -0,0 +1,11 @@ + + + + Exe + net10.0 + enable + enable + false + + + diff --git a/tests/CSharpDB.ExtensionSandbox.Worker/Program.cs b/tests/CSharpDB.ExtensionSandbox.Worker/Program.cs new file mode 100644 index 00000000..7e183284 --- /dev/null +++ b/tests/CSharpDB.ExtensionSandbox.Worker/Program.cs @@ -0,0 +1,141 @@ +using System.Text.Json; + +int exitCode = 0; +string? line; +while ((line = await Console.In.ReadLineAsync()) is not null) +{ + if (string.IsNullOrWhiteSpace(line)) + continue; + + WorkerRequest? request; + try + { + request = JsonSerializer.Deserialize( + line, + WorkerJson.Options); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync(ex.Message); + return 2; + } + + if (request is null) + return 2; + + try + { + WorkerResponse response = request.Name switch + { + "Echo" => Echo(request), + "Sleep" => await SleepAsync(request), + "AllocateMemory" => await AllocateMemoryAsync(request), + "Crash" => Crash(), + _ => new WorkerResponse( + Succeeded: false, + Message: $"Unknown command '{request.Name}'.", + ErrorCode: "UnknownCommand"), + }; + + await Console.Out.WriteLineAsync(JsonSerializer.Serialize(response, WorkerJson.Options)); + exitCode = response.Succeeded ? 0 : 3; + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync(ex.ToString()); + return 1; + } +} + +return exitCode; + +static WorkerResponse Echo(WorkerRequest request) +{ + string? message = request.Arguments is not null && + request.Arguments.TryGetValue("message", out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + return new WorkerResponse( + Succeeded: true, + Message: "Echo completed.", + Value: JsonSerializer.SerializeToElement(message, WorkerJson.Options)); +} + +static async Task SleepAsync(WorkerRequest request) +{ + int delayMs = 250; + if (request.Arguments is not null && + request.Arguments.TryGetValue("delayMs", out JsonElement value) && + value.ValueKind == JsonValueKind.Number && + value.TryGetInt32(out int parsedDelayMs)) + { + delayMs = parsedDelayMs; + } + + await Task.Delay(delayMs); + return new WorkerResponse( + Succeeded: true, + Message: $"Slept for {delayMs}ms.", + Value: JsonSerializer.SerializeToElement(delayMs, WorkerJson.Options)); +} + +static async Task AllocateMemoryAsync(WorkerRequest request) +{ + int megabytes = ReadInt32Argument(request, "megabytes", 64); + int holdMs = ReadInt32Argument(request, "holdMs", 250); + if (megabytes <= 0) + { + return new WorkerResponse( + Succeeded: false, + Message: "megabytes must be greater than zero.", + ErrorCode: "InvalidArgument"); + } + + byte[] buffer = GC.AllocateUninitializedArray(checked(megabytes * 1024 * 1024)); + for (int i = 0; i < buffer.Length; i += 4096) + buffer[i] = 1; + + await Task.Delay(Math.Max(holdMs, 0)); + return new WorkerResponse( + Succeeded: true, + Message: $"Allocated {megabytes}MB.", + Value: JsonSerializer.SerializeToElement(megabytes, WorkerJson.Options)); +} + +static WorkerResponse Crash() +{ + Environment.Exit(42); + return new WorkerResponse(false, "unreachable", ErrorCode: "Unreachable"); +} + +static int ReadInt32Argument(WorkerRequest request, string name, int defaultValue) +{ + if (request.Arguments is not null && + request.Arguments.TryGetValue(name, out JsonElement value) && + value.ValueKind == JsonValueKind.Number && + value.TryGetInt32(out int parsedValue)) + { + return parsedValue; + } + + return defaultValue; +} + +internal sealed record WorkerRequest( + string Kind, + string Name, + Dictionary? Arguments, + Dictionary? Metadata); + +internal sealed record WorkerResponse( + bool Succeeded, + string? Message = null, + JsonElement? Value = null, + string? ErrorCode = null); + +internal static class WorkerJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); +} diff --git a/tests/CSharpDB.Tests/CSharpDB.Tests.csproj b/tests/CSharpDB.Tests/CSharpDB.Tests.csproj index c1f44285..f2c5840e 100644 --- a/tests/CSharpDB.Tests/CSharpDB.Tests.csproj +++ b/tests/CSharpDB.Tests/CSharpDB.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs b/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs new file mode 100644 index 00000000..34ca27d0 --- /dev/null +++ b/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs @@ -0,0 +1,266 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class DbExtensionPolicyTests +{ + [Fact] + public void Evaluate_DeniesExtensionsWhenHostPolicyDisablesExecution() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature"); + var policy = new DbExtensionPolicy( + AllowExtensions: false, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Extension execution is disabled by host policy.", decision.DenialReason); + } + + [Fact] + public void Evaluate_DeniesUnsignedExtensionWhenSignatureIsRequired() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: null); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Extension policy requires a signature.", decision.DenialReason); + } + + [Fact] + public void Evaluate_AllowsHostCallbackWithoutArtifactSignature() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: null) with + { + Runtime = DbExtensionRuntimeKind.HostCallback, + Entrypoint = "host:ApproveOrder", + ArtifactSha256 = null, + }; + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.True(decision.Allowed); + Assert.Null(decision.DenialReason); + } + + [Fact] + public void Evaluate_AllowsHostCallbackDescriptorWhenCapabilitiesAreGranted() + { + DbCommandRegistry registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "ApproveOrder", + new DbCommandOptions( + Timeout: TimeSpan.FromSeconds(2), + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest( + DbExtensionCapability.ReadDatabase, + Tables: ["Orders"]), + ]), + static _ => DbCommandResult.Success())); + DbHostCallbackDescriptor descriptor = Assert.Single(registry.Callbacks); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted, + PolicySource: "test-policy"), + ], + DefaultTimeout: TimeSpan.FromSeconds(10), + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + descriptor, + policy, + DbExtensionHostMode.Embedded); + + Assert.True(decision.Allowed); + Assert.Null(decision.DenialReason); + Assert.Equal(TimeSpan.FromSeconds(2), decision.Timeout); + Assert.Equal( + [DbExtensionCapability.Commands, DbExtensionCapability.ReadDatabase], + decision.Capabilities.Select(static capability => capability.Name).ToArray()); + } + + [Fact] + public void Evaluate_DeniesHostCallbackDescriptorWhenCapabilityIsNotGranted() + { + DbCommandRegistry registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "NotifyExternalSystem", + new DbCommandOptions( + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Network), + ]), + static _ => DbCommandResult.Success())); + DbHostCallbackDescriptor descriptor = Assert.Single(registry.Callbacks); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + descriptor, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Capability 'Network' is not granted.", decision.DenialReason); + } + + [Fact] + public void Evaluate_AllowsSignedExtensionWhenRequestedCapabilitiesAreGranted() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature"); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Approved test command.", + PolicySource: "unit-test"), + ], + DefaultTimeout: TimeSpan.FromSeconds(3), + MaxMemoryBytes: 64 * 1024 * 1024, + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.True(decision.Allowed); + Assert.Null(decision.DenialReason); + DbExtensionCapabilityDecision capability = Assert.Single(decision.Capabilities); + Assert.Equal(DbExtensionCapability.Commands, capability.Name); + Assert.Equal(DbExtensionCapabilityGrantStatus.Granted, capability.Status); + Assert.Equal("unit-test", capability.PolicySource); + Assert.Equal(TimeSpan.FromSeconds(3), decision.Timeout); + Assert.Equal(64 * 1024 * 1024, decision.MaxMemoryBytes); + } + + [Fact] + public void Evaluate_DeniesExtensionWhenRequestedCapabilityIsMissing() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature") with + { + Capabilities = + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Commands), + new DbExtensionCapabilityRequest(DbExtensionCapability.ReadDatabase, Tables: ["Orders"]), + ], + }; + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Capability 'ReadDatabase' is not granted.", decision.DenialReason); + } + + [Fact] + public void Evaluate_DeniesExtensionWhenHostModeIsNotAllowed() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature"); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ], + RequireSignature: true, + AllowedHostModes: DbExtensionHostMode.Daemon); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Extension execution is not allowed in Embedded mode.", decision.DenialReason); + } + + private static DbExtensionManifest CreateCommandManifest(string? signature) + => new( + Id: "com.example.order-automation", + Name: "Order Automation", + Version: "1.2.0", + Runtime: DbExtensionRuntimeKind.OutOfProcess, + Entrypoint: "order-automation", + Exports: + [ + new DbExtensionExport(DbExtensionExportKind.Command, "ApproveOrder"), + ], + Capabilities: + [ + new DbExtensionCapabilityRequest( + DbExtensionCapability.Commands, + Reason: "Approve orders from form automation.", + Exports: ["ApproveOrder"]), + ], + RequiredCSharpDbVersion: "9.0", + ArtifactSha256: "abc123", + Signature: signature, + Publisher: "Example Co."); +} diff --git a/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessCommandSandboxPrototype.cs b/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessCommandSandboxPrototype.cs new file mode 100644 index 00000000..4fea4f47 --- /dev/null +++ b/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessCommandSandboxPrototype.cs @@ -0,0 +1,460 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CSharpDB.Tests; + +internal sealed class OutOfProcessCommandSandboxPrototype +{ + private static readonly SemaphoreSlim s_buildLock = new(1, 1); + private readonly string _workerAssemblyPath; + + private OutOfProcessCommandSandboxPrototype(string workerAssemblyPath) + { + _workerAssemblyPath = workerAssemblyPath; + } + + public static async Task CreateAsync(CancellationToken ct) + { + string workerAssemblyPath = GetWorkerAssemblyPath(); + if (!File.Exists(workerAssemblyPath)) + await BuildWorkerAsync(ct); + + return new OutOfProcessCommandSandboxPrototype(workerAssemblyPath); + } + + public async Task InvokeCommandAsync( + string name, + IReadOnlyDictionary? arguments, + TimeSpan timeout, + CancellationToken ct, + SandboxResourceLimits? resourceLimits = null) + { + await using OutOfProcessCommandSandboxSession session = StartSession(); + return await session.InvokeCommandAsync(name, arguments, timeout, ct, resourceLimits); + } + + public OutOfProcessCommandSandboxSession StartSession() + => new(_workerAssemblyPath); + + private static async Task BuildWorkerAsync(CancellationToken ct) + { + await s_buildLock.WaitAsync(ct); + try + { + string workerAssemblyPath = GetWorkerAssemblyPath(); + if (File.Exists(workerAssemblyPath)) + return; + + var startInfo = new ProcessStartInfo + { + FileName = GetDotNetHostPath(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add("build"); + startInfo.ArgumentList.Add(GetWorkerProjectPath()); + startInfo.ArgumentList.Add("--configuration"); + startInfo.ArgumentList.Add(GetConfiguration()); + startInfo.ArgumentList.Add("--nologo"); + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + await process.WaitForExitAsync(ct); + if (process.ExitCode != 0) + { + string stdout = await process.StandardOutput.ReadToEndAsync(ct); + string stderr = await process.StandardError.ReadToEndAsync(ct); + throw new InvalidOperationException( + $"Failed to build extension sandbox worker. Exit code {process.ExitCode}.{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); + } + } + finally + { + s_buildLock.Release(); + } + } + + private static string GetWorkerAssemblyPath() + => Path.Combine( + GetRepositoryRoot(), + "tests", + "CSharpDB.ExtensionSandbox.Worker", + "bin", + GetConfiguration(), + "net10.0", + "CSharpDB.ExtensionSandbox.Worker.dll"); + + private static string GetWorkerProjectPath() + => Path.Combine( + GetRepositoryRoot(), + "tests", + "CSharpDB.ExtensionSandbox.Worker", + "CSharpDB.ExtensionSandbox.Worker.csproj"); + + private static string GetRepositoryRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "CSharpDB.slnx"))) + return current.FullName; + + current = current.Parent; + } + + throw new InvalidOperationException("Could not find CSharpDB repository root."); + } + + private static string GetConfiguration() + => AppContext.BaseDirectory.Contains( + $"{Path.DirectorySeparatorChar}Release{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? "Release" + : "Debug"; + + private static string GetDotNetHostPath() + => Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"; +} + +internal sealed class OutOfProcessCommandSandboxSession : IAsyncDisposable +{ + private readonly Process _process; + private bool _disposed; + + public OutOfProcessCommandSandboxSession(string workerAssemblyPath) + { + var startInfo = new ProcessStartInfo + { + FileName = GetDotNetHostPath(), + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + startInfo.ArgumentList.Add(workerAssemblyPath); + + _process = new Process { StartInfo = startInfo }; + Stopwatch stopwatch = Stopwatch.StartNew(); + _process.Start(); + StartupElapsed = stopwatch.Elapsed; + } + + public TimeSpan StartupElapsed { get; } + + public async Task InvokeCommandAsync( + string name, + IReadOnlyDictionary? arguments, + TimeSpan timeout, + CancellationToken ct, + SandboxResourceLimits? resourceLimits = null) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Timeout must be greater than zero."); + + var request = new SandboxCommandRequest( + Kind: "command", + Name: name, + Arguments: arguments, + Metadata: new Dictionary + { + ["surface"] = "Phase9Prototype", + ["correlationId"] = Guid.NewGuid().ToString("N"), + }); + + Stopwatch stopwatch = Stopwatch.StartNew(); + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + using var watchdogCts = new CancellationTokenSource(); + Task? resourceTask = StartResourceWatchdog(resourceLimits, watchdogCts.Token); + + try + { + string requestJson = JsonSerializer.Serialize(request, SandboxJson.Options); + await _process.StandardInput.WriteLineAsync(requestJson.AsMemory(), linkedCts.Token); + await _process.StandardInput.FlushAsync(linkedCts.Token); + + Task responseTask = _process.StandardOutput.ReadLineAsync(linkedCts.Token).AsTask(); + Task completedTask = resourceTask is null + ? responseTask + : await Task.WhenAny(responseTask, resourceTask); + + if (resourceTask is not null && completedTask == resourceTask) + { + ResourceLimitViolation? violation = await resourceTask; + if (violation is not null) + { + await KillWorkerAsync(_process); + return new SandboxInvocationResult( + Succeeded: false, + TimedOut: false, + Crashed: false, + ResourceLimitExceeded: true, + ExitCode: GetExitCodeOrNull(_process), + Elapsed: stopwatch.Elapsed, + Message: $"Worker exceeded the soft working set limit of {violation.LimitBytes} bytes.", + Value: null, + ErrorCode: "ResourceLimitExceeded", + ObservedWorkingSetBytes: violation.ObservedBytes); + } + } + + string? responseLine = await responseTask; + if (responseLine is null) + { + await _process.WaitForExitAsync(CancellationToken.None); + string stderr = await _process.StandardError.ReadToEndAsync(); + return new SandboxInvocationResult( + Succeeded: false, + TimedOut: false, + Crashed: _process.ExitCode != 0, + ResourceLimitExceeded: false, + ExitCode: _process.ExitCode, + Elapsed: stopwatch.Elapsed, + Message: stderr, + Value: null, + ErrorCode: _process.ExitCode == 0 ? "ProtocolError" : "WorkerCrash"); + } + + SandboxCommandResponse? response = JsonSerializer.Deserialize( + responseLine, + SandboxJson.Options); + + if (response is null) + { + return new SandboxInvocationResult( + Succeeded: false, + TimedOut: false, + Crashed: false, + ResourceLimitExceeded: false, + ExitCode: GetExitCodeOrNull(_process), + Elapsed: stopwatch.Elapsed, + Message: "Worker returned an empty response.", + Value: null, + ErrorCode: "ProtocolError"); + } + + return new SandboxInvocationResult( + response.Succeeded, + TimedOut: false, + Crashed: false, + ResourceLimitExceeded: false, + ExitCode: GetExitCodeOrNull(_process), + Elapsed: stopwatch.Elapsed, + Message: response.Message, + Value: response.Value, + ErrorCode: response.ErrorCode); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + await KillWorkerAsync(_process); + return new SandboxInvocationResult( + Succeeded: false, + TimedOut: true, + Crashed: false, + ResourceLimitExceeded: false, + ExitCode: GetExitCodeOrNull(_process), + Elapsed: stopwatch.Elapsed, + Message: $"Command '{name}' timed out after {timeout.TotalMilliseconds:0.###}ms.", + Value: null, + ErrorCode: "Timeout"); + } + catch (OperationCanceledException) + { + await KillWorkerAsync(_process); + throw; + } + catch (IOException ex) when (_process.HasExited) + { + return new SandboxInvocationResult( + Succeeded: false, + TimedOut: false, + Crashed: _process.ExitCode != 0, + ResourceLimitExceeded: false, + ExitCode: _process.ExitCode, + Elapsed: stopwatch.Elapsed, + Message: ex.Message, + Value: null, + ErrorCode: _process.ExitCode == 0 ? "ProtocolError" : "WorkerCrash"); + } + finally + { + await watchdogCts.CancelAsync(); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + return; + + _disposed = true; + try + { + if (!_process.HasExited) + { + _process.StandardInput.Close(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + await _process.WaitForExitAsync(cts.Token); + } + } + catch (OperationCanceledException) + { + await KillWorkerAsync(_process); + } + catch (InvalidOperationException) + { + } + catch (IOException) + { + } + finally + { + _process.Dispose(); + } + } + + private Task? StartResourceWatchdog( + SandboxResourceLimits? resourceLimits, + CancellationToken ct) + { + if (resourceLimits?.MaxWorkingSetBytes is not { } maxWorkingSetBytes) + return null; + + TimeSpan pollInterval = resourceLimits.PollInterval ?? TimeSpan.FromMilliseconds(25); + return WatchWorkingSetAsync(_process, maxWorkingSetBytes, pollInterval, ct); + } + + private static async Task WatchWorkingSetAsync( + Process process, + long maxWorkingSetBytes, + TimeSpan pollInterval, + CancellationToken ct) + { + long maxObservedBytes = 0; + try + { + while (!ct.IsCancellationRequested && !process.HasExited) + { + process.Refresh(); + maxObservedBytes = Math.Max(maxObservedBytes, process.WorkingSet64); + if (maxObservedBytes > maxWorkingSetBytes) + return new ResourceLimitViolation(maxObservedBytes, maxWorkingSetBytes); + + await Task.Delay(pollInterval, ct); + } + } + catch (OperationCanceledException) + { + } + catch (InvalidOperationException) + { + } + + return null; + } + + private static async Task KillWorkerAsync(Process process) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + } + + try + { + if (!process.HasExited) + await process.WaitForExitAsync(CancellationToken.None); + } + catch (InvalidOperationException) + { + } + } + + private static int? GetExitCodeOrNull(Process process) + { + try + { + return process.HasExited ? process.ExitCode : null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static string GetDotNetHostPath() + => Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet"; +} + +internal sealed record SandboxResourceLimits( + long? MaxWorkingSetBytes = null, + TimeSpan? PollInterval = null); + +internal sealed record SandboxLatencyMeasurements( + TimeSpan ColdInvocationElapsed, + TimeSpan WarmBatchElapsed, + TimeSpan WarmMedianInvocationElapsed, + int WarmInvocationCount) +{ + public static SandboxLatencyMeasurements Create( + SandboxInvocationResult coldInvocation, + IReadOnlyList warmInvocations, + TimeSpan warmBatchElapsed) + { + if (warmInvocations.Count == 0) + throw new ArgumentException("At least one warm invocation is required.", nameof(warmInvocations)); + + TimeSpan[] sorted = warmInvocations + .Select(static invocation => invocation.Elapsed) + .Order() + .ToArray(); + + return new SandboxLatencyMeasurements( + coldInvocation.Elapsed, + warmBatchElapsed, + sorted[sorted.Length / 2], + warmInvocations.Count); + } +} + +internal sealed record SandboxInvocationResult( + bool Succeeded, + bool TimedOut, + bool Crashed, + bool ResourceLimitExceeded, + int? ExitCode, + TimeSpan Elapsed, + string? Message, + JsonElement? Value, + string? ErrorCode, + long? ObservedWorkingSetBytes = null); + +internal sealed record SandboxCommandRequest( + string Kind, + string Name, + IReadOnlyDictionary? Arguments, + IReadOnlyDictionary Metadata); + +internal sealed record SandboxCommandResponse( + bool Succeeded, + string? Message = null, + JsonElement? Value = null, + string? ErrorCode = null); + +internal sealed record ResourceLimitViolation( + long ObservedBytes, + long LimitBytes); + +internal static class SandboxJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); +} diff --git a/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxMeasurementTests.cs b/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxMeasurementTests.cs new file mode 100644 index 00000000..0f4af165 --- /dev/null +++ b/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxMeasurementTests.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; + +namespace CSharpDB.Tests; + +public sealed class OutOfProcessSandboxMeasurementTests +{ + [Fact] + public async Task InvokeCommandAsync_CapturesColdWarmAndBatchedLatencyMeasurements() + { + var ct = TestContext.Current.CancellationToken; + OutOfProcessCommandSandboxPrototype sandbox = await OutOfProcessCommandSandboxPrototype.CreateAsync(ct); + + SandboxInvocationResult cold = await sandbox.InvokeCommandAsync( + "Echo", + new Dictionary { ["message"] = "cold" }, + TimeSpan.FromSeconds(5), + ct); + + await using OutOfProcessCommandSandboxSession session = sandbox.StartSession(); + var warmResults = new List(); + Stopwatch batchStopwatch = Stopwatch.StartNew(); + for (int i = 0; i < 8; i++) + { + SandboxInvocationResult warm = await session.InvokeCommandAsync( + "Echo", + new Dictionary { ["message"] = $"warm-{i}" }, + TimeSpan.FromSeconds(5), + ct); + + warmResults.Add(warm); + } + + batchStopwatch.Stop(); + SandboxLatencyMeasurements measurements = SandboxLatencyMeasurements.Create( + cold, + warmResults, + batchStopwatch.Elapsed); + + Assert.True(cold.Succeeded); + Assert.All(warmResults, static result => Assert.True(result.Succeeded)); + Assert.Equal(8, measurements.WarmInvocationCount); + Assert.True(measurements.ColdInvocationElapsed > TimeSpan.Zero); + Assert.True(measurements.WarmBatchElapsed > TimeSpan.Zero); + Assert.True(measurements.WarmMedianInvocationElapsed > TimeSpan.Zero); + Assert.True(measurements.WarmBatchElapsed < TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task InvokeCommandAsync_KillsWorkerWhenSoftWorkingSetLimitIsExceeded() + { + var ct = TestContext.Current.CancellationToken; + OutOfProcessCommandSandboxPrototype sandbox = await OutOfProcessCommandSandboxPrototype.CreateAsync(ct); + + SandboxInvocationResult result = await sandbox.InvokeCommandAsync( + "AllocateMemory", + new Dictionary + { + ["megabytes"] = 256, + ["holdMs"] = 5_000, + }, + TimeSpan.FromSeconds(10), + ct, + new SandboxResourceLimits( + MaxWorkingSetBytes: 128L * 1024 * 1024, + PollInterval: TimeSpan.FromMilliseconds(10))); + + Assert.False(result.Succeeded); + Assert.False(result.TimedOut); + Assert.False(result.Crashed); + Assert.True(result.ResourceLimitExceeded); + Assert.Equal("ResourceLimitExceeded", result.ErrorCode); + Assert.True(result.ObservedWorkingSetBytes > 128L * 1024 * 1024); + Assert.True(result.Elapsed < TimeSpan.FromSeconds(5)); + } +} diff --git a/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxPrototypeTests.cs b/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxPrototypeTests.cs new file mode 100644 index 00000000..b362e7c2 --- /dev/null +++ b/tests/CSharpDB.Tests/ExtensionSandbox/OutOfProcessSandboxPrototypeTests.cs @@ -0,0 +1,81 @@ +namespace CSharpDB.Tests; + +public sealed class OutOfProcessSandboxPrototypeTests +{ + [Fact] + public async Task InvokeCommandAsync_ReturnsJsonResponse() + { + var ct = TestContext.Current.CancellationToken; + OutOfProcessCommandSandboxPrototype sandbox = await OutOfProcessCommandSandboxPrototype.CreateAsync(ct); + + SandboxInvocationResult result = await sandbox.InvokeCommandAsync( + "Echo", + new Dictionary { ["message"] = "approved" }, + TimeSpan.FromSeconds(5), + ct); + + Assert.True(result.Succeeded); + Assert.False(result.TimedOut); + Assert.False(result.Crashed); + Assert.False(result.ResourceLimitExceeded); + Assert.Equal("Echo completed.", result.Message); + Assert.Equal("approved", result.Value?.GetString()); + Assert.True(result.Elapsed > TimeSpan.Zero); + } + + [Fact] + public async Task InvokeCommandAsync_KillsWorkerOnTimeout() + { + var ct = TestContext.Current.CancellationToken; + OutOfProcessCommandSandboxPrototype sandbox = await OutOfProcessCommandSandboxPrototype.CreateAsync(ct); + + SandboxInvocationResult result = await sandbox.InvokeCommandAsync( + "Sleep", + new Dictionary { ["delayMs"] = 5_000 }, + TimeSpan.FromMilliseconds(150), + ct); + + Assert.False(result.Succeeded); + Assert.True(result.TimedOut); + Assert.False(result.Crashed); + Assert.False(result.ResourceLimitExceeded); + Assert.Equal("Timeout", result.ErrorCode); + Assert.True(result.Elapsed < TimeSpan.FromSeconds(3)); + } + + [Fact] + public async Task InvokeCommandAsync_KillsWorkerOnCancellation() + { + var ct = TestContext.Current.CancellationToken; + OutOfProcessCommandSandboxPrototype sandbox = await OutOfProcessCommandSandboxPrototype.CreateAsync(ct); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromMilliseconds(150)); + + await Assert.ThrowsAsync( + async () => await sandbox.InvokeCommandAsync( + "Sleep", + new Dictionary { ["delayMs"] = 5_000 }, + TimeSpan.FromSeconds(5), + cts.Token)); + } + + [Fact] + public async Task InvokeCommandAsync_ReportsWorkerCrashWithoutCrashingHost() + { + var ct = TestContext.Current.CancellationToken; + OutOfProcessCommandSandboxPrototype sandbox = await OutOfProcessCommandSandboxPrototype.CreateAsync(ct); + + SandboxInvocationResult result = await sandbox.InvokeCommandAsync( + "Crash", + arguments: null, + TimeSpan.FromSeconds(5), + ct); + + Assert.False(result.Succeeded); + Assert.False(result.TimedOut); + Assert.True(result.Crashed); + Assert.False(result.ResourceLimitExceeded); + Assert.Equal(42, result.ExitCode); + Assert.Equal("WorkerCrash", result.ErrorCode); + } +} diff --git a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs index 95411d2a..6a1a8314 100644 --- a/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs +++ b/tests/CSharpDB.Tests/TrustedCommandRegistryTests.cs @@ -10,7 +10,17 @@ public async Task Registry_ValidatesNamesCollisionsAndMetadata() var registry = DbCommandRegistry.Create(commands => commands.AddCommand( "RecalculateInventory", - new DbCommandOptions("Rebuilds inventory summaries.", IsLongRunning: true), + new DbCommandOptions( + "Rebuilds inventory summaries.", + Timeout: TimeSpan.FromSeconds(5), + IsLongRunning: true, + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest( + DbExtensionCapability.ReadDatabase, + Reason: "Reads product inventory tables.", + Tables: ["Products", "Inventory"]), + ]), static context => { Assert.Equal("RecalculateInventory", context.CommandName); @@ -21,7 +31,19 @@ public async Task Registry_ValidatesNamesCollisionsAndMetadata() Assert.True(registry.TryGetCommand("recalculateinventory", out DbCommandDefinition definition)); Assert.Equal("Rebuilds inventory summaries.", definition.Options.Description); + Assert.Equal(TimeSpan.FromSeconds(5), definition.Options.Timeout); Assert.True(definition.Options.IsLongRunning); + Assert.Equal(AutomationCallbackKind.Command, definition.Descriptor.Kind); + Assert.Equal(DbExtensionRuntimeKind.HostCallback, definition.Descriptor.Runtime); + Assert.Equal("RecalculateInventory", definition.Descriptor.Name); + Assert.Null(definition.Descriptor.Arity); + Assert.Equal("Rebuilds inventory summaries.", definition.Descriptor.Description); + Assert.Equal(TimeSpan.FromSeconds(5), definition.Descriptor.Timeout); + Assert.True(definition.Descriptor.IsLongRunning); + Assert.Equal( + [DbExtensionCapability.Commands, DbExtensionCapability.ReadDatabase], + definition.Descriptor.Capabilities.Select(static capability => capability.Name).ToArray()); + Assert.Same(definition.Descriptor, Assert.Single(registry.Callbacks)); DbCommandResult result = await definition.InvokeAsync( new Dictionary(StringComparer.OrdinalIgnoreCase) diff --git a/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs b/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs index 2fe555d5..dc436f9c 100644 --- a/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs +++ b/tests/CSharpDB.Tests/TrustedScalarFunctionTests.cs @@ -16,13 +16,35 @@ public void Registry_ValidatesNamesCollisionsAndMetadata() functions.AddScalar( "Bump", 1, - new DbScalarFunctionOptions(DbType.Integer, IsDeterministic: true, NullPropagating: true), + new DbScalarFunctionOptions( + DbType.Integer, + IsDeterministic: true, + NullPropagating: true, + Description: "Adds one to an integer.", + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest( + DbExtensionCapability.Clock, + Reason: "Demonstrates additional host-visible capability metadata."), + ]), static (_, args) => DbValue.FromInteger(args[0].AsInteger + 1))); Assert.True(registry.TryGetScalar("bump", 1, out var definition)); Assert.Equal(DbType.Integer, definition.Options.ReturnType); Assert.True(definition.Options.IsDeterministic); Assert.True(definition.Options.NullPropagating); + Assert.Equal(AutomationCallbackKind.ScalarFunction, definition.Descriptor.Kind); + Assert.Equal(DbExtensionRuntimeKind.HostCallback, definition.Descriptor.Runtime); + Assert.Equal("Bump", definition.Descriptor.Name); + Assert.Equal(1, definition.Descriptor.Arity); + Assert.Equal(DbType.Integer, definition.Descriptor.ReturnType); + Assert.Equal("Adds one to an integer.", definition.Descriptor.Description); + Assert.True(definition.Descriptor.IsDeterministic); + Assert.True(definition.Descriptor.NullPropagating); + Assert.Equal( + [DbExtensionCapability.ScalarFunctions, DbExtensionCapability.Clock], + definition.Descriptor.Capabilities.Select(static capability => capability.Name).ToArray()); + Assert.Same(definition.Descriptor, Assert.Single(registry.Callbacks)); Assert.Throws(() => DbFunctionRegistry.Create(functions => { From 90cf559218691f5ed47ebbfd630fe68fd053b5cc Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Thu, 30 Apr 2026 18:51:09 -0700 Subject: [PATCH 24/39] feat: Enhance README with additional command and function metadata details --- docs/trusted-csharp-functions/README.md | 50 ++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index af015ba6..4f806f98 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -51,6 +51,11 @@ same surface-specific failure path as other command errors. `IsLongRunning` is metadata for hosts and UI surfaces; it does not move the command out of process or run it on a separate scheduler. +Every registered command exposes a `DbHostCallbackDescriptor` through +`DbCommandDefinition.Descriptor` and `DbCommandRegistry.Callbacks`. The +descriptor is read-only metadata for policy checks, diagnostics, and Admin +visibility. It does not sandbox the command. + --- ## What You Can Register @@ -214,7 +219,12 @@ Each function can include `DbScalarFunctionOptions`: new DbScalarFunctionOptions( ReturnType: DbType.Text, IsDeterministic: true, - NullPropagating: true) + NullPropagating: true, + Description: "Formats a URL slug.", + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Clock) + ]) ``` | Option | Meaning | @@ -222,6 +232,9 @@ new DbScalarFunctionOptions( | `ReturnType` | Optional metadata describing the expected return type. | | `IsDeterministic` | Marks the function as returning the same output for the same inputs. V1 exposes the metadata but does not use it for constant folding or index planning. | | `NullPropagating` | If any argument is `NULL`, CSharpDB returns `NULL` without invoking the delegate. | +| `Description` | Optional human-readable text for host tools and Admin visibility. | +| `AdditionalCapabilities` | Optional capability metadata beyond the implicit `ScalarFunctions` capability. This is for CSharpDB policy mediation and visibility, not a .NET sandbox. | +| `Metadata` | Optional host-defined descriptor metadata. | Without `NullPropagating`, `DbValue.Null` is passed to the delegate and the function decides what to do. @@ -234,6 +247,41 @@ functions.AddScalar( args[0].IsNull ? args[1] : args[0]); ``` +Registered scalar functions expose `DbScalarFunctionDefinition.Descriptor` and +`DbFunctionRegistry.Callbacks`. The descriptor always uses +`DbExtensionRuntimeKind.HostCallback`, records the callback kind, name, arity, +return type, deterministic/null behavior, and includes the implicit +`ScalarFunctions` capability plus any additional capabilities declared in the +options. + +Commands expose the same descriptor shape through `DbCommandDefinition`. +Command descriptors include the implicit `Commands` capability plus any +additional capabilities declared in `DbCommandOptions`, along with description, +timeout, and long-running metadata. + +Hosts can evaluate descriptor capabilities with `DbExtensionPolicyEvaluator`: + +```csharp +DbHostCallbackDescriptor descriptor = commandRegistry.Callbacks.Single(); +DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + descriptor, + new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted), + ]), + DbExtensionHostMode.Embedded); +``` + +Policy evaluation controls what CSharpDB-mediated APIs should allow. It does +not restrict arbitrary in-process .NET calls made by trusted host code. + --- ## SQL Usage From 377fb3a5299bcdd74b6d8315d172b538591f6bb4 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Thu, 30 Apr 2026 19:12:45 -0700 Subject: [PATCH 25/39] Checkpoint phase 9 callback readiness UI --- .../Components/Tabs/CallbacksTab.razor | 75 +++++- src/CSharpDB.Admin/Program.cs | 1 + .../Services/HostCallbackReadinessService.cs | 147 +++++++++++ src/CSharpDB.Admin/wwwroot/css/app.css | 73 +++++- .../HostCallbackReadinessServiceTests.cs | 228 ++++++++++++++++++ 5 files changed, 522 insertions(+), 2 deletions(-) create mode 100644 src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs diff --git a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor index c8bb7e4f..2a3e6ca9 100644 --- a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor @@ -1,6 +1,9 @@ @using CSharpDB.Primitives @inject HostCallbackCatalogService CallbackCatalog @inject HostCallbackPolicyService CallbackPolicy +@inject HostCallbackReadinessService CallbackReadiness +@inject ToastService Toast +@inject IJSRuntime JS
@@ -13,10 +16,21 @@ callbacks
-
+
+ @GetReadinessText() +
+ +
+
@@ -218,6 +232,12 @@ This callback is referenced by saved database metadata, but it is not registered in the current Admin host.

+
+ +
+ @@ -265,6 +285,7 @@ private string _kindFilter = string.Empty; private string _statusFilter = string.Empty; private string? _selectedKey; + private HostCallbackReadinessReport? _readiness; private IReadOnlyList FilteredEntries => _entries.Where(MatchesFilter).ToArray(); @@ -272,6 +293,8 @@ private HostCallbackCatalogEntry? SelectedEntry => _entries.FirstOrDefault(entry => GetEntryKey(entry) == _selectedKey); + private bool HasMissingEntries => _entries.Any(static entry => entry.IsMissingRegistration); + protected override async Task OnInitializedAsync() { await RefreshAsync(); @@ -285,6 +308,7 @@ private async Task RefreshAsync() { _entries = await CallbackCatalog.GetEntriesAsync(); + _readiness = await CallbackReadiness.GetReadinessAsync(); ApplyTabSelection(); if (_selectedKey is null || !_entries.Any(entry => GetEntryKey(entry) == _selectedKey)) @@ -331,6 +355,40 @@ _statusFilter = args.Value?.ToString() ?? string.Empty; } + private async Task CopyMissingStubsAsync() + { + if (!HasMissingEntries) + { + Toast.Info("All referenced callbacks are registered."); + return; + } + + try + { + string source = await CallbackReadiness.GenerateMissingStubSourceAsync(); + await JS.InvokeVoidAsync("clipboardInterop.writeText", source); + Toast.Success("Copied missing callback registration stubs."); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + + private async Task CopySelectedStubAsync(HostCallbackCatalogEntry entry) + { + try + { + string source = CallbackReadiness.GenerateStubSource(entry); + await JS.InvokeVoidAsync("clipboardInterop.writeText", source); + Toast.Success($"Copied stub for '{entry.Name}'."); + } + catch (Exception ex) + { + Toast.Error(ex.Message); + } + } + private bool MatchesFilter(HostCallbackCatalogEntry entry) { if (!string.IsNullOrWhiteSpace(_kindFilter) @@ -470,6 +528,21 @@ : "callbacks-policy-badge denied"; } + private string GetReadinessText() + { + if (_readiness is null) + return "Checking"; + + return _readiness.Ready + ? $"Ready · {_readiness.RegisteredCount} registered" + : $"{_readiness.MissingCount} missing · {_readiness.ReferencedCount} referenced"; + } + + private string GetReadinessBadgeClass() + => _readiness?.Ready == true + ? "callbacks-readiness-badge ready" + : "callbacks-readiness-badge missing"; + private static string GetPolicyStatus(DbExtensionPolicyDecision decision) => decision.Allowed ? "Allowed" : "Denied"; diff --git a/src/CSharpDB.Admin/Program.cs b/src/CSharpDB.Admin/Program.cs index a220db35..3a1c52f0 100644 --- a/src/CSharpDB.Admin/Program.cs +++ b/src/CSharpDB.Admin/Program.cs @@ -41,6 +41,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddCSharpDbAdminForms(); builder.Services.AddCSharpDbAdminReports(); diff --git a/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs b/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs new file mode 100644 index 00000000..0b8f5211 --- /dev/null +++ b/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs @@ -0,0 +1,147 @@ +using System.Globalization; +using System.Text; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Services; + +public sealed record HostCallbackReadinessReport( + int RegisteredCount, + int ReferencedCount, + IReadOnlyList MissingEntries) +{ + public int MissingCount => MissingEntries.Count; + public bool Ready => MissingCount == 0; +} + +public sealed class HostCallbackReadinessService +{ + private static readonly AutomationStubGenerationOptions s_missingStubOptions = new( + Namespace: "CSharpDbAutomation", + ClassName: "MissingHostCallbackRegistration"); + + private readonly HostCallbackCatalogService _catalog; + + public HostCallbackReadinessService(HostCallbackCatalogService catalog) + { + _catalog = catalog; + } + + public async Task GetReadinessAsync() + { + IReadOnlyList entries = await _catalog.GetEntriesAsync(); + HostCallbackCatalogEntry[] missingEntries = entries + .Where(static entry => entry.IsMissingRegistration) + .ToArray(); + + return new HostCallbackReadinessReport( + RegisteredCount: entries.Count(static entry => entry.IsRegistered), + ReferencedCount: entries.Count(static entry => entry.IsReferenced), + MissingEntries: missingEntries); + } + + public async Task GenerateMissingStubSourceAsync() + { + HostCallbackReadinessReport report = await GetReadinessAsync(); + return GenerateStubSource(report.MissingEntries, s_missingStubOptions); + } + + public string GenerateStubSource(HostCallbackCatalogEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return GenerateStubSource( + [entry], + new AutomationStubGenerationOptions( + Namespace: "CSharpDbAutomation", + ClassName: CreateStubClassName(entry), + MethodName: "Register")); + } + + private static string GenerateStubSource( + IReadOnlyList entries, + AutomationStubGenerationOptions options) + { + DbAutomationMetadata metadata = BuildMetadata(entries); + return AutomationStubGenerator.GenerateCSharp(metadata, options); + } + + private static DbAutomationMetadata BuildMetadata(IReadOnlyList entries) + { + var commands = new List(); + var scalarFunctions = new List(); + + foreach (HostCallbackCatalogEntry entry in entries) + { + foreach (HostCallbackReference reference in entry.References) + { + string location = FormatReferenceLocation(reference); + if (entry.Kind == AutomationCallbackKind.Command) + { + commands.Add(new DbAutomationCommandReference( + entry.Name, + reference.Surface, + location)); + } + else if (entry.Kind == AutomationCallbackKind.ScalarFunction && entry.Arity.HasValue) + { + scalarFunctions.Add(new DbAutomationScalarFunctionReference( + entry.Name, + entry.Arity.Value, + reference.Surface, + location)); + } + } + } + + return new DbAutomationMetadata( + DbAutomationMetadata.CurrentMetadataVersion, + commands, + scalarFunctions); + } + + private static string FormatReferenceLocation(HostCallbackReference reference) + { + string owner = string.IsNullOrWhiteSpace(reference.OwnerName) + ? reference.OwnerId + : reference.OwnerName; + + return $"{reference.OwnerKind} {owner} ({reference.OwnerId}): {reference.Location}"; + } + + private static string CreateStubClassName(HostCallbackCatalogEntry entry) + { + StringBuilder builder = new("Register"); + foreach (string part in entry.Name.Split(['_', '-', '.', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + builder.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) + builder.Append(part[1..]); + } + + if (builder.Length == "Register".Length) + builder.Append(entry.Kind.ToString()); + + if (entry.Arity.HasValue) + builder.Append("Arity").Append(entry.Arity.Value.ToString(CultureInfo.InvariantCulture)); + + builder.Append("Callback"); + return SanitizeIdentifier(builder.ToString()); + } + + private static string SanitizeIdentifier(string candidate) + { + var builder = new StringBuilder(candidate.Length); + for (int i = 0; i < candidate.Length; i++) + { + char ch = candidate[i]; + bool valid = i == 0 + ? char.IsLetter(ch) || ch == '_' + : char.IsLetterOrDigit(ch) || ch == '_'; + builder.Append(valid ? ch : '_'); + } + + return builder.Length == 0 || char.IsDigit(builder[0]) + ? "_" + builder + : builder.ToString(); + } +} diff --git a/src/CSharpDB.Admin/wwwroot/css/app.css b/src/CSharpDB.Admin/wwwroot/css/app.css index 308958df..424b134f 100644 --- a/src/CSharpDB.Admin/wwwroot/css/app.css +++ b/src/CSharpDB.Admin/wwwroot/css/app.css @@ -4988,9 +4988,46 @@ body { min-height: 0; } +.callbacks-tab .data-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr); + justify-content: start; + align-items: start; + gap: 8px 12px; + overflow-x: visible; +} + +.callbacks-tab .data-crumb { + min-width: 0; +} + +.callbacks-tab .data-pager { + grid-column: 1; + margin-left: 0; + justify-self: start; +} + +.callbacks-readiness-group { + grid-column: 1; + justify-self: start; + padding-left: 0; + border-left: 0; +} + +.callbacks-actions { + grid-column: 1; + padding-left: 0; + border-left: 0; +} + .callbacks-filter-bar { - min-width: 280px; + grid-column: 1; + width: min(100%, 360px); + min-width: 0; flex: 1; + flex-wrap: wrap; + padding-left: 0; + border-left: 0; } .callbacks-filter, @@ -5018,6 +5055,33 @@ body { padding: 0 10px; } +.callbacks-readiness-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + max-width: 220px; + padding: 2px 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + white-space: nowrap; +} + +.callbacks-readiness-badge.ready { + border-color: rgba(158, 206, 106, 0.35); + background: rgba(158, 206, 106, 0.1); + color: var(--accent-green); +} + +.callbacks-readiness-badge.missing { + border-color: rgba(224, 175, 104, 0.4); + background: rgba(224, 175, 104, 0.12); + color: var(--accent-yellow); +} + .callbacks-layout { display: grid; grid-template-columns: minmax(420px, 1fr) minmax(340px, 420px); @@ -5169,6 +5233,13 @@ body { line-height: 1.45; } +.callbacks-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 12px 0; +} + .callbacks-detail-table { margin-top: 12px; } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs new file mode 100644 index 00000000..a84a7640 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs @@ -0,0 +1,228 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Reports.Contracts; +using CSharpDB.Admin.Reports.Models; +using CSharpDB.Admin.Services; +using CSharpDB.Primitives; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Forms.Tests.Admin; + +public sealed class HostCallbackReadinessServiceTests +{ + [Fact] + public async Task GetReadinessAsync_ReturnsMissingReferencedCallbacks() + { + using ServiceProvider provider = CreateProvider( + functions: DbFunctionRegistry.Empty, + commands: DbCommandRegistry.Empty, + forms: + [ + CreateForm("orders-form", "Orders") with + { + Automation = new DbAutomationMetadata( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.OnLoad"), + ]), + }, + ], + reports: + [ + CreateReport("orders-report", "Orders") with + { + Automation = new DbAutomationMetadata( + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("FormatTotal", 1, "admin.reports", "bands.detail.controls.total.expression"), + ]), + }, + ]); + + HostCallbackReadinessService readiness = provider.GetRequiredService(); + + HostCallbackReadinessReport report = await readiness.GetReadinessAsync(); + + Assert.False(report.Ready); + Assert.Equal(0, report.RegisteredCount); + Assert.Equal(2, report.ReferencedCount); + Assert.Equal(2, report.MissingCount); + Assert.Contains(report.MissingEntries, entry => entry.Name == "AuditOrder" && entry.Kind == AutomationCallbackKind.Command); + Assert.Contains(report.MissingEntries, entry => entry.Name == "FormatTotal" && entry.Kind == AutomationCallbackKind.ScalarFunction); + } + + [Fact] + public async Task GetReadinessAsync_IsReadyWhenReferencedCallbacksAreRegistered() + { + DbFunctionRegistry functions = DbFunctionRegistry.Create(builder => + builder.AddScalar("FormatTotal", 1, (_, _) => DbValue.Null)); + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + builder.AddCommand("AuditOrder", _ => DbCommandResult.Success())); + + using ServiceProvider provider = CreateProvider( + functions, + commands, + forms: + [ + CreateForm("orders-form", "Orders") with + { + Automation = new DbAutomationMetadata( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.OnLoad"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("FormatTotal", 1, "admin.forms", "controls.total.formula"), + ]), + }, + ], + reports: []); + + HostCallbackReadinessService readiness = provider.GetRequiredService(); + + HostCallbackReadinessReport report = await readiness.GetReadinessAsync(); + + Assert.True(report.Ready); + Assert.Equal(2, report.RegisteredCount); + Assert.Equal(2, report.ReferencedCount); + Assert.Empty(report.MissingEntries); + } + + [Fact] + public async Task GenerateMissingStubSourceAsync_ProducesCSharpForMissingReferencesOnly() + { + DbFunctionRegistry functions = DbFunctionRegistry.Create(builder => + builder.AddScalar("RegisteredFunction", 1, (_, _) => DbValue.Null)); + + using ServiceProvider provider = CreateProvider( + functions, + DbCommandRegistry.Empty, + forms: + [ + CreateForm("orders-form", "Orders") with + { + Automation = new DbAutomationMetadata( + Commands: + [ + new DbAutomationCommandReference("AuditOrder", "admin.forms", "form.events.OnLoad"), + ], + ScalarFunctions: + [ + new DbAutomationScalarFunctionReference("RegisteredFunction", 1, "admin.forms", "controls.slug.formula"), + new DbAutomationScalarFunctionReference("MissingFunction", 2, "admin.forms", "controls.total.formula"), + ]), + }, + ], + reports: []); + + HostCallbackReadinessService readiness = provider.GetRequiredService(); + + string source = await readiness.GenerateMissingStubSourceAsync(); + + Assert.Contains("class MissingHostCallbackRegistration", source); + Assert.Contains("commands.AddAsyncCommand", source); + Assert.Contains("\"AuditOrder\"", source); + Assert.Contains("functions.AddScalar", source); + Assert.Contains("\"MissingFunction\"", source); + Assert.Contains("Form Orders Form (orders-form): form.events.OnLoad", source); + Assert.DoesNotContain("\"RegisteredFunction\"", source); + } + + [Fact] + public void GenerateStubSource_ProducesSourceForSelectedEntry() + { + var entry = new HostCallbackCatalogEntry( + AutomationCallbackKind.Command, + "Send_Invoice", + Arity: null, + Descriptor: null, + References: + [ + new HostCallbackReference( + AutomationCallbackKind.Command, + "Send_Invoice", + Arity: null, + "admin.forms", + "controls.send.commandButton.click", + "Form", + "invoice-form", + "Invoice Form"), + ]); + var readiness = new HostCallbackReadinessService(new HostCallbackCatalogService(new ServiceCollection().BuildServiceProvider())); + + string source = readiness.GenerateStubSource(entry); + + Assert.Contains("class RegisterSendInvoiceCallback", source); + Assert.Contains("\"Send_Invoice\"", source); + Assert.Contains("Form Invoice Form (invoice-form): controls.send.commandButton.click", source); + } + + private static ServiceProvider CreateProvider( + DbFunctionRegistry functions, + DbCommandRegistry commands, + IReadOnlyList forms, + IReadOnlyList reports) + => new ServiceCollection() + .AddSingleton(functions) + .AddSingleton(commands) + .AddSingleton(new StubFormRepository(forms)) + .AddSingleton(new StubReportRepository(reports)) + .AddScoped() + .AddScoped() + .BuildServiceProvider(); + + private static FormDefinition CreateForm(string formId, string tableName) + => new( + formId, + $"{tableName} Form", + tableName, + 1, + $"sig:{tableName}:v1", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + []); + + private static ReportDefinition CreateReport(string reportId, string sourceName) + => new( + reportId, + $"{sourceName} Report", + new ReportSourceReference(ReportSourceKind.Table, sourceName), + 1, + $"sig:{sourceName}:v1", + ReportPageSettings.DefaultLetterPortrait, + [], + [], + [new ReportBandDefinition("detail", ReportBandKind.Detail, 28, null, [])]); + + private sealed class StubFormRepository : IFormRepository + { + private readonly IReadOnlyList _forms; + + public StubFormRepository(IReadOnlyList forms) + { + _forms = forms; + } + + public Task GetAsync(string formId) => throw new NotSupportedException(); + public Task CreateAsync(FormDefinition form) => throw new NotSupportedException(); + public Task TryUpdateAsync(string formId, int expectedVersion, FormDefinition updated) => throw new NotSupportedException(); + public Task> ListAsync(string? tableName = null) => Task.FromResult(_forms); + public Task DeleteAsync(string formId) => throw new NotSupportedException(); + } + + private sealed class StubReportRepository : IReportRepository + { + private readonly IReadOnlyList _reports; + + public StubReportRepository(IReadOnlyList reports) + { + _reports = reports; + } + + public Task GetAsync(string reportId) => throw new NotSupportedException(); + public Task CreateAsync(ReportDefinition report) => throw new NotSupportedException(); + public Task TryUpdateAsync(string reportId, int expectedVersion, ReportDefinition updated) => throw new NotSupportedException(); + public Task> ListAsync(ReportSourceKind? sourceKind = null, string? sourceName = null) => Task.FromResult(_reports); + public Task DeleteAsync(string reportId) => throw new NotSupportedException(); + } +} From 6795efd79aac37263ee3d26ab622ae6e8e4e8058 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Fri, 1 May 2026 06:13:50 -0700 Subject: [PATCH 26/39] feat: Add Fulfillment Ops Admin Automation tutorial and enhance README with developer handoff details --- docs/trusted-csharp-functions/README.md | 110 ++- .../fulfillment-ops-admin-automation.md | 837 ++++++++++++++++++ samples/trusted-csharp-host/README.md | 45 + 3 files changed, 988 insertions(+), 4 deletions(-) create mode 100644 docs/tutorials/fulfillment-ops-admin-automation.md diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index 4f806f98..d58ca4de 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -4,6 +4,10 @@ CSharpDB can call host-registered C# scalar functions from SQL and the embedded This feature is intentionally trusted and in-process. It does not store C# source code in the database, sandbox user code, load plugin assemblies from database files, or serialize delegates over HTTP or gRPC. +For an end-to-end app-builder walkthrough that combines Admin Forms, collections, +macro actions, reports, trusted callbacks, and callback readiness, see the +[Fulfillment Ops Admin Automation tutorial](../tutorials/fulfillment-ops-admin-automation.md). + --- ## Trusted Commands @@ -192,6 +196,103 @@ The VS Code story stays host-owned: VS Code is the editor/debugger for the C# host project, while database metadata stores names and declarative action data only. +### End-To-End Developer Handoff + +The production workflow is a handoff between an app builder and a host +developer: + +1. The app builder creates database metadata that references a callback by name. + Examples include a formula such as `=Slugify([Name])` or a form action step + such as `RunCommand` with command name `SendOpsDigest`. +2. Admin records only the callback name, arity/kind metadata, action arguments, + and reference location. It does not store C# source. +3. The callback catalog compares referenced names with the callbacks registered + by the current host. Missing names appear as missing callback readiness. +4. The app builder copies the generated registration stub from Admin and gives + it to the host developer. +5. The host developer implements the C# function or command in the host + project, registers it during startup, and debugs it in VS Code like normal + application code. +6. The host is restarted. Admin refreshes callback readiness, and the reference + changes from missing to registered/allowed when the name, kind, and arity + match. + +For Admin itself, host-owned demo callbacks are registered in +`src/CSharpDB.Admin/Services/AdminHostCallbacks.cs`. A scalar function is +registered with `DbFunctionRegistry`: + +```csharp +functions.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); +``` + +A command callback is registered with `DbCommandRegistry`: + +```csharp +commands.AddCommand( + "SendOpsDigest", + new DbCommandOptions("Sends a fulfillment operations digest."), + static context => + { + string source = context.Arguments.TryGetValue("source", out DbValue value) + ? value.AsText + : "unknown"; + + // Call host-owned services here. + return DbCommandResult.Success($"Digest requested by {source}."); + }); +``` + +That C# code belongs to the host application. The database still stores only the +name `SendOpsDigest` and any declarative action arguments. + +### Stored Procedures In The Mix + +Stored procedures are different from trusted callbacks. They are database-owned +SQL definitions with parameter metadata. They are useful when the logic should +stay inside CSharpDB and can be expressed as SQL: + +| Need | Prefer | +| --- | --- | +| Reusable multi-statement table work | Stored procedure | +| Transactional updates plus follow-up result sets | Stored procedure | +| A form button that runs reviewed database logic | `RunProcedure` | +| External API, email, filesystem, queue, or host service call | Trusted command callback | +| Custom scalar calculation inside SQL expressions | Trusted scalar function | +| UI-only behavior such as filtering or control state | Declarative macro action | + +A stored procedure can be executed directly through the client API: + +```csharp +ProcedureExecutionResult result = await client.ExecuteProcedureAsync( + "AllocateOrder", + new Dictionary + { + ["orderId"] = 7005, + ["allocatedBy"] = "Wave Planner", + ["note"] = "Allocated from a reviewed procedure.", + }); +``` + +Admin's SQL editor also accepts `EXEC` as an Admin command surface: + +```sql +EXEC AllocateOrder @orderId = 7005, @allocatedBy = 'Wave Planner'; +EXEC RefreshOperationalStats; +EXEC tutorial_OpenOrderSnapshot { "status": "released" }; +``` + +Use `RunProcedure` from form metadata only when the rendered host enables +procedure actions. Use `RunCommand` when the same button needs host-owned C#. +When both are needed, run the procedure first for database work and the command +second for the external side effect. + --- ## Registration Rules @@ -641,9 +742,10 @@ form lifecycle dispatch can still run `SetFieldValue`, `ShowMessage`, `Stop`, and `RunCommand`, but it reports a failure if a sequence asks for rendered-form navigation or save/delete actions. -V1 action sequences do not include loops, stored C# source, database-owned -plugins, direct SQL/procedure execution actions, or remote delegate -serialization. +Action sequences do not include loops, stored C# source, database-owned +plugins, or remote delegate serialization. Rendered Admin form runtimes support +direct SQL and procedure actions only when the host explicitly enables those +capabilities. --- @@ -914,4 +1016,4 @@ V1 does not support: - Sending delegates over HTTP, gRPC, or pipeline package files. - Optimizer pushdown, expression indexes, generated columns, or constant folding based on custom function metadata. - Additional Access-style control events such as double-click, key, mouse, timer, and dirty/current events. -- Richer macro/action scripts with loops, direct SQL/procedure actions, conditional UI rules, or database-owned executable code. +- Richer macro/action scripts with loops, reusable UI rule presets, additional event surfaces, or database-owned executable code. diff --git a/docs/tutorials/fulfillment-ops-admin-automation.md b/docs/tutorials/fulfillment-ops-admin-automation.md new file mode 100644 index 00000000..39ee9886 --- /dev/null +++ b/docs/tutorials/fulfillment-ops-admin-automation.md @@ -0,0 +1,837 @@ +# Fulfillment Ops Admin Automation Tutorial + +This tutorial walks through a realistic CSharpDB Admin scenario for an app +builder. You will use the fulfillment demo database to inspect operational +tables, work with JSON document collections, review Access-style forms and +reports, run stored procedures, build a small form automation workflow, and +inspect the trusted host callbacks that make executable behavior visible +without storing C# code in the database. + +The goal is not just to click through Admin. The goal is to understand the +product model: + +- tables and collections hold operational data +- stored procedures package reusable SQL work inside the database +- forms and reports provide Access-style application surfaces +- macro actions are saved as database metadata +- trusted callbacks are C# code owned and registered by the host application +- callback readiness tells app builders whether saved metadata can run in the + current host + +## Scenario + +You are building an internal fulfillment console for an operations team. The +team needs to: + +- review open orders +- create or review shipments +- receive purchase orders +- process returns +- inspect scanner-session JSON captured by warehouse devices +- inspect webhook payloads received from external systems +- run repeatable SQL workflows such as allocation, receiving, and status + snapshots +- run a few host-owned automation hooks from buttons and form events +- verify that every callback referenced by forms, reports, or automation is + registered by the current Admin host + +CSharpDB Admin fits this scenario because it combines database browsing, +document inspection, form design, reports, declarative macro actions, and +stored procedure execution with trusted C# callback visibility in one tool. + +## Prerequisites + +Use a copy of the demo database for tutorial work. The tutorial includes create, +edit, and delete steps for disposable objects. + +From the repository root: + +```powershell +Copy-Item ` + -LiteralPath .\src\CSharpDB.Admin\fulfillment-hub-demo.db ` + -Destination .\src\CSharpDB.Admin\fulfillment-hub-tutorial.db ` + -Force +``` + +Start Admin against the copied database: + +```powershell +$env:ConnectionStrings__CSharpDB = 'Data Source=fulfillment-hub-tutorial.db' +dotnet run --project .\src\CSharpDB.Admin\CSharpDB.Admin.csproj --urls http://localhost:62818 +``` + +Open: + +```text +http://localhost:62818/ +``` + +The Admin header should show a connected database. If it still shows +`fulfillment-hub-demo.db`, stop Admin and restart it with the environment +variable above. + +Important trust boundary: + +- trusted callbacks are ordinary in-process C# code registered by the host +- database metadata can reference callback names, but does not store callback + implementations +- CSharpDB does not use WASM, source scanning, or in-process database-owned + plugin assemblies for this workflow + +## Tour The Admin App + +Start in the Object Explorer on the left. + +You should see these high-level groups: + +- **User Tables** +- **Collections** +- **Forms** +- **Reports** +- **Procedures** +- **Callbacks** + +Use the filter chips near the top of Object Explorer to focus the list. The +tutorial uses these objects: + +| Area | Objects | +| --- | --- | +| Tables | `orders`, `shipments`, `returns`, `products`, `inventory_positions`, `purchase_orders` | +| Collections | `scanner_sessions`, `webhook_archive` | +| Forms | `Order Workbench`, `Purchase Order Receiving`, `Return Intake` | +| Reports | `Low Stock Watch`, `Open Order Queue`, `Shipment Manifest` | +| Procedures | `AllocateOrder`, `CreateShipment`, `ReceivePurchaseOrder`, `RecordReturn`, `RefreshOperationalStats` | +| Callbacks | `Slugify`, `EchoAutomationEvent` | + +If Object Explorer does not show a new object after a write, click the refresh +button in the Object Explorer header. + +## Browse Operational Tables + +1. Click **Tables** in Object Explorer. +2. Open `orders`. +3. Page through the order rows. +4. Open `shipments`. +5. Open `returns`. +6. Open `inventory_positions`. + +Notice the table browser pattern: + +- each table opens in its own tab +- the toolbar contains refresh and paging controls +- the grid is for row browsing +- the detail or edit area is for record-level work + +This is the relational side of the fulfillment app. The next sections use +forms, procedures, collections, reports, and callbacks to build an application +workflow on top of these tables. + +## Use The Existing Forms + +1. Click **Forms** in Object Explorer. +2. Open `Order Workbench`. +3. Use the data-entry runtime to move through order records. +4. Open `Purchase Order Receiving`. +5. Open `Return Intake`. + +These forms are the Access-style app surfaces. An app builder can design forms +over tables and views, then attach event bindings and action sequences. + +Use the designer entry for `Order Workbench` when you want to edit layout or +actions. Use the data-entry entry when you want to run the form as an operator +would. + +## Use Stored Procedures For Reusable Database Work + +Stored procedures are database-owned SQL workflows. They are the right tool +when the behavior is still database work: + +| Use a stored procedure when... | Use something else when... | +| --- | --- | +| the logic is a reusable set of SQL statements | the user is running one ad hoc query | +| multiple table updates should succeed or fail together | the workflow needs external APIs, files, email, queues, or services | +| the operation needs named parameters and defaults | the workflow only changes form UI state | +| the operation should return follow-up result sets after it writes | the behavior must be host-owned C# | + +In this model: + +- procedures store SQL and parameter metadata in the database +- procedure execution runs inside one transaction +- `@parameter` references in the SQL body are bound from Args JSON or `EXEC` + arguments +- procedures can read and write tables, write audit/event rows, and return one + or more result sets +- procedures do not store C# source and are not host callbacks + +### Inspect And Run A Read-Only Procedure + +1. Click **Procedures** in Object Explorer. +2. Open `RefreshOperationalStats`. +3. Review the body SQL. It returns table stats, shortage-watch rows, and the + open order board. +4. Confirm the procedure has no parameters. +5. In **Args JSON**, keep: + + ```json + {} + ``` + +6. Click **Run**. + +Expected result: + +- the execution summary reports success +- the tab lists each statement in the procedure body +- result grids appear for the `SELECT` statements + +You can also run the same procedure from the SQL editor: + +```sql +EXEC RefreshOperationalStats; +``` + +### Inspect Write Procedures Before Running Them + +Open these procedures and read their body SQL and parameter lists: + +- `AllocateOrder` +- `ReceivePurchaseOrder` +- `CreateShipment` +- `RecordReturn` + +These are good stored-procedure candidates because they coordinate several +related table changes and then return review data. For example, `AllocateOrder` +updates inventory reservations, updates order-line allocation state, updates +the order, writes an `ops_events` row, and returns follow-up rows for review. + +Run write procedures only against the copied tutorial database. They are meant +to demonstrate operational workflows, but they still mutate real rows in that +copy. + +### Create A Tutorial Read-Only Procedure + +Create one disposable procedure so you can practice the editor without changing +operational data. + +1. Right-click **Procedures** and choose **New Procedure...**, or use the + command palette item **New Procedure**. +2. Set **Name** to: + + ```text + tutorial_OpenOrderSnapshot + ``` + +3. Set **Description** to: + + ```text + Tutorial read-only snapshot of order board rows by status. + ``` + +4. Leave **Enabled** checked. +5. Set **Body SQL** to: + + ```sql + SELECT order_number, + customer_name, + warehouse_code, + order_status, + priority_code, + total_amount + FROM order_fulfillment_board + WHERE order_status = @status + ORDER BY required_ship_date, priority_code DESC, order_number; + ``` + +6. Add one parameter: + + | Name | Type | Required | Default | Description | + | --- | --- | --- | --- | --- | + | `status` | `TEXT` | unchecked | `released` | Order status to show. | + +7. Click **Save**. +8. In **Args JSON**, enter: + + ```json + { + "status": "released" + } + ``` + +9. Click **Run** and review the result grid. + +The same procedure can be run from the SQL editor with either JSON args: + +```sql +EXEC tutorial_OpenOrderSnapshot { "status": "allocated" }; +``` + +or SQL-style args: + +```sql +EXEC tutorial_OpenOrderSnapshot @status = 'allocated'; +``` + +### When Procedures Meet Forms + +Forms can reference stored procedures through the `RunProcedure` macro action. +Use that when a button or form event should invoke a reviewed database workflow, +such as allocation, receiving, or a read-only snapshot. The action target is the +procedure name, and the action arguments become procedure arguments. + +Use `RunCommand` instead when the workflow must leave the database and call +host-owned C# services. A common pattern is: + +1. `RunProcedure` performs the database work. +2. `RunCommand` tells host-owned C# to send a notification, publish a message, + or call an external service. + +The default Admin rendered form host may keep `RunProcedure` disabled unless +the host explicitly opts in. The procedure editor and SQL editor can still run +procedures directly. + +## Use Collections For Operational JSON + +Collections are for JSON documents that are better kept as document payloads +than flattened relational rows. In this demo they represent warehouse scanner +sessions and webhook payload archives. + +### Inspect `scanner_sessions` + +1. Click **Collections** in Object Explorer. +2. Open `scanner_sessions`. +3. Confirm the collection tab shows a paged document grid. +4. Select a document. +5. Review the indented JSON in the detail panel. +6. Change the page size to `10`, `25`, `50`, or `100`. +7. Use the exact-key lookup if you know a document key. + +The demo data may fit on a single page. The important behavior is that the tab +uses the same paged browsing model for two documents as it does for thousands. + +The grid shows: + +- row number +- document key +- JSON kind +- compact preview + +The detail panel shows the selected document as formatted JSON. + +### Create And Delete A Disposable Document + +Use a tutorial-only key so cleanup is obvious. + +1. In `scanner_sessions`, click **New Document**. +2. Enter this key: + + ```text + tutorial_scanner_session + ``` + +3. Enter this JSON: + + ```json + { + "sessionId": "tutorial_scanner_session", + "warehouse": "SEA-01", + "operator": "tutorial", + "startedAt": "2026-05-01T09:00:00Z", + "events": [ + { + "kind": "scan", + "sku": "TUTORIAL-SKU", + "quantity": 1 + } + ], + "status": "review" + } + ``` + +4. Confirm **Save** is enabled only while the JSON is valid. +5. Click **Save**. +6. Select the saved document and confirm the preview updates. +7. Click **Delete**. +8. Confirm the delete prompt. + +If you intentionally break the JSON, for example by removing a closing brace, +Admin keeps **Save** disabled and shows a validation message. + +### Inspect `webhook_archive` + +1. Open `webhook_archive`. +2. Select a document. +3. Review the payload structure. +4. Review the page controls and page-size selector. If your copied database has + more webhook documents than one page can hold, move between pages. + +This is the document side of the fulfillment app. The app builder can inspect +payloads without needing a new transport contract or a custom JSON viewer. + +## Build An Access-Style Workflow + +This section adds a tutorial-only workflow to `Order Workbench`. The workflow +demonstrates the macro/action model. It stores action metadata in the form; it +does not store executable C# code in the database. + +Use a copied tutorial database before making these edits. + +### Add Tutorial Controls + +1. Open the `Order Workbench` designer. +2. Add a label near the top of the form. +3. Set the label text to: + + ```text + Tutorial review mode is active. + ``` + +4. Copy the generated read-only control ID from the property inspector and + write it down as ``. +5. Add a command button. +6. Set the button text to: + + ```text + Tutorial Review + ``` + +7. Copy its generated control ID as ``. +8. Add a second command button. +9. Set the button text to: + + ```text + Clear Tutorial Filter + ``` + +10. Copy its generated control ID as ``. + +The current designer generates control IDs and shows them as read-only. Use the +recorded IDs anywhere this tutorial asks for a target control. These controls +are tutorial-owned because their visible text and action sequence names are +tutorial-specific, so the cleanup step is still easy. + +### Add The Review Action Sequence + +Select the **Tutorial Review** button. Add an `OnClick` event, then add an +action sequence named: + +```text +tutorial_prepare_review +``` + +Add these steps in order: + +| Step | Action | Target | Value or settings | +| ---: | --- | --- | --- | +| 1 | `SetControlVisibility` | `` | `true` | +| 2 | `SetControlEnabled` | `` | `true` | +| 3 | `SetControlReadOnly` | `` | `true` | +| 4 | `ApplyFilter` | `form` | `Status <> 'Closed'` | +| 5 | `OpenForm` | `Purchase Order Receiving` | arguments JSON shown below | +| 6 | `RunCommand` | empty | command name: `EchoAutomationEvent`; arguments JSON shown below | +| 7 | `ShowMessage` | empty | `Tutorial review mode is active.` | + +For step 5, use JSON in the arguments field: + +```json +{ + "mode": "browse", + "filter": "Status <> 'Closed'" +} +``` + +For step 6, use JSON in the arguments field: + +```json +{ + "source": "tutorial_review_button" +} +``` + +Save the form. + +Run the data-entry form and click **Tutorial Review**. + +Expected result: + +- the banner becomes visible +- the clear-filter button becomes enabled +- the review button becomes read-only or disabled for editing where supported +- the current form applies the `Status <> 'Closed'` filter +- `Purchase Order Receiving` opens in a new tab with the supplied mode/filter +- `EchoAutomationEvent` runs as a trusted host command +- the form shows the tutorial message + +If the filter fails, check that the form's source has a `Status` field. If your +copy of the form uses a different field name, change the filter to a field that +exists on the form source. + +### Add The Clear Filter Sequence + +Select the **Clear Tutorial Filter** button. Add an `OnClick` event, then add +an action sequence named: + +```text +tutorial_clear_review +``` + +Add these steps: + +| Step | Action | Target | Value or settings | +| ---: | --- | --- | --- | +| 1 | `ClearFilter` | `form` | empty | +| 2 | `SetControlVisibility` | `` | `false` | +| 3 | `SetControlReadOnly` | `` | `false` | +| 4 | `ShowMessage` | empty | `Tutorial review mode cleared.` | + +Save the form, run data entry, and click **Clear Tutorial Filter**. + +Expected result: + +- the form filter is cleared +- the banner is hidden +- the review button becomes editable again where supported +- a confirmation message appears + +### Optional Power Actions: SQL And Procedures + +The action model includes `RunSql` and `RunProcedure`. The default Admin +rendered form host may leave those actions disabled by host policy, even for +read-only procedure bodies. That is intentional: SQL/procedure actions can +change data, so a host should enable them deliberately. + +To understand the design-time shape, add a disabled or non-running tutorial +sequence named: + +```text +tutorial_power_actions_reference +``` + +Add these steps with `StopOnFailure = false`: + +| Step | Action | Target | Value or settings | +| ---: | --- | --- | --- | +| 1 | `RunSql` | empty | `SELECT COUNT(*) AS open_orders FROM orders WHERE Status <> 'Closed'` | +| 2 | `RunProcedure` | `tutorial_OpenOrderSnapshot` | arguments JSON shown below | +| 3 | `ShowMessage` | empty | `Power action reference completed.` | + +For step 2, use JSON in the arguments field: + +```json +{ + "status": "released" +} +``` + +If you run this sequence in a host where SQL/procedure actions are disabled, +the expected result is a clear failure message such as `RunSql action is +disabled by host policy` or `RunProcedure action is disabled by host policy`. +That is the correct default posture. In a host that enables these actions, use +a copied tutorial database and only run idempotent or disposable operations. + +If `tutorial_OpenOrderSnapshot` was not created earlier, use +`RefreshOperationalStats` instead and leave the arguments field as `{}`. + +## Trusted Host Callbacks + +Callbacks are the bridge between saved database metadata and host-owned C#. + +1. Click **Callbacks** in Object Explorer. +2. Open the callback catalog. +3. Select `Slugify`. +4. Review its descriptor: + - kind: scalar function + - runtime: `HostCallback` + - return type: text + - deterministic/null behavior + - capability requests and policy decision +5. Select `EchoAutomationEvent`. +6. Review its descriptor: + - kind: command + - runtime: `HostCallback` + - command capability + - policy decision + +The readiness badge summarizes whether saved metadata references callbacks that +are missing from the current host. + +Important: + +- `Slugify` and `EchoAutomationEvent` are registered by the Admin host at + startup +- the database can reference these names +- the database does not contain their C# implementation +- descriptor and policy metadata make callbacks visible, but do not sandbox + them + +## Demonstrate Missing Callback Readiness + +Use the copied tutorial database for this step. + +1. Open the `Order Workbench` designer. +2. Select the **Tutorial Review** button. +3. Add one more `RunCommand` step to `tutorial_prepare_review`. +4. Set the command name to: + + ```text + tutorial_SendOpsDigest + ``` + +5. Set `StopOnFailure = false`. +6. Add arguments: + + ```json + { + "source": "tutorial_missing_callback_demo" + } + ``` + +7. Save the form. +8. Open **Callbacks**. +9. Click **Refresh**. + +Expected result: + +- the readiness badge reports a missing callback +- the grid includes `tutorial_SendOpsDigest` +- the row is marked missing +- the details panel shows the form/reference location +- **Stubs** is enabled + +Click **Stubs** to copy registration stub source for all missing callbacks, or +select the missing row and click **Copy Stub** for only that callback. + +The copied stub is a developer handoff. It is not stored in the database and it +is not automatically trusted. A host developer must implement and register the +command in the host application. + +Cleanup options: + +- remove the `tutorial_SendOpsDigest` step from the form, or +- keep it as a deliberate missing-callback example in the copied tutorial + database + +## Implement The Missing Callback In A Host + +The previous section created the app-builder side of the story: database +metadata now references a command named `tutorial_SendOpsDigest`. The database +still does not contain C# code. A host developer must add that command to the +host application and register it at startup. + +For the Admin demo host, the registration point is: + +```text +src/CSharpDB.Admin/Services/AdminHostCallbacks.cs +``` + +For a smaller VS Code debugging walkthrough, open the sample host project: + +```powershell +code .\samples\trusted-csharp-host +``` + +That sample shows the same pattern with `Slugify` and +`AuditCustomerChange`: callbacks are ordinary C# methods/delegates, registered +with `DbFunctionRegistry` or `DbCommandRegistry`, then invoked by SQL or form +automation metadata that references them by name. + +A host developer would implement the tutorial command like this inside the +host's command registry setup: + +```csharp +commands.AddCommand( + "tutorial_SendOpsDigest", + new DbCommandOptions("Sends a tutorial fulfillment operations digest."), + static context => + { + string source = context.Arguments.TryGetValue("source", out DbValue value) + ? value.AsText + : "unknown"; + + // Call host-owned services here: email, queues, logging, APIs, etc. + return DbCommandResult.Success($"Tutorial digest requested by {source}."); + }); +``` + +Debug flow: + +1. Put a breakpoint inside the command delegate. +2. Start the host from VS Code with `F5`. +3. Open Admin against the copied tutorial database. +4. Run the form action that references `tutorial_SendOpsDigest`. +5. Confirm the breakpoint is hit in host-owned C# code. +6. Refresh **Callbacks** and confirm readiness changes from missing to + registered/allowed when the name and kind match. + +The generated stub from Admin is only a starting point for the developer. The +trusted implementation is the reviewed C# code compiled into the host app. + +## Reports And Review + +Reports let app builders validate that the workflow supports operational +review, not only data entry. + +1. Click **Reports** in Object Explorer. +2. Open `Low Stock Watch`. +3. Preview the report. +4. Open `Open Order Queue`. +5. Preview the report. +6. Open `Shipment Manifest`. +7. Preview the report. + +Use these reports to answer: + +- do order changes show up where operators expect? +- does low-stock review still surface the right products? +- can a shipment manifest be reviewed after order/shipment work? +- do report expressions and data sources still load after form automation + changes? + +If a report uses host-registered scalar functions in calculated expressions, +those functions appear in the same callback catalog as form commands. + +## Security Model + +This tutorial uses several different kinds of behavior: + +| Behavior | Stored in database? | Executes C#? | Trust model | +| --- | --- | --- | --- | +| Tables and rows | yes | no | data only | +| Collection documents | yes | no | JSON data only | +| Stored procedures | yes | no | declarative SQL executed by CSharpDB | +| Form/report metadata | yes | no | declarative metadata | +| Macro/action sequences | yes | no | interpreted by Admin/runtime | +| Trusted callbacks | name/reference only | yes | host-owned in-process C# | + +CSharpDB deliberately does not treat database files as executable plugin +packages. + +Rejected for this feature track: + +- WASM plugin execution +- source scanning as a security boundary +- runtime-loaded database-owned assemblies in the CSharpDB process +- C# source stored in the database and compiled on normal open paths + +Future out-of-process .NET/C# workers remain a gated exploration path for +portable extension packages. They are not part of this tutorial workflow. + +## Troubleshooting + +### The Admin app opened the original demo database + +Stop Admin and restart with: + +```powershell +$env:ConnectionStrings__CSharpDB = 'Data Source=fulfillment-hub-tutorial.db' +dotnet run --project .\src\CSharpDB.Admin\CSharpDB.Admin.csproj --urls http://localhost:62818 +``` + +### Object Explorer does not show the object I expect + +Click the refresh icon in Object Explorer. If the object is still missing, +check that the tutorial database copy is the active database. + +### A collection document will not save + +Check: + +- the key is not blank +- the JSON is valid +- the collection name is `scanner_sessions` or another existing collection +- you are not editing a read-only existing key + +### A form filter fails + +Check: + +- the filter references a field that exists on the form source +- text values are quoted +- brackets are balanced +- parameters such as `@status` have matching action arguments + +Examples: + +```text +Status <> 'Closed' +[Status] = 'Ready' +Quantity > 0 +ClosedAt = null +``` + +### `RunSql` or `RunProcedure` is disabled + +That is expected in the default safe host posture unless the host explicitly +enables those rendered form actions. Use `RunCommand` for trusted host-owned C# +callbacks, or enable SQL/procedure actions only in a host that has reviewed the +risk. + +### A procedure will not save or run + +Check: + +- the procedure name is a simple identifier +- every `@parameter` referenced by Body SQL is listed in Parameters +- Args JSON contains every required parameter +- argument names match parameter names without the `@` prefix +- text values are JSON strings in Args JSON or single-quoted in `EXEC` +- the procedure is enabled + +Examples: + +```json +{ + "status": "released" +} +``` + +```sql +EXEC tutorial_OpenOrderSnapshot @status = 'released'; +``` + +### A callback is missing + +Open **Callbacks**, refresh the catalog, select the missing callback, and use +**Copy Stub**. Give the generated stub to the host developer who owns the +application. The fix is to register host-owned C# code with the same callback +name and compatible arity. + +### A callback is denied + +Review the capability grid and policy reason in the callback details. A denied +callback should not execute. The host policy must grant the requested capability +before the callback is considered ready. + +### The tutorial controls should be removed + +Open the `Order Workbench` designer and delete: + +- the label with text `Tutorial review mode is active.` +- the `Tutorial Review` button +- the `Clear Tutorial Filter` button +- action sequences whose names start with `tutorial_` + +Save the form and refresh Object Explorer. + +Also delete the disposable procedure: + +- `tutorial_OpenOrderSnapshot` + +## What You Built + +You used one copied fulfillment database to exercise the full current app-builder +story: + +- browsed relational operational tables +- inspected existing stored procedures and created a disposable read-only + procedure +- inspected paged JSON collections +- edited a disposable collection document +- used forms as Access-style data-entry surfaces +- added macro-style form action sequences +- used trusted host callbacks from saved metadata +- inspected callback readiness and generated missing registration stubs +- reviewed reports as operational validation surfaces +- kept executable code host-owned instead of database-owned + +That is the intended production posture for the current CSharpDB Admin +automation model. diff --git a/samples/trusted-csharp-host/README.md b/samples/trusted-csharp-host/README.md index 9253fa36..efa13529 100644 --- a/samples/trusted-csharp-host/README.md +++ b/samples/trusted-csharp-host/README.md @@ -32,6 +32,51 @@ only. 6. Inspect the generated starter C# registration stub. 7. Put breakpoints in `Slugify` or the `AuditCustomerChange` command callback. +## Developer Handoff Story + +The intended production workflow has two roles: + +1. An app builder creates metadata in Admin, such as a calculated expression + that calls `Slugify(...)` or a form action sequence that runs + `AuditCustomerChange`. +2. A host developer owns the C# implementation, registers that callback during + startup, and debugs it from VS Code. + +The database/form metadata stores callback names, argument values, and +reference locations. It does not store C# source or compiled assemblies. + +`Program.cs` shows both registration paths: + +```csharp +builder.AddScalar( + "Slugify", + arity: 1, + options: new DbScalarFunctionOptions( + ReturnType: DbType.Text, + IsDeterministic: true, + NullPropagating: true), + invoke: static (_, args) => DbValue.FromText(Slugify(args[0].AsText))); +``` + +```csharp +builder.AddCommand( + "AuditCustomerChange", + new DbCommandOptions("Records a customer workflow event."), + context => + { + long customerId = context.Arguments["Id"].AsInteger; + string status = context.Arguments["Status"].AsText; + + return DbCommandResult.Success( + $"Customer {customerId} changed to {status}."); + }); +``` + +When Admin reports a missing callback, use the generated stub as the handoff +artifact. The host developer pastes the registration shape into the host app, +replaces the stub body with reviewed C# code, sets a breakpoint, then runs the +host with `F5` to verify the metadata reference reaches the callback. + ## Run From Terminal ```powershell From a46321f298a06559bd84e22e7d9d3130ff9b9dce Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Fri, 1 May 2026 11:57:28 -0700 Subject: [PATCH 27/39] Add extensible admin form controls --- .../Components/Designer/DesignCanvas.razor | 377 +++++- .../Components/Designer/DesignerState.cs | 82 +- .../Components/Designer/FormRenderer.razor | 1021 ++++++++++++++++- .../Components/Designer/LayersPanel.razor | 32 +- .../Designer/PropertyInspector.razor | 959 +++++++++++++++- .../Components/Designer/Toolbox.razor | 120 +- .../Contracts/IFormControlRegistry.cs | 12 + .../Contracts/IFormRecordService.cs | 1 + .../Models/FormAttachmentTableBinding.cs | 11 + .../Models/FormAttachmentValue.cs | 18 + .../Models/FormControlContexts.cs | 34 + .../Models/FormControlDescriptor.cs | 80 ++ .../Pages/DataEntry.razor | 131 ++- .../AdminFormsServiceCollectionExtensions.cs | 29 + .../Services/BuiltInFormControlDescriptors.cs | 133 +++ .../Services/DbFormRecordService.cs | 45 + .../Services/DefaultFormControlRegistry.cs | 15 + .../Services/DefaultFormGenerator.cs | 1 + .../Services/FormChoiceResolver.cs | 268 +++++ .../Services/FormControlRegistry.cs | 33 + .../Services/FormControlRegistryBuilder.cs | 61 + .../FormControlRegistryConfiguration.cs | 12 + src/CSharpDB.Admin.Forms/Services/FormSql.cs | 2 +- .../wwwroot/css/designer.css | 434 ++++++- .../FormControls/RatingDesignerPreview.razor | 43 + .../RatingDesignerPreview.razor.css | 41 + .../FormControls/RatingPropertyEditor.razor | 72 ++ .../RatingPropertyEditor.razor.css | 4 + .../FormControls/RatingRuntimeControl.razor | 79 ++ .../RatingRuntimeControl.razor.css | 56 + .../SampleRatingControlRegistration.cs | 30 + src/CSharpDB.Admin/Program.cs | 3 + .../Internal/EngineTransportClient.cs | 2 +- .../Admin/SampleFormControlsTests.cs | 29 + .../Components/Designer/ChildDataGridTests.cs | 3 + .../Components/Designer/DesignerStateTests.cs | 105 ++ .../FormControlRegistryDesignerTests.cs | 305 +++++ .../FormControlRegistryRuntimeTests.cs | 255 ++++ .../FormRendererCommandButtonTests.cs | 104 ++ .../Pages/DataEntryTests.cs | 3 + .../CustomControlRoundtripTests.cs | 54 + .../Serialization/JsonRoundtripTests.cs | 150 +++ .../Services/DbFormRecordServiceTests.cs | 165 +++ .../Services/DefaultFormGeneratorTests.cs | 1 + .../Services/FormActionDiagnosticsTests.cs | 9 +- .../Services/FormChoiceResolverTests.cs | 72 ++ .../Services/FormControlRegistryTests.cs | 172 +++ 47 files changed, 5419 insertions(+), 249 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs create mode 100644 src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs create mode 100644 src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs create mode 100644 src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs create mode 100644 src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs create mode 100644 src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor.css create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor.css create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor.css create mode 100644 src/CSharpDB.Admin/Components/Samples/FormControls/SampleRatingControlRegistration.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Admin/SampleFormControlsTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryDesignerTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryRuntimeTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Serialization/CustomControlRoundtripTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Services/FormChoiceResolverTests.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Services/FormControlRegistryTests.cs diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor index 4a6d96b0..e07fa907 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignCanvas.razor @@ -1,5 +1,6 @@ @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Components.Designer +@using CSharpDB.Admin.Forms.Contracts @implements IDisposable
@@ -23,6 +24,7 @@ @foreach (var control in State.Controls) { + if (IsNestedControl(control)) continue; if (!State.IsVisibleAtBreakpoint(control)) continue; var isSelected = State.SelectedIds.Contains(control.ControlId); var c = control; // capture for lambda @@ -42,8 +44,15 @@ }
- @switch (c.ControlType) + @if (TryGetDesignerPreviewComponent(c, out Type? designerPreviewComponent)) { + + } + else + { + @switch (c.ControlType) + { case "label": @GetProp(c, "text", "Label") break; @@ -67,12 +76,31 @@ break; + case "comboBox": + + break; + case "listBox": + + break; case "lookup": var lkTable = GetProp(c, "lookupTable", ""); break; + case "optionGroup": +
+ ○ Option 1 + ○ Option 2 +
+ break; + case "toggleButton": + + break; case "computed": var formula = GetProp(c, "formula", ""); break; - default: - [@c.ControlType] + case "tabControl": + var tabPages = GetTabPages(c); + var visibleTabPages = tabPages.Count > 0 + ? tabPages + : new List<(string Id, string Label)> { ("tab1", "Page 1") }; + var activeTabPage = GetActiveDesignerTab(c, visibleTabPages); + var tabChildControls = GetTabChildControls(c.ControlId, activeTabPage.Id); +
+
+ + @(tabPages.Count == 0 ? "Tab Control (no pages)" : "Tab Control") +
+
+ @foreach (var tab in visibleTabPages) + { + var page = tab; + + } +
+
+ @if (tabChildControls.Count == 0) + { +
Assign child controls in the property inspector
+ } + else + { + @foreach (var child in tabChildControls) + { + var tabChild = child; + var childSelected = State.SelectedIds.Contains(tabChild.ControlId); +
+ @if (State.ShowTabOrder && IsTabOrderControl(tabChild)) + { + var tabIdx = GetTabIndex(tabChild); + @if (tabIdx > 0) + { +
@tabIdx
+ } + } + +
+ @if (TryGetDesignerPreviewComponent(tabChild, out Type? tabChildDesignerPreviewComponent)) + { + + } + else + { + @GetControlDisplayName(tabChild.ControlType) + @GetDesignControlTitle(tabChild) + } +
+ + @if (childSelected) + { + @foreach (var h in _handles) + { + var handle = h; +
+ } + } +
+ } + } +
+
+ break; + case "subform": + var formId = GetProp(c, "formId", ""); +
+
+ + @(string.IsNullOrWhiteSpace(formId) ? "Subform (not configured)" : $"Subform: {formId}") +
+
Embedded form
+
break; + case "attachment": +
+ + No file + +
+ break; + case "image": +
+ Image preview +
+ break; + default: + [@c.ControlType] + break; + } }
@@ -187,9 +317,11 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; private ElementReference _canvasRef; private static readonly string[] _handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; + private readonly Dictionary _activeDesignerTabs = new(StringComparer.Ordinal); // Marquee selection state private bool _isMarquee; @@ -225,6 +357,183 @@ return []; } + private static int GetIntProp(ControlDefinition c, string key, int fallback) + { + if (!c.Props.Values.TryGetValue(key, out var value) || value is null) + return fallback; + + if (value is int i) return i; + if (value is long l) return (int)l; + if (value is double d) return (int)d; + if (value is System.Text.Json.JsonElement je && je.TryGetInt32(out var ji)) return ji; + return int.TryParse(value.ToString(), out var parsed) ? parsed : fallback; + } + + private static bool IsNestedControl(ControlDefinition c) + => c.Props.Values.TryGetValue("parentControlId", out var value) + && !string.IsNullOrWhiteSpace(value?.ToString()); + + private static List<(string Id, string Label)> GetTabPages(ControlDefinition c) + { + if (!c.Props.Values.TryGetValue("tabs", out var value) || value is null) + return []; + + if (value is System.Text.Json.JsonElement json && json.ValueKind == System.Text.Json.JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(element => ReadTabPage(element)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToList(); + } + + if (value is IEnumerable items) + { + return items + .Select(item => ReadTabPage(item)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToList(); + } + + return []; + } + + private static (string Id, string Label)? ReadTabPage(object? value) + { + if (value is System.Text.Json.JsonElement json) + { + if (json.ValueKind != System.Text.Json.JsonValueKind.Object) + return null; + + var id = json.TryGetProperty("id", out var idValue) ? idValue.ToString() : ""; + var label = json.TryGetProperty("label", out var labelValue) ? labelValue.ToString() : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IReadOnlyDictionary readOnly) + { + var id = readOnly.TryGetValue("id", out var idValue) ? idValue?.ToString() ?? "" : ""; + var label = readOnly.TryGetValue("label", out var labelValue) ? labelValue?.ToString() ?? id : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IDictionary dictionary) + { + var id = dictionary.TryGetValue("id", out var idValue) ? idValue?.ToString() ?? "" : ""; + var label = dictionary.TryGetValue("label", out var labelValue) ? labelValue?.ToString() ?? id : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + return null; + } + + private (string Id, string Label) GetActiveDesignerTab(ControlDefinition control, IReadOnlyList<(string Id, string Label)> tabs) + { + if (tabs.Count == 0) + return ("tab1", "Page 1"); + + if (_activeDesignerTabs.TryGetValue(control.ControlId, out string? activeId)) + { + var active = tabs.FirstOrDefault(tab => string.Equals(tab.Id, activeId, StringComparison.Ordinal)); + if (!string.IsNullOrWhiteSpace(active.Id)) + return active; + } + + _activeDesignerTabs[control.ControlId] = tabs[0].Id; + return tabs[0]; + } + + private void SetActiveDesignerTab(string controlId, string tabId) + { + _activeDesignerTabs[controlId] = tabId; + StateHasChanged(); + } + + private static string GetPreviewTabClass(string activeTabId, string tabId) + => string.Equals(activeTabId, tabId, StringComparison.Ordinal) + ? "preview-childtabs-tab active" + : "preview-childtabs-tab"; + + private IReadOnlyList GetTabChildControls(string parentControlId, string tabId) + => State.Controls + .Where(control => State.IsVisibleAtBreakpoint(control) && IsTabChildControl(control, parentControlId, tabId)) + .ToList(); + + private static bool IsTabChildControl(ControlDefinition control, string parentControlId, string tabId) + => TryGetStringProp(control, "parentControlId", out string configuredParentId) + && string.Equals(configuredParentId, parentControlId, StringComparison.Ordinal) + && TryGetStringProp(control, "parentTabId", out string configuredTabId) + && string.Equals(configuredTabId, tabId, StringComparison.Ordinal); + + private static bool TryGetStringProp(ControlDefinition control, string key, out string value) + { + value = string.Empty; + if (!control.Props.Values.TryGetValue(key, out object? raw) || raw is null) + return false; + + if (raw is System.Text.Json.JsonElement json) + raw = json.ValueKind == System.Text.Json.JsonValueKind.String ? json.GetString() : json.ToString(); + + value = raw?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private string GetTabChildStyle(ControlDefinition control) + { + var rect = State.GetEffectiveRect(control); + return FormattableString.Invariant($"left: {rect.X}px; top: {rect.Y}px; width: {rect.Width}px; height: {rect.Height}px;"); + } + + private string GetDesignControlTitle(ControlDefinition control) + { + var fieldName = control.Binding?.FieldName; + return control.ControlType switch + { + "label" => GetProp(control, "text", "Label"), + "checkbox" => GetProp(control, "text", fieldName ?? "Checkbox"), + "commandButton" => GetProp(control, "text", "Button"), + "toggleButton" => GetProp(control, "text", fieldName ?? "Toggle"), + "datagrid" => GetProp(control, "childTable", "DataGrid"), + "subform" => GetProp(control, "formId", "Subform"), + _ when !string.IsNullOrWhiteSpace(fieldName) => $"{GetControlDisplayName(control.ControlType)}: {fieldName}", + _ => GetControlDisplayName(control.ControlType) + }; + } + + private FormControlDescriptor? GetControlDescriptor(string controlType) + => ControlRegistry.TryGetControl(controlType, out FormControlDescriptor descriptor) ? descriptor : null; + + private string GetControlDisplayName(string controlType) + => GetControlDescriptor(controlType)?.DisplayName ?? controlType; + + private bool TryGetDesignerPreviewComponent(ControlDefinition control, out Type componentType) + { + componentType = default!; + FormControlDescriptor? descriptor = GetControlDescriptor(control.ControlType); + if (descriptor?.DesignerPreviewComponentType is null) + return false; + + componentType = descriptor.DesignerPreviewComponentType; + return true; + } + + private Dictionary GetDesignerPreviewParameters(ControlDefinition control, bool isSelected, Rect effectiveRect) + { + FormControlDescriptor descriptor = GetControlDescriptor(control.ControlType) + ?? new FormControlDescriptor + { + ControlType = control.ControlType, + DisplayName = control.ControlType, + ShowInToolbox = false, + }; + + return new Dictionary + { + ["Context"] = new FormControlDesignContext(control, State, isSelected, effectiveRect, descriptor), + }; + } + private void OnCanvasPointerDown(PointerEventArgs e) { if (e.Button != 0) return; @@ -253,62 +562,17 @@ var y = State.Snap(e.OffsetY); var controlType = State.ActiveTool!; - var (width, height) = controlType switch - { - "label" => (180.0, 34.0), - "checkbox" => (200.0, 34.0), - "textarea" => (320.0, 80.0), - "radio" => (200.0, 80.0), - "datagrid" => (560.0, 200.0), - "childtabs" => (600.0, 280.0), - "commandButton" => (160.0, 34.0), - _ => (320.0, 34.0) - }; - - var props = new Dictionary(); - if (controlType == "label") - props["text"] = "Label"; - if (controlType == "checkbox") - props["text"] = "Checkbox"; - if (controlType == "datagrid") - { - props["dataGridMode"] = "standalone"; - props["childTable"] = ""; - props["foreignKeyField"] = ""; - props["parentKeyField"] = ""; - props["foreignKeyName"] = ""; - props["visibleColumns"] = Array.Empty(); - props["allowAdd"] = true; - props["allowDelete"] = true; - props["allowEdit"] = true; - } - if (controlType == "childtabs") - { - props["tabs"] = Array.Empty(); - } - if (controlType == "lookup") - { - props["lookupTable"] = ""; - props["displayField"] = ""; - props["valueField"] = ""; - props["placeholder"] = "-- Select --"; - } - if (controlType == "computed") - { - props["formula"] = ""; - props["format"] = ""; - } - if (controlType == "commandButton") - { - props["text"] = "Button"; - props["commandName"] = ""; - } + FormControlDescriptor? descriptor = GetControlDescriptor(controlType); + var (width, height) = descriptor is null + ? (320.0, 34.0) + : (descriptor.DefaultWidth, descriptor.DefaultHeight); + Dictionary props = descriptor?.CreateDefaultProps() ?? []; var control = new ControlDefinition( ControlId: Guid.NewGuid().ToString("N"), ControlType: controlType, Rect: new Rect(x, y, width, height), - Binding: (controlType is "label" or "datagrid" or "childtabs" or "commandButton") ? null : new BindingDefinition("", "TwoWay"), + Binding: (descriptor?.SupportsBinding ?? true) ? new BindingDefinition("", "TwoWay") : null, Props: new PropertyBag(props), ValidationOverride: null); @@ -428,8 +692,9 @@ return 0; } - private static bool IsTabOrderControl(ControlDefinition c) - => c.ControlType is not ("label" or "datagrid" or "childtabs"); + private bool IsTabOrderControl(ControlDefinition c) + => GetControlDescriptor(c.ControlType)?.ParticipatesInTabOrder + ?? (c.ControlType is not ("label" or "datagrid" or "childtabs" or "tabControl" or "subform")); private void ApplyResize(PointerEventArgs e) { diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 1f9c4247..3ce67bda 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -211,7 +211,23 @@ public void DeleteSelected() { if (SelectedIds.Count == 0) return; PushUndo(); - _controls.RemoveAll(c => SelectedIds.Contains(c.ControlId)); + var idsToDelete = new HashSet(SelectedIds, StringComparer.Ordinal); + bool added; + do + { + added = false; + foreach (var child in _controls) + { + if (TryGetParentControlId(child, out string parentControlId) && + idsToDelete.Contains(parentControlId) && + idsToDelete.Add(child.ControlId)) + { + added = true; + } + } + } while (added); + + _controls.RemoveAll(c => idsToDelete.Contains(c.ControlId)); SelectedIds.Clear(); NotifyChanged(); } @@ -242,6 +258,22 @@ public void UpdateControlProp(string controlId, string key, object? value) NotifyChanged(); } + public void UpdateControlProps(string controlId, IReadOnlyDictionary values) + { + if (values.Count == 0) return; + var idx = _controls.FindIndex(c => c.ControlId == controlId); + if (idx < 0) return; + PushUndo(); + var c = _controls[idx]; + var newValues = new Dictionary(c.Props.Values); + + foreach (var (key, value) in values) + newValues[key] = value; + + _controls[idx] = c with { Props = new PropertyBag(newValues) }; + NotifyChanged(); + } + public void UpdateControlType(string controlId, string newType) { var idx = _controls.FindIndex(c => c.ControlId == controlId); @@ -326,8 +358,24 @@ public void MoveToIndex(string controlId, int newIndex) public void CopySelected() { + var idsToCopy = new HashSet(SelectedIds, StringComparer.Ordinal); + bool added; + do + { + added = false; + foreach (var child in _controls) + { + if (TryGetParentControlId(child, out string parentControlId) && + idsToCopy.Contains(parentControlId) && + idsToCopy.Add(child.ControlId)) + { + added = true; + } + } + } while (added); + _clipboard = _controls - .Where(c => SelectedIds.Contains(c.ControlId)) + .Where(c => idsToCopy.Contains(c.ControlId)) .ToList(); } @@ -337,12 +385,25 @@ public void PasteClipboard() PushUndo(); SelectedIds.Clear(); + var idMap = _clipboard.ToDictionary( + control => control.ControlId, + _ => Guid.NewGuid().ToString("N"), + StringComparer.Ordinal); + foreach (var original in _clipboard) { + var props = new Dictionary(original.Props.Values); + if (TryGetParentControlId(original, out string parentControlId) && + idMap.TryGetValue(parentControlId, out string? pastedParentId)) + { + props["parentControlId"] = pastedParentId; + } + var pasted = original with { - ControlId = Guid.NewGuid().ToString("N"), - Rect = original.Rect with { X = original.Rect.X + 16, Y = original.Rect.Y + 16 } + ControlId = idMap[original.ControlId], + Rect = original.Rect with { X = original.Rect.X + 16, Y = original.Rect.Y + 16 }, + Props = new PropertyBag(props) }; _controls.Add(pasted); SelectedIds.Add(pasted.ControlId); @@ -436,6 +497,19 @@ private void MoveControlInternal(string controlId, double x, double y) _controls[idx] = c with { Rect = c.Rect with { X = x, Y = y } }; } + private static bool TryGetParentControlId(ControlDefinition control, out string parentControlId) + { + parentControlId = string.Empty; + if (!control.Props.Values.TryGetValue("parentControlId", out object? value) || value is null) + return false; + + if (value is System.Text.Json.JsonElement json) + value = json.ValueKind == System.Text.Json.JsonValueKind.String ? json.GetString() : json.ToString(); + + parentControlId = value?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(parentControlId); + } + // ===== Responsive Breakpoints ===== public double GetCanvasWidth() diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 2ce85520..06385366 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -2,11 +2,13 @@ @using System.Text.Json @using CSharpDB.Admin.Forms.Contracts @using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Pages +@using CSharpDB.Admin.Forms.Services @using CSharpDB.Primitives @inject DbCommandRegistry Commands
- @foreach (var control in Form.Controls) + @foreach (var control in GetControlsToRender()) { var c = control; var fieldName = c.Binding?.FieldName; @@ -15,8 +17,15 @@ var errorMsg = hasError ? ValidationErrors![fieldName!] : null; var inputClass = hasError ? "fr-input fr-field-error" : "fr-input";
- @switch (c.ControlType) + @if (TryGetRuntimeComponent(c, out Type? runtimeComponent)) { + + } + else + { + @switch (c.ControlType) + { case "label": @GetProp(c, "text", "Label") @@ -69,38 +78,73 @@ break; case "select": + var selectChoices = GetControlChoices(c, fieldName); break; case "lookup": + var lookupChoices = GetControlChoices(c, fieldName); + break; + case "comboBox": + var comboChoices = GetControlChoices(c, fieldName); + var comboListId = $"combo_{c.ControlId}"; + + + @foreach (var ch in comboChoices) + { + + } + + break; + case "listBox": + var listChoices = GetControlChoices(c, fieldName); + var listMultiSelect = IsListBoxMultiSelect(c); + break; @@ -116,7 +160,8 @@ @onchange="@(e => SetFieldValueAsync(c, fieldName, e.Value))"> break; case "radio": - @if (Choices?.TryGetValue(fieldName ?? "", out var radioChoices) == true && radioChoices is not null) + var radioChoices = GetControlChoices(c, fieldName); + @if (radioChoices.Count > 0) {
@foreach (var ch in radioChoices) @@ -138,6 +183,42 @@
} break; + case "optionGroup": + var optionChoices = GetControlChoices(c, fieldName); + var orientation = GetProp(c, "orientation", "vertical"); + var buttonStyle = GetBoolProp(c, "buttonStyle") || string.Equals(GetProp(c, "buttonStyle", ""), "buttons", StringComparison.OrdinalIgnoreCase); +
+ @foreach (var ch in optionChoices) + { + var val = ch.Value; + + } +
+ break; + case "toggleButton": + var toggleOn = IsToggleButtonOn(c, fieldName); + + break; case "computed": var computedValue = GetFieldValue(fieldName); var computedFormat = GetProp(c, "format", ""); @@ -164,6 +245,130 @@ @GetProp(c, "text", "Button") break; + case "tabControl": + var tabPages = GetTabPages(c); + @if (tabPages.Count == 0) + { +
Tab control has no pages.
+ } + else + { + var activeTab = GetActiveTabPage(c, tabPages); + var childForm = BuildTabChildForm(c, activeTab.Id); +
+
+ @foreach (var tab in tabPages) + { + var page = tab; + + } +
+
+ @if (childForm.Controls.Count == 0) + { +
No controls on this tab.
+ } + else + { + + } +
+
+ } + break; + case "subform": + var subformId = GetProp(c, "formId", ""); + var subformParentField = GetProp(c, "parentKeyField", ""); + var subformForeignField = GetProp(c, "foreignKeyField", ""); + var subformParentValue = GetFieldObjectValue(subformParentField); + @if (string.IsNullOrWhiteSpace(subformId) || + string.IsNullOrWhiteSpace(subformParentField) || + string.IsNullOrWhiteSpace(subformForeignField)) + { +
Subform not configured.
+ } + else if (subformParentValue is null || string.IsNullOrWhiteSpace(subformParentValue.ToString())) + { +
Save the parent record first to see child records.
+ } + else + { +
+ +
+ } + break; + case "attachment": +
+
@GetBlobSummary(c, fieldName)
+
+ + +
+
+ break; + case "image": + var imageSrc = GetImageDataUrl(c, fieldName); +
+ @if (!string.IsNullOrWhiteSpace(imageSrc)) + { + @GetProp(c, + } + else + { +
No image
+ } +
+ + +
+
+ break; case "datagrid": var dgMode = GetProp(c, "dataGridMode", "related"); var dgIsStandalone = string.Equals(dgMode, "standalone", StringComparison.OrdinalIgnoreCase); @@ -233,9 +438,10 @@
Child Tabs not configured.
} break; - default: - [@c.ControlType] - break; + default: + [@c.ControlType] + break; + } } @if (errorMsg is not null) { @@ -260,15 +466,80 @@ [Parameter] public IFormActionRuntime? ActionRuntime { get; set; } [Parameter] public IReadOnlyDictionary>? ControlPropertyOverrides { get; set; } [Parameter] public IReadOnlyDictionary? ControlFilters { get; set; } + [Parameter] public bool RenderAllControls { get; set; } + [Parameter] public double AnchorCanvasWidth { get; set; } = DesktopCanvasWidth; + [Parameter] public double? AnchorCanvasHeight { get; set; } + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; private readonly HashSet _executingCommandButtons = []; + private readonly Dictionary _activeTabs = new(StringComparer.Ordinal); private const string LayoutModeElastic = "elastic"; private const double DesktopCanvasWidth = 1200; private const double TabletCanvasWidth = 768; + private const long DefaultMaxUploadBytes = 10 * 1024 * 1024; + + private bool TryGetRuntimeComponent(ControlDefinition control, out Type componentType) + { + componentType = default!; + if (!ControlRegistry.TryGetControl(control.ControlType, out FormControlDescriptor descriptor) || + descriptor.RuntimeComponentType is null) + { + return false; + } + + if (descriptor.IsBuiltIn && !descriptor.ReplaceBuiltInRuntime) + return false; + + componentType = descriptor.RuntimeComponentType; + return true; + } + + private Dictionary GetRuntimeComponentParameters( + ControlDefinition control, + string? fieldName, + string? validationError, + int tabIndex) + { + FormControlDescriptor descriptor = ControlRegistry.TryGetControl(control.ControlType, out FormControlDescriptor registered) + ? registered + : new FormControlDescriptor + { + ControlType = control.ControlType, + DisplayName = control.ControlType, + ShowInToolbox = false, + }; + + return new Dictionary + { + ["Context"] = new FormControlRuntimeContext( + Form, + control, + descriptor, + TableDefinition, + Record, + fieldName, + GetFieldObjectValue(fieldName), + GetControlChoices(control, fieldName), + IsControlEnabled(control), + IsControlReadOnly(control), + validationError, + tabIndex, + value => SetFieldValueAsync(control, fieldName, value), + (eventKind, arguments) => InvokeControlEventsAsync(control, eventKind, arguments)), + }; + } private bool IsElasticLayout => string.Equals(Form.Layout.LayoutMode, LayoutModeElastic, StringComparison.OrdinalIgnoreCase); + private IEnumerable GetControlsToRender() + => RenderAllControls + ? Form.Controls + : Form.Controls.Where(IsTopLevelControl); + + private static bool IsTopLevelControl(ControlDefinition control) + => !FormChoiceResolver.TryGetString(control.Props.Values, "parentControlId", out _); + private string GetRendererClass() => IsElasticLayout ? "form-renderer form-renderer-elastic" @@ -296,22 +567,92 @@ int desktopSpan = GetGridSpan(desktop, 12, DesktopCanvasWidth); int tabletSpan = GetGridSpan(tablet, 8, TabletCanvasWidth); + ControlAnchors anchors = GetControlAnchors(c); + double canvasWidth = GetAnchorCanvasWidth(); + double canvasHeight = GetAnchorCanvasHeight(); + + string resizeMode = GetProp(c, "resizeMode", "anchor"); + string left; + string right; + string width; + string top; + string bottom; + string height; + + if (string.Equals(resizeMode, "scale", StringComparison.OrdinalIgnoreCase)) + { + left = ToCssPercent(desktop.X, canvasWidth); + right = "auto"; + width = ToCssPercent(desktop.Width, canvasWidth); + top = ToCssPercent(desktop.Y, canvasHeight); + bottom = "auto"; + height = ToCssPercent(desktop.Height, canvasHeight); + } + else + { + left = anchors.Left || !anchors.Right ? ToCssPx(desktop.X) : "auto"; + right = anchors.Right ? ToCssPx(Math.Max(0, canvasWidth - desktop.X - desktop.Width)) : "auto"; + width = anchors.Left && anchors.Right ? "auto" : ToCssPx(desktop.Width); + + top = anchors.Top || !anchors.Bottom ? ToCssPx(desktop.Y) : "auto"; + bottom = anchors.Bottom ? ToCssPx(Math.Max(0, canvasHeight - desktop.Y - desktop.Height)) : "auto"; + height = anchors.Top && anchors.Bottom ? "auto" : ToCssPx(desktop.Height); + } + + double minWidth = Math.Max(0, GetDoubleProp(c, "minWidth", 24)); + double minHeight = Math.Max(0, GetDoubleProp(c, "minHeight", 16)); + double elasticMinHeight = anchors.Top && anchors.Bottom ? minHeight : Math.Max(minHeight, desktop.Height); string visibilityStyle = IsControlVisible(c) ? string.Empty : " display: none;"; return FormattableString.Invariant( - $"--fr-left: {desktop.X}px; --fr-top: {desktop.Y}px; --fr-width: {desktop.Width}px; --fr-height: {desktop.Height}px; --fr-stack-order: {GetStackOrder(c)}; --fr-grid-span: {desktopSpan}; --fr-tablet-span: {tabletSpan};{visibilityStyle}"); + $"--fr-left: {left}; --fr-top: {top}; --fr-right: {right}; --fr-bottom: {bottom}; --fr-width: {width}; --fr-height: {height}; --fr-min-width: {minWidth}px; --fr-min-height: {minHeight}px; --fr-elastic-min-height: {elasticMinHeight}px; --fr-stack-order: {GetStackOrder(c)}; --fr-grid-span: {desktopSpan}; --fr-tablet-span: {tabletSpan};{visibilityStyle}"); + } + + private readonly record struct ControlAnchors(bool Left, bool Top, bool Right, bool Bottom); + + private ControlAnchors GetControlAnchors(ControlDefinition c) + { + bool left = GetBoolProp(c, "anchorLeft", fallback: true); + bool top = GetBoolProp(c, "anchorTop", fallback: true); + bool right = GetBoolProp(c, "anchorRight", fallback: false); + bool bottom = GetBoolProp(c, "anchorBottom", fallback: false); + + if (!left && !right) + left = true; + if (!top && !bottom) + top = true; + + return new ControlAnchors(left, top, right, bottom); + } + + private double GetAnchorCanvasWidth() + => AnchorCanvasWidth > 0 ? AnchorCanvasWidth : DesktopCanvasWidth; + + private double GetAnchorCanvasHeight() + => AnchorCanvasHeight is > 0 ? AnchorCanvasHeight.Value : GetCanvasHeight(); + + private static string ToCssPx(double value) + => FormattableString.Invariant($"{value}px"); + + private static string ToCssPercent(double value, double total) + { + if (total <= 0) + return "0%"; + + return FormattableString.Invariant($"{value / total * 100}%"); } private double GetCanvasHeight() { - double bottom = Form.Controls.Count == 0 + ControlDefinition[] controls = GetControlsToRender().ToArray(); + double bottom = controls.Length == 0 ? 500 - : Form.Controls.Max(control => control.Rect.Y + control.Rect.Height) + 24; + : controls.Max(control => control.Rect.Y + control.Rect.Height) + 24; return Math.Max(500, bottom); } private int GetStackOrder(ControlDefinition c) { - var ordered = Form.Controls + var ordered = GetControlsToRender() .Select((control, index) => new { Control = control, @@ -454,12 +795,232 @@ private bool IsRadioChoiceSelected(string? fieldName, string? choiceValue) => FormControlValueConverter.ChoiceMatchesValue(GetFieldObjectValue(fieldName), choiceValue, GetFieldDefinition(fieldName)); + private IReadOnlyList GetControlChoices(ControlDefinition control, string? fieldName) + => FormChoiceResolver.ResolveChoices(control, fieldName, Choices, GetFieldDefinition(fieldName)); + + private string GetSelectedChoiceValue(string? fieldName, IReadOnlyList choices) + { + object? value = GetFieldObjectValue(fieldName); + if (value is null) + return string.Empty; + + EnumChoice? selected = choices.FirstOrDefault(choice => + FormControlValueConverter.ChoiceMatchesValue(value, choice.Value, GetFieldDefinition(fieldName))); + return selected?.Value ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + + private string GetComboDisplayValue(string? fieldName, IReadOnlyList choices) + { + string selectedValue = GetSelectedChoiceValue(fieldName, choices); + if (string.IsNullOrWhiteSpace(selectedValue)) + return GetFieldValue(fieldName); + + return choices.FirstOrDefault(choice => string.Equals(choice.Value, selectedValue, StringComparison.OrdinalIgnoreCase))?.Label + ?? selectedValue; + } + private Task SetCheckboxFieldValueAsync(ControlDefinition control, string? fieldName, bool isChecked) => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertCheckboxValue(isChecked, GetFieldDefinition(fieldName))); private Task SetRadioFieldValueAsync(ControlDefinition control, string? fieldName, string? choiceValue) => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue, GetFieldDefinition(fieldName))); + private Task SetChoiceFieldValueAsync(ControlDefinition control, string? fieldName, object? choiceValue) + => SetFieldValueAsync(control, fieldName, FormControlValueConverter.ConvertChoiceValue(choiceValue?.ToString(), GetFieldDefinition(fieldName))); + + private bool IsListBoxMultiSelect(ControlDefinition control) + => GetBoolProp(control, "multiSelect"); + + private bool IsListBoxChoiceSelected(ControlDefinition control, string? fieldName, string? choiceValue, IReadOnlyList choices) + { + if (!IsListBoxMultiSelect(control)) + return FormControlValueConverter.ChoiceMatchesValue(GetFieldObjectValue(fieldName), choiceValue, GetFieldDefinition(fieldName)); + + FormFieldDefinition? field = GetFieldDefinition(fieldName); + return GetSelectedListBoxValues(control, fieldName, choices) + .Any(selected => FormControlValueConverter.ChoiceMatchesValue(selected, choiceValue, field)); + } + + private Task SetListBoxValueAsync(ControlDefinition control, string? fieldName, object? rawValue, IReadOnlyList choices) + { + if (!IsListBoxMultiSelect(control)) + return SetChoiceFieldValueAsync(control, fieldName, rawValue); + + IReadOnlyList selectedValues = ReadSelectedListBoxValues(rawValue); + object? storedValue = selectedValues.Count == 0 + ? null + : string.Join(GetListBoxValueDelimiter(control), selectedValues); + + return SetFieldValueAsync(control, fieldName, storedValue); + } + + private IReadOnlyList GetSelectedListBoxValues(ControlDefinition control, string? fieldName, IReadOnlyList choices) + { + object? value = GetFieldObjectValue(fieldName); + if (value is null) + return []; + + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString()) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } + + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is string text) + { + string delimiter = GetListBoxValueDelimiter(control); + return text + .Split([delimiter], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } + + if (value is System.Collections.IEnumerable values && value is not byte[]) + { + return values + .Cast() + .Select(item => Convert.ToString(item, CultureInfo.InvariantCulture)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } + + string selectedValue = GetSelectedChoiceValue(fieldName, choices); + return string.IsNullOrWhiteSpace(selectedValue) ? [] : [selectedValue]; + } + + private static IReadOnlyList ReadSelectedListBoxValues(object? rawValue) + { + if (rawValue is null) + return []; + + if (rawValue is string text) + return string.IsNullOrWhiteSpace(text) ? [] : [text]; + + if (rawValue is string[] textValues) + return textValues.Where(value => !string.IsNullOrWhiteSpace(value)).ToArray(); + + if (rawValue is IEnumerable enumerableTextValues) + return enumerableTextValues.Where(value => !string.IsNullOrWhiteSpace(value)).ToArray(); + + if (rawValue is System.Collections.IEnumerable enumerable) + { + return enumerable + .Cast() + .Select(item => Convert.ToString(item, CultureInfo.InvariantCulture)) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .Select(item => item!) + .ToArray(); + } + + string converted = Convert.ToString(rawValue, CultureInfo.InvariantCulture) ?? string.Empty; + return string.IsNullOrWhiteSpace(converted) ? [] : [converted]; + } + + private string GetListBoxValueDelimiter(ControlDefinition control) + { + string delimiter = GetProp(control, "multiValueDelimiter", ";"); + return string.IsNullOrEmpty(delimiter) ? ";" : delimiter; + } + + private Task SetComboBoxValueAsync(ControlDefinition control, string? fieldName, object? inputValue, IReadOnlyList choices) + { + string raw = inputValue?.ToString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(raw)) + return SetFieldValueAsync(control, fieldName, null); + + EnumChoice? choice = choices.FirstOrDefault(candidate => + string.Equals(candidate.Label, raw, StringComparison.OrdinalIgnoreCase) || + string.Equals(candidate.Value, raw, StringComparison.OrdinalIgnoreCase)); + + if (choice is not null) + return SetChoiceFieldValueAsync(control, fieldName, choice.Value); + + bool allowCustomValue = GetBoolProp(control, "allowCustomValue"); + return allowCustomValue + ? SetChoiceFieldValueAsync(control, fieldName, raw) + : Task.CompletedTask; + } + + private static string GetOptionGroupClass(string orientation, bool buttonStyle) + { + List classes = ["fr-option-group"]; + classes.Add(string.Equals(orientation, "horizontal", StringComparison.OrdinalIgnoreCase) + ? "fr-option-horizontal" + : "fr-option-vertical"); + if (buttonStyle) + classes.Add("fr-option-buttons"); + return string.Join(" ", classes); + } + + private bool IsToggleButtonOn(ControlDefinition control, string? fieldName) + { + object? value = GetFieldObjectValue(fieldName); + object? trueValue = GetConfiguredToggleValue(control, "trueValue", true); + return ValuesMatch(value, trueValue, GetFieldDefinition(fieldName)); + } + + private string GetToggleButtonClass(bool isOn) + => isOn ? "fr-toggle-button active" : "fr-toggle-button"; + + private string GetToggleButtonText(ControlDefinition control, string? fieldName, bool isOn) + { + string explicitText = GetProp(control, "text", string.Empty); + if (!string.IsNullOrWhiteSpace(explicitText)) + return explicitText; + + return isOn + ? GetProp(control, "onText", fieldName ?? "On") + : GetProp(control, "offText", fieldName ?? "Off"); + } + + private Task ToggleButtonValueAsync(ControlDefinition control, string? fieldName) + { + bool isOn = IsToggleButtonOn(control, fieldName); + object? next = GetConfiguredToggleValue(control, isOn ? "falseValue" : "trueValue", isOn ? false : true); + FormFieldDefinition? field = GetFieldDefinition(fieldName); + object? converted = field?.DataType == FieldDataType.Boolean + ? FormControlValueConverter.ConvertCheckboxValue(FormControlValueConverter.ToBoolean(next), field) + : FormControlValueConverter.ConvertChoiceValue(next?.ToString(), field); + return SetFieldValueAsync(control, fieldName, converted); + } + + private static object? GetConfiguredToggleValue(ControlDefinition control, string key, object fallback) + { + if (!control.Props.Values.TryGetValue(key, out object? value) || value is null) + return fallback; + + if (value is JsonElement json) + return json.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => json.GetString(), + JsonValueKind.Number when json.TryGetInt64(out long integer) => integer, + JsonValueKind.Number => json.GetDouble(), + _ => json.ToString(), + }; + + return value; + } + + private static bool ValuesMatch(object? value, object? configuredValue, FormFieldDefinition? field) + { + if (configuredValue is bool boolValue) + return FormControlValueConverter.ToBoolean(value) == boolValue; + + return FormControlValueConverter.ChoiceMatchesValue(value, configuredValue?.ToString(), field); + } + private async Task SetFieldValueAsync(ControlDefinition control, string? fieldName, object? value) { if (fieldName is null) return; @@ -473,6 +1034,211 @@ await OnFieldChanged.InvokeAsync(fieldName); } + private async Task OnBlobFileChangedAsync(ControlDefinition control, string? fieldName, InputFileChangeEventArgs args) + { + if ((!IsAttachmentTableMode(control) && string.IsNullOrWhiteSpace(fieldName)) || !IsControlEnabled(control) || IsControlReadOnly(control)) + return; + + IBrowserFile file = args.File; + long maxBytes = GetLongProp(control, "maxFileBytes", DefaultMaxUploadBytes); + await using Stream stream = file.OpenReadStream(maxBytes); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + string eventFieldName = fieldName ?? FormAttachmentValue.GetRecordKey(control.ControlId); + + object? oldValue = IsAttachmentTableMode(control) + ? GetPendingAttachmentValue(control) + : GetFieldObjectValue(fieldName); + await BeforeFieldChanged.InvokeAsync(eventFieldName); + + if (IsAttachmentTableMode(control)) + { + Record[FormAttachmentValue.GetRecordKey(control.ControlId)] = FormAttachmentValue.FromFile( + memory.ToArray(), + file.Name, + file.ContentType, + file.Size); + } + else + { + Record[fieldName!] = memory.ToArray(); + await SetRelatedFieldValueAsync(control, "fileNameField", file.Name); + await SetRelatedFieldValueAsync(control, "contentTypeField", file.ContentType); + await SetRelatedFieldValueAsync(control, "fileSizeField", file.Size); + } + + object? newValue = IsAttachmentTableMode(control) + ? GetPendingAttachmentValue(control) + : Record[fieldName!]; + var runtimeArguments = BuildFieldEventArguments(control, ControlEventKind.OnChange, eventFieldName, newValue, oldValue); + runtimeArguments["fileName"] = file.Name; + runtimeArguments["contentType"] = file.ContentType; + runtimeArguments["fileSize"] = file.Size; + + await InvokeControlEventsAsync(control, ControlEventKind.OnChange, runtimeArguments); + await OnFieldChanged.InvokeAsync(eventFieldName); + } + + private async Task ClearBlobFieldAsync(ControlDefinition control, string? fieldName) + { + if ((!IsAttachmentTableMode(control) && string.IsNullOrWhiteSpace(fieldName)) || !CanClearBlob(control, fieldName)) + return; + + string eventFieldName = fieldName ?? FormAttachmentValue.GetRecordKey(control.ControlId); + object? oldValue = IsAttachmentTableMode(control) + ? GetPendingAttachmentValue(control) + : GetFieldObjectValue(fieldName); + await BeforeFieldChanged.InvokeAsync(eventFieldName); + + if (IsAttachmentTableMode(control)) + { + Record[FormAttachmentValue.GetRecordKey(control.ControlId)] = FormAttachmentValue.Clear(); + } + else + { + Record[fieldName!] = null; + await SetRelatedFieldValueAsync(control, "fileNameField", null); + await SetRelatedFieldValueAsync(control, "contentTypeField", null); + await SetRelatedFieldValueAsync(control, "fileSizeField", null); + } + + await InvokeControlEventsAsync( + control, + ControlEventKind.OnChange, + BuildFieldEventArguments(control, ControlEventKind.OnChange, eventFieldName, IsAttachmentTableMode(control) ? GetPendingAttachmentValue(control) : null, oldValue)); + await OnFieldChanged.InvokeAsync(eventFieldName); + } + + private Task SetRelatedFieldValueAsync(ControlDefinition control, string metadataProperty, object? value) + { + string fieldName = GetProp(control, metadataProperty, string.Empty); + if (string.IsNullOrWhiteSpace(fieldName)) + return Task.CompletedTask; + + Record[fieldName] = value; + return OnFieldChanged.InvokeAsync(fieldName); + } + + private bool CanClearBlob(ControlDefinition control, string? fieldName) + => IsControlEnabled(control) && + !IsControlReadOnly(control) && + (IsAttachmentTableMode(control) || GetBlobBytes(fieldName) is { Length: > 0 }); + + private string GetBlobSummary(ControlDefinition control, string? fieldName) + { + if (IsAttachmentTableMode(control)) + { + FormAttachmentValue? pending = GetPendingAttachmentValue(control); + if (pending?.ClearExisting == true) + return "Attachment will be cleared"; + if (pending?.Bytes is { Length: > 0 } pendingBytes) + { + string pendingSize = FormatByteSize(pendingBytes.LongLength); + return string.IsNullOrWhiteSpace(pending.FileName) + ? $"Pending upload ({pendingSize})" + : $"Pending upload: {pending.FileName} ({pendingSize})"; + } + + return "Attachment table"; + } + + byte[]? bytes = GetBlobBytes(fieldName); + if (bytes is null || bytes.Length == 0) + return "No file"; + + string fileNameField = GetProp(control, "fileNameField", string.Empty); + string fileName = string.IsNullOrWhiteSpace(fileNameField) ? string.Empty : GetFieldValue(fileNameField); + string size = FormatByteSize(bytes.LongLength); + return string.IsNullOrWhiteSpace(fileName) + ? size + : $"{fileName} ({size})"; + } + + private byte[]? GetBlobBytes(string? fieldName) + { + object? value = GetFieldObjectValue(fieldName); + return value switch + { + byte[] bytes => bytes, + JsonElement json when json.ValueKind == JsonValueKind.String && TryReadBase64(json.GetString(), out byte[]? bytes) => bytes, + string text when TryReadBase64(text, out byte[]? bytes) => bytes, + _ => null, + }; + } + + private string? GetImageDataUrl(ControlDefinition control, string? fieldName) + { + if (IsAttachmentTableMode(control) && GetPendingAttachmentValue(control)?.Bytes is { Length: > 0 } pendingBytes) + { + string pendingContentType = GetPendingAttachmentValue(control)?.ContentType ?? "image/png"; + if (string.IsNullOrWhiteSpace(pendingContentType)) + pendingContentType = "image/png"; + + return $"data:{pendingContentType};base64,{Convert.ToBase64String(pendingBytes)}"; + } + + byte[]? bytes = GetBlobBytes(fieldName); + if (bytes is null || bytes.Length == 0) + return null; + + string contentTypeField = GetProp(control, "contentTypeField", string.Empty); + string contentType = string.IsNullOrWhiteSpace(contentTypeField) ? string.Empty : GetFieldValue(contentTypeField); + if (string.IsNullOrWhiteSpace(contentType)) + contentType = "image/png"; + + return $"data:{contentType};base64,{Convert.ToBase64String(bytes)}"; + } + + private bool IsAttachmentTableMode(ControlDefinition control) + => string.Equals(GetProp(control, "storageMode", "blobField"), "attachmentTable", StringComparison.OrdinalIgnoreCase); + + private FormAttachmentValue? GetPendingAttachmentValue(ControlDefinition control) + => Record.TryGetValue(FormAttachmentValue.GetRecordKey(control.ControlId), out object? value) && value is FormAttachmentValue attachment + ? attachment + : null; + + private static bool TryReadBase64(string? value, out byte[]? bytes) + { + bytes = null; + if (string.IsNullOrWhiteSpace(value)) + return false; + + try + { + bytes = Convert.FromBase64String(value); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static string FormatByteSize(long bytes) + { + string[] units = ["B", "KB", "MB", "GB"]; + double size = bytes; + int unit = 0; + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + + return unit == 0 + ? $"{bytes} {units[unit]}" + : $"{size:0.#} {units[unit]}"; + } + + private string GetImageStyle(ControlDefinition control) + { + string fit = GetProp(control, "fit", "contain"); + if (fit is not ("contain" or "cover" or "fill" or "scale-down")) + fit = "contain"; + + return $"object-fit: {fit};"; + } + private async Task InvokeCommandButtonAsync(ControlDefinition control) { string commandName = GetProp(control, "commandName", string.Empty); @@ -677,11 +1443,165 @@ await OnFieldChanged.InvokeAsync(key); } + private IReadOnlyList GetTabPages(ControlDefinition control) + { + if (!control.Props.Values.TryGetValue("tabs", out object? value) || value is null) + return []; + + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(element => ReadTabPage(element)) + .Where(tab => tab is not null) + .Select(tab => tab!) + .ToArray(); + } + + if (value is IEnumerable items) + { + return items + .Select(item => ReadTabPage(item)) + .Where(tab => tab is not null) + .Select(tab => tab!) + .ToArray(); + } + + return []; + } + + private static TabPageInfo? ReadTabPage(object? value) + { + if (value is null) + return null; + + if (value is JsonElement json) + { + if (json.ValueKind != JsonValueKind.Object) + return null; + + string id = json.TryGetProperty("id", out JsonElement idElement) ? ReadJsonText(idElement) : string.Empty; + string label = json.TryGetProperty("label", out JsonElement labelElement) ? ReadJsonText(labelElement) : id; + return string.IsNullOrWhiteSpace(id) ? null : new TabPageInfo(id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IReadOnlyDictionary readOnly) + return ReadTabPage(readOnly); + + if (value is IDictionary dictionary) + return ReadTabPage(dictionary); + + return null; + } + + private static TabPageInfo? ReadTabPage(IReadOnlyDictionary dictionary) + { + string id = ReadDictionaryText(dictionary, "id"); + if (string.IsNullOrWhiteSpace(id)) + return null; + + string label = ReadDictionaryText(dictionary, "label"); + return new TabPageInfo(id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + private TabPageInfo GetActiveTabPage(ControlDefinition control, IReadOnlyList tabs) + { + if (_activeTabs.TryGetValue(control.ControlId, out string? activeId)) + { + TabPageInfo? active = tabs.FirstOrDefault(tab => string.Equals(tab.Id, activeId, StringComparison.Ordinal)); + if (active is not null) + return active; + } + + _activeTabs[control.ControlId] = tabs[0].Id; + return tabs[0]; + } + + private void SetActiveTab(string controlId, string tabId) + => _activeTabs[controlId] = tabId; + + private string GetTabButtonClass(string activeTabId, string tabId) + => string.Equals(activeTabId, tabId, StringComparison.Ordinal) + ? "fr-tab-button active" + : "fr-tab-button"; + + private FormDefinition BuildTabChildForm(ControlDefinition tabControl, string tabId) + { + var childControls = Form.Controls + .Where(control => IsTabChildControl(control, tabControl.ControlId, tabId)) + .ToArray(); + + return Form with { Controls = childControls }; + } + + private static double GetTabChildCanvasWidth(ControlDefinition tabControl) + => Math.Max(24, tabControl.Rect.Width - 24); + + private double GetTabChildCanvasHeight(ControlDefinition tabControl, string tabId) + { + double designHeight = Form.Controls + .Where(control => IsTabChildControl(control, tabControl.ControlId, tabId)) + .Select(control => control.Rect.Y + control.Rect.Height + 24) + .DefaultIfEmpty(0) + .Max(); + double tabPageHeight = Math.Max(16, tabControl.Rect.Height - 56); + + return Math.Max(designHeight, tabPageHeight); + } + + private static bool IsTabChildControl(ControlDefinition control, string parentControlId, string tabId) + => TryGetControlProp(control, "parentControlId", out string configuredParentId) + && string.Equals(configuredParentId, parentControlId, StringComparison.Ordinal) + && TryGetControlProp(control, "parentTabId", out string configuredTabId) + && string.Equals(configuredTabId, tabId, StringComparison.Ordinal); + + private static bool TryGetControlProp(ControlDefinition control, string key, out string value) + { + value = string.Empty; + if (!control.Props.Values.TryGetValue(key, out object? raw) || raw is null) + return false; + + if (raw is JsonElement json) + raw = json.ValueKind == JsonValueKind.String ? json.GetString() : json.ToString(); + + value = raw?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private static string ReadDictionaryText(IReadOnlyDictionary dictionary, string key) + { + if (!dictionary.TryGetValue(key, out object? value) || value is null) + return string.Empty; + + if (value is JsonElement json) + return ReadJsonText(json); + + return value.ToString() ?? string.Empty; + } + + private static string ReadJsonText(JsonElement value) + => value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ToString(); + + private string BuildSubformKey(ControlDefinition control, object? parentValue) + => $"{control.ControlId}:{parentValue}"; + + private static string BuildSubformFilterExpression(string foreignKeyField) + => $"[{foreignKeyField}] = @parentValue"; + + private static IReadOnlyDictionary BuildSubformFilterParameters(object? parentValue) + => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["parentValue"] = parentValue, + }; + private FormFieldDefinition? GetFieldDefinition(string? fieldName) => fieldName is null ? null : TableDefinition?.Fields.FirstOrDefault(field => string.Equals(field.Name, fieldName, StringComparison.OrdinalIgnoreCase)); + private sealed record TabPageInfo(string Id, string Label); + private static int GetTabIndex(ControlDefinition c) { if (c.Props.Values.TryGetValue("tabIndex", out var val)) @@ -746,6 +1666,59 @@ ? ReadBoolean(value, fallback) : fallback; + private int GetIntProp(ControlDefinition c, string key, int fallback) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadInteger(value, fallback) + : fallback; + + private double GetDoubleProp(ControlDefinition c, string key, double fallback) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadDouble(value, fallback) + : fallback; + + private long GetLongProp(ControlDefinition c, string key, long fallback) + => TryGetEffectiveProperty(c, key, out object? value) + ? ReadLong(value, fallback) + : fallback; + + private static int ReadInteger(object? value, int fallback) + { + long parsed = ReadLong(value, fallback); + return parsed > int.MaxValue || parsed < int.MinValue + ? fallback + : (int)parsed; + } + + private static long ReadLong(object? value, long fallback) + { + return value switch + { + int i => i, + long l => l, + double d => (long)d, + decimal m => (long)m, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt64(out long l) => l, + _ when long.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed) => parsed, + _ => fallback, + }; + } + + private static double ReadDouble(object? value, double fallback) + { + return value switch + { + int i => i, + long l => l, + float f => f, + double d => d, + decimal m => (double)m, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetDouble(out double d) => d, + JsonElement json when json.ValueKind == JsonValueKind.String && double.TryParse(json.GetString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed, + _ when double.TryParse(value?.ToString(), NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) => parsed, + _ => fallback, + }; + } + private bool TryGetEffectiveProperty(ControlDefinition control, string key, out object? value) { bool found = control.Props.Values.TryGetValue(key, out value); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor b/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor index d3e57a54..09cb5ece 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/LayersPanel.razor @@ -1,4 +1,6 @@ @using CSharpDB.Admin.Forms.Components.Designer +@using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models @implements IDisposable
@@ -36,6 +38,7 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; private int _dragStartIndex = -1; private int _dragOverIndex = -1; @@ -76,32 +79,27 @@ State.SelectControl(controlId, e.ShiftKey); } - private static string GetLabel(CSharpDB.Admin.Forms.Models.ControlDefinition c) + private string GetLabel(ControlDefinition c) { // Prefer binding field name, then text prop, then control type if (c.Binding?.FieldName is { Length: > 0 } fieldName) return fieldName; if (c.Props.Values.TryGetValue("text", out var textVal) && textVal is string s && s.Length > 0) return s; - return c.ControlType; + return GetControlDisplayName(c.ControlType); } - private static string GetTypeIcon(string controlType) => controlType switch + private string GetTypeIcon(string controlType) { - "label" => "A", - "text" => "\u2328", - "textarea" => "\u2263", - "number" => "#", - "date" => "\U0001F4C5", - "checkbox" => "\u2611", - "radio" => "\u25C9", - "select" => "\u25BE", - "lookup" => "\U0001F50D", - "datagrid" => "\u2637", - "childtabs" => "\u2630", - "computed" => "\u03A3", - _ => "?" - }; + return ControlRegistry.TryGetControl(controlType, out FormControlDescriptor descriptor) + ? descriptor.IconText + : "?"; + } + + private string GetControlDisplayName(string controlType) + => ControlRegistry.TryGetControl(controlType, out FormControlDescriptor descriptor) + ? descriptor.DisplayName + : controlType; private static string LayerRowClass(bool selected, bool dragOver) { diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index df8af45c..0e00f6cc 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -1,3 +1,4 @@ +@using System.Globalization @using System.Text.Json @using CSharpDB.Admin.Forms.Models @using CSharpDB.Admin.Forms.Components.Designer @@ -6,6 +7,7 @@ @using CSharpDB.Primitives @inject ISchemaProvider SchemaProvider @inject DbCommandRegistry CommandRegistry +@inject IFormRepository FormRepository @implements IDisposable
@@ -65,19 +67,14 @@
@@ -119,9 +116,66 @@ @onchange="@(e => OnRectChanged(e, RectPart.H))" />
+
+ + +
+
+ + +
+
+ +
+ + + + +
+
+
+
+ + +
+
+ + +
+
- @if (_selected.ControlType is "label" or "checkbox") + @if (_selected.ControlType is "label" or "checkbox" or "toggleButton") {
@@ -133,7 +187,7 @@
} - @if (_selected.ControlType is "text" or "textarea" or "number" or "select" or "lookup") + @if (_selected.ControlType is "text" or "textarea" or "number" or "select" or "lookup" or "comboBox" or "listBox") {
@@ -158,6 +212,85 @@
} + @if (_selected.ControlType is "select" or "comboBox" or "listBox" or "optionGroup") + { +
+ +
+ + + @if (!string.IsNullOrWhiteSpace(_optionsError)) + { +
@_optionsError
+ } +
+ @if (_selected.ControlType == "comboBox") + { +
+ + +
+ } + @if (_selected.ControlType == "listBox") + { +
+ + +
+ @if (GetBoolProp(PropMultiSelect)) + { +
+ + +
+ } +
+ + +
+ } + @if (_selected.ControlType == "optionGroup") + { +
+ + +
+
+ + +
+ } +
+ } + + @if (_selected.ControlType == "toggleButton") + { +
+ +
+ + +
+
+ + +
+
+ } + @if (_selected.Binding is not null) {
@@ -199,8 +332,19 @@
} + else if (IsTabOrderControl(_selected)) + { +
+ +
+ + +
+
+ } - @if (_selected.ControlType == "lookup") + @if (_selected.ControlType is "lookup" or "comboBox" or "listBox" or "optionGroup") {
@@ -229,6 +373,12 @@ }
+
+ + +
} +
+ + +
} @@ -442,6 +597,257 @@
} + @if (_selected.ControlType == "tabControl") + { +
+ +
+ + + @if (!string.IsNullOrWhiteSpace(_tabPagesError)) + { +
@_tabPagesError
+ } +
+
+ } + + @if (_selected.ControlType != "tabControl" && GetTabControls().Count > 0) + { +
+ +
+ + +
+ @if (GetSelectedParentTabControl() is { } parentTabControl) + { +
+ + +
+ } +
+ } + + @if (_selected.ControlType == "subform") + { +
+ +
+ + +
+
+ + @RenderFieldSelect(PropParentKeyField, _sourceTableDef, "-- Select parent field --") +
+
+ + +
+
+ + +
+
+ + +
+
+ } + + @if (_selected.ControlType is "attachment" or "image") + { +
+ +
+ + +
+ @if (IsAttachmentTableMode()) + { +
+ + +
+
+ + @RenderFieldSelect(PropParentKeyField, _sourceTableDef, "-- Primary key --") +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ } + else + { +
+ + @RenderFieldSelect(PropFileNameField, _sourceTableDef, "-- None --") +
+
+ + @RenderFieldSelect(PropContentTypeField, _sourceTableDef, "-- None --") +
+
+ + @RenderFieldSelect(PropFileSizeField, _sourceTableDef, "-- None --") +
+ } +
+ + +
+
+ + +
+ @if (_selected.ControlType == "image") + { +
+ + +
+
+ + +
+ } +
+ } + + @if (GetSelectedDescriptor() is { } registeredDescriptor && + (registeredDescriptor.PropertyEditorComponentType is not null || registeredDescriptor.PropertyDescriptors.Count > 0)) + { +
+ + @if (registeredDescriptor.PropertyEditorComponentType is not null) + { + + } + @foreach (FormControlPropertyDescriptor property in registeredDescriptor.PropertyDescriptors) + { +
+ + @switch (property.Editor) + { + case FormControlPropertyEditor.TextArea: + + break; + case FormControlPropertyEditor.Number: + + break; + case FormControlPropertyEditor.Checkbox: + + break; + case FormControlPropertyEditor.Select: + + break; + default: + + break; + } + @if (!string.IsNullOrWhiteSpace(property.HelpText)) + { +
@property.HelpText
+ } +
+ } +
+ } +
@@ -471,6 +877,7 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; private ControlDefinition? _selected; @@ -480,6 +887,13 @@ private const string PropMaxLength = "maxLength"; private const string PropReadOnly = "readOnly"; private const string PropTabIndex = "tabIndex"; + private const string PropAnchorLeft = "anchorLeft"; + private const string PropAnchorTop = "anchorTop"; + private const string PropAnchorRight = "anchorRight"; + private const string PropAnchorBottom = "anchorBottom"; + private const string PropMinWidth = "minWidth"; + private const string PropMinHeight = "minHeight"; + private const string PropResizeMode = "resizeMode"; private const string PropDataGridMode = "dataGridMode"; private const string PropChildTable = "childTable"; private const string PropForeignKeyName = "foreignKeyName"; @@ -492,6 +906,38 @@ private const string PropLookupTable = "lookupTable"; private const string PropDisplayField = "displayField"; private const string PropValueField = "valueField"; + private const string PropDisplayFields = "displayFields"; + private const string PropRowLimit = "rowLimit"; + private const string PropOptions = "options"; + private const string PropAllowCustomValue = "allowCustomValue"; + private const string PropVisibleRows = "visibleRows"; + private const string PropMultiSelect = "multiSelect"; + private const string PropMultiValueDelimiter = "multiValueDelimiter"; + private const string PropOrientation = "orientation"; + private const string PropButtonStyle = "buttonStyle"; + private const string PropTrueValue = "trueValue"; + private const string PropFalseValue = "falseValue"; + private const string PropTabs = "tabs"; + private const string PropParentControlId = "parentControlId"; + private const string PropParentTabId = "parentTabId"; + private const string PropFormId = "formId"; + private const string PropShowToolbar = "showToolbar"; + private const string PropShowRecordList = "showRecordList"; + private const string PropFileNameField = "fileNameField"; + private const string PropContentTypeField = "contentTypeField"; + private const string PropFileSizeField = "fileSizeField"; + private const string PropStorageMode = "storageMode"; + private const string PropAttachmentTable = "attachmentTable"; + private const string PropAttachmentForeignKeyField = "attachmentForeignKeyField"; + private const string PropAttachmentBlobField = "attachmentBlobField"; + private const string PropAttachmentFileNameField = "attachmentFileNameField"; + private const string PropAttachmentContentTypeField = "attachmentContentTypeField"; + private const string PropAttachmentFileSizeField = "attachmentFileSizeField"; + private const string PropAttachmentControlIdField = "attachmentControlIdField"; + private const string PropAccept = "accept"; + private const string PropMaxFileBytes = "maxFileBytes"; + private const string PropAlt = "alt"; + private const string PropFit = "fit"; private const string PropFormula = "formula"; private const string PropFormat = "format"; private const string PropCommandName = "commandName"; @@ -501,6 +947,7 @@ // DataGrid configuration state private IReadOnlyList? _availableTables; + private IReadOnlyList? _availableForms; private string? _loadedSourceTableName; private FormTableDefinition? _sourceTableDef; private FormTableDefinition? _childTableDef; @@ -513,6 +960,12 @@ private string? _loadedCommandArgumentControlId; private string _commandArgumentText = string.Empty; private string? _commandArgumentError; + private string? _loadedOptionsControlId; + private string _optionsText = string.Empty; + private string? _optionsError; + private string? _loadedTabPagesControlId; + private string _tabPagesText = string.Empty; + private string? _tabPagesError; private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); @@ -520,6 +973,7 @@ { State.OnChange += OnStateChanged; _availableTables = await SchemaProvider.ListTableNamesAsync(); + _availableForms = await FormRepository.ListAsync(); await RefreshSourceTableDefinitionAsync(); } @@ -554,8 +1008,8 @@ await InvokeAsync(StateHasChanged); } - // Load lookup table def when a lookup control is selected - if (_selected?.ControlType == "lookup" && _selected != prev) + // Load lookup table def when a lookup-backed choice control is selected + if (_selected != prev && IsLookupChoiceControl(_selected?.ControlType)) { var lookupTable = GetProp(PropLookupTable, ""); if (!string.IsNullOrEmpty(lookupTable)) @@ -575,7 +1029,7 @@ if (_selected?.ControlType != "childtabs") _currentTabs = []; - if (_selected?.ControlType != "lookup") + if (_selected?.ControlType is not ("lookup" or "comboBox" or "listBox" or "optionGroup")) _lookupTableDef = null; if (_selected?.ControlType != "commandButton") @@ -585,6 +1039,20 @@ _commandArgumentError = null; } + if (_selected?.ControlType is not ("select" or "comboBox" or "listBox" or "optionGroup")) + { + _loadedOptionsControlId = null; + _optionsText = string.Empty; + _optionsError = null; + } + + if (_selected?.ControlType != "tabControl") + { + _loadedTabPagesControlId = null; + _tabPagesText = string.Empty; + _tabPagesError = null; + } + await InvokeAsync(StateHasChanged); } @@ -597,17 +1065,195 @@ private bool GetBoolProp(string key) { - if (_selected?.Props.Values.TryGetValue(key, out var val) == true && val is bool b) - return b; + if (_selected?.Props.Values.TryGetValue(key, out var val) == true) + return ReadBool(val, false); return false; } + private FormControlDescriptor? GetSelectedDescriptor() + => _selected is not null && ControlRegistry.TryGetControl(_selected.ControlType, out FormControlDescriptor descriptor) + ? descriptor + : null; + + private IReadOnlyList GetTypeDropdownControls() + => ControlRegistry.Controls; + + private bool IsRegisteredControlType(string controlType) + => ControlRegistry.TryGetControl(controlType, out _); + + private bool IsTabOrderControl(ControlDefinition control) + => ControlRegistry.TryGetControl(control.ControlType, out FormControlDescriptor descriptor) + ? descriptor.ParticipatesInTabOrder + : control.ControlType is not ("label" or "datagrid" or "childtabs" or "tabControl" or "subform"); + + private string GetRegisteredPropertyValue(FormControlPropertyDescriptor property) + { + if (_selected?.Props.Values.TryGetValue(property.Name, out object? value) == true && value is not null) + return value.ToString() ?? string.Empty; + + return property.DefaultValue?.ToString() ?? string.Empty; + } + + private bool GetRegisteredBoolPropertyValue(FormControlPropertyDescriptor property) + { + if (_selected?.Props.Values.TryGetValue(property.Name, out object? value) == true) + return ReadBool(value, ReadBool(property.DefaultValue, false)); + + return ReadBool(property.DefaultValue, false); + } + + private void OnRegisteredPropertyChanged(FormControlPropertyDescriptor property, object? rawValue) + { + object? value = property.Editor switch + { + FormControlPropertyEditor.Checkbox => rawValue is bool b && b, + FormControlPropertyEditor.Number => ParseScalar(rawValue), + _ => rawValue?.ToString(), + }; + + OnPropChanged(property.Name, value); + } + + private Dictionary GetPropertyEditorParameters(FormControlDescriptor descriptor) + => new() + { + ["Context"] = new FormControlPropertyContext( + _selected!, + descriptor, + _sourceTableDef, + _availableTables, + _availableForms, + (key, value) => + { + OnPropChanged(key, value); + return Task.CompletedTask; + }), + }; + + private bool IsAttachmentTableMode() + => string.Equals(GetProp(PropStorageMode, "blobField"), "attachmentTable", StringComparison.OrdinalIgnoreCase); + + private bool GetAnchorProp(string key, bool fallback) + { + if (_selected?.Props.Values.TryGetValue(key, out var val) == true) + return ReadBool(val, fallback); + return fallback; + } + private void OnPropChanged(string key, object? value) { if (_selected is null) return; State.UpdateControlProp(_selected.ControlId, key, value); } + private string GetAnchorPreset() + { + var anchors = GetCurrentAnchors(); + + return anchors switch + { + (true, true, false, false) => "topLeft", + (false, true, true, false) => "topRight", + (true, false, false, true) => "bottomLeft", + (false, false, true, true) => "bottomRight", + (true, true, true, false) => "stretchAcross", + (true, true, false, true) => "stretchDown", + (true, true, true, true) => "stretchBoth", + _ => "custom", + }; + } + + private void OnAnchorPresetChanged(ChangeEventArgs e) + { + if (_selected is null) return; + + string preset = e.Value?.ToString() ?? string.Empty; + if (preset == "custom") + return; + + var anchors = preset switch + { + "topLeft" => (Left: true, Top: true, Right: false, Bottom: false), + "topRight" => (Left: false, Top: true, Right: true, Bottom: false), + "bottomLeft" => (Left: true, Top: false, Right: false, Bottom: true), + "bottomRight" => (Left: false, Top: false, Right: true, Bottom: true), + "stretchAcross" => (Left: true, Top: true, Right: true, Bottom: false), + "stretchDown" => (Left: true, Top: true, Right: false, Bottom: true), + "stretchBoth" => (Left: true, Top: true, Right: true, Bottom: true), + _ => GetCurrentAnchors(), + }; + + UpdateAnchorProps(anchors); + } + + private void OnAnchorChanged(string key, object? value) + { + if (_selected is null) return; + + var anchors = GetCurrentAnchors(); + bool left = anchors.Left; + bool top = anchors.Top; + bool right = anchors.Right; + bool bottom = anchors.Bottom; + bool isChecked = value is bool b && b; + + switch (key) + { + case PropAnchorLeft: + left = isChecked; + break; + case PropAnchorTop: + top = isChecked; + break; + case PropAnchorRight: + right = isChecked; + break; + case PropAnchorBottom: + bottom = isChecked; + break; + } + + if (!left && !right) + { + if (key == PropAnchorLeft) + right = true; + else + left = true; + } + + if (!top && !bottom) + { + if (key == PropAnchorTop) + bottom = true; + else + top = true; + } + + UpdateAnchorProps((left, top, right, bottom)); + } + + private (bool Left, bool Top, bool Right, bool Bottom) GetCurrentAnchors() + => ( + GetAnchorProp(PropAnchorLeft, true), + GetAnchorProp(PropAnchorTop, true), + GetAnchorProp(PropAnchorRight, false), + GetAnchorProp(PropAnchorBottom, false)); + + private void UpdateAnchorProps((bool Left, bool Top, bool Right, bool Bottom) anchors) + { + if (_selected is null) return; + + State.UpdateControlProps( + _selected.ControlId, + new Dictionary + { + [PropAnchorLeft] = anchors.Left, + [PropAnchorTop] = anchors.Top, + [PropAnchorRight] = anchors.Right, + [PropAnchorBottom] = anchors.Bottom, + }); + } + private void OnFormNameChanged(ChangeEventArgs e) { State.SetFormName(e.Value?.ToString()); @@ -617,6 +1263,13 @@ { if (_selected is null || e.Value is not string newType) return; State.UpdateControlType(_selected.ControlId, newType); + if (ControlRegistry.TryGetControl(newType, out FormControlDescriptor descriptor)) + { + if (!descriptor.SupportsBinding && _selected.Binding is not null) + State.UpdateControlBinding(_selected.ControlId, null); + else if (descriptor.SupportsBinding && _selected.Binding is null) + State.UpdateControlBinding(_selected.ControlId, new BindingDefinition("", "TwoWay")); + } } private void OnRectChanged(ChangeEventArgs e, RectPart part) @@ -667,6 +1320,29 @@ return long.TryParse(s, out var l) ? l : null; } + private static object? ParseNonNegativeDouble(object? value) + { + if (value is null) return null; + var s = value.ToString(); + if (string.IsNullOrWhiteSpace(s)) return null; + return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? Math.Max(0, parsed) + : null; + } + + private static bool ReadBool(object? value, bool fallback) + { + return value switch + { + bool b => b, + JsonElement json when json.ValueKind == JsonValueKind.True => true, + JsonElement json when json.ValueKind == JsonValueKind.False => false, + JsonElement json when json.ValueKind == JsonValueKind.String && bool.TryParse(json.GetString(), out bool parsed) => parsed, + _ when bool.TryParse(value?.ToString(), out bool parsed) => parsed, + _ => fallback, + }; + } + // ===== DataGrid Configuration Methods ===== private async Task OnDataGridModeChanged(ChangeEventArgs e) @@ -834,6 +1510,247 @@ } } + private string GetOptionsText() + { + if (_selected is null) + return string.Empty; + + if (string.Equals(_loadedOptionsControlId, _selected.ControlId, StringComparison.Ordinal)) + return _optionsText; + + _loadedOptionsControlId = _selected.ControlId; + _optionsError = null; + IReadOnlyList options = _selected.Props.Values.TryGetValue(PropOptions, out object? value) + ? FormChoiceResolver.ReadOptions(value) + : []; + _optionsText = string.Join(Environment.NewLine, options.Select(option => $"{option.Value}|{option.Label}")); + return _optionsText; + } + + private void OnOptionsChanged(string text) + { + _optionsText = text; + _optionsError = null; + + if (string.IsNullOrWhiteSpace(text)) + { + OnPropChanged(PropOptions, Array.Empty()); + return; + } + + var options = new List(); + foreach (string line in text.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + string[] parts = line.Split('|', 2); + string value = parts[0].Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + _optionsError = "Each option needs a value before the pipe."; + return; + } + + string label = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) + ? parts[1].Trim() + : value; + options.Add(new Dictionary + { + ["value"] = value, + ["label"] = label, + }); + } + + OnPropChanged(PropOptions, options.ToArray()); + } + + private string GetDisplayFieldsText() + { + if (_selected?.Props.Values.TryGetValue(PropDisplayFields, out object? value) == true) + return string.Join(", ", FormChoiceResolver.ReadStringList(value)); + + return string.Empty; + } + + private void OnDisplayFieldsChanged(string text) + { + object?[] fields = text + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(field => (object?)field) + .ToArray(); + OnPropChanged(PropDisplayFields, fields); + } + + private static object? ParseScalar(object? value) + { + string text = value?.ToString()?.Trim() ?? string.Empty; + if (text.Length == 0) + return null; + + if (bool.TryParse(text, out bool boolValue)) + return boolValue; + if (long.TryParse(text, out long longValue)) + return longValue; + if (double.TryParse(text, out double doubleValue)) + return doubleValue; + + return text; + } + + private string GetTabPagesText() + { + if (_selected is null) + return string.Empty; + + if (string.Equals(_loadedTabPagesControlId, _selected.ControlId, StringComparison.Ordinal)) + return _tabPagesText; + + _loadedTabPagesControlId = _selected.ControlId; + _tabPagesError = null; + _tabPagesText = string.Join(Environment.NewLine, ReadTabPages(_selected).Select(tab => $"{tab.Id}|{tab.Label}")); + return _tabPagesText; + } + + private void OnTabPagesChanged(string text) + { + _tabPagesText = text; + _tabPagesError = null; + + var tabs = new List(); + foreach (string line in text.Split(["\r\n", "\n"], StringSplitOptions.None)) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + string[] parts = line.Split('|', 2); + string id = parts[0].Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + _tabPagesError = "Each tab page needs an id before the pipe."; + return; + } + + string label = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) + ? parts[1].Trim() + : id; + tabs.Add(new Dictionary + { + ["id"] = id, + ["label"] = label, + }); + } + + OnPropChanged(PropTabs, tabs.ToArray()); + } + + private IReadOnlyList<(string Id, string Label)> ReadTabPages(ControlDefinition control) + { + if (!control.Props.Values.TryGetValue(PropTabs, out object? value) || value is null) + return []; + + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + { + return json.EnumerateArray() + .Select(element => ReadTabPage(element)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToArray(); + } + + if (value is IEnumerable items) + { + return items + .Select(item => ReadTabPage(item)) + .Where(tab => tab is not null) + .Select(tab => tab!.Value) + .ToArray(); + } + + return []; + } + + private static (string Id, string Label)? ReadTabPage(object? value) + { + if (value is JsonElement json) + { + if (json.ValueKind != JsonValueKind.Object) + return null; + + string id = json.TryGetProperty("id", out JsonElement idValue) ? idValue.ToString() : string.Empty; + string label = json.TryGetProperty("label", out JsonElement labelValue) ? labelValue.ToString() : id; + return string.IsNullOrWhiteSpace(id) ? null : (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + if (value is IReadOnlyDictionary readOnly) + return ReadTabPage(readOnly); + + if (value is IDictionary dictionary) + return ReadTabPage(dictionary); + + return null; + } + + private static (string Id, string Label)? ReadTabPage(IReadOnlyDictionary dictionary) + { + string id = dictionary.TryGetValue("id", out object? idValue) ? idValue?.ToString() ?? string.Empty : string.Empty; + if (string.IsNullOrWhiteSpace(id)) + return null; + + string label = dictionary.TryGetValue("label", out object? labelValue) ? labelValue?.ToString() ?? id : id; + return (id, string.IsNullOrWhiteSpace(label) ? id : label); + } + + private List GetTabControls() + => State.Controls + .Where(control => control.ControlType == "tabControl" && !string.Equals(control.ControlId, _selected?.ControlId, StringComparison.Ordinal)) + .ToList(); + + private ControlDefinition? GetSelectedParentTabControl() + { + string parentControlId = GetProp(PropParentControlId, string.Empty); + return string.IsNullOrWhiteSpace(parentControlId) + ? null + : State.Controls.FirstOrDefault(control => string.Equals(control.ControlId, parentControlId, StringComparison.Ordinal)); + } + + private void OnParentTabControlChanged(ChangeEventArgs e) + { + if (_selected is null) + return; + + string parentControlId = e.Value?.ToString() ?? string.Empty; + OnPropChanged(PropParentControlId, parentControlId); + if (string.IsNullOrWhiteSpace(parentControlId)) + { + OnPropChanged(PropParentTabId, string.Empty); + return; + } + + ControlDefinition? parent = State.Controls.FirstOrDefault(control => string.Equals(control.ControlId, parentControlId, StringComparison.Ordinal)); + string firstTabId = parent is null ? string.Empty : ReadTabPages(parent).FirstOrDefault().Id ?? string.Empty; + OnPropChanged(PropParentTabId, firstTabId); + } + + private static string GetControlDisplayName(ControlDefinition control) + => control.Props.Values.TryGetValue(PropText, out object? text) && !string.IsNullOrWhiteSpace(text?.ToString()) + ? $"{text} ({control.ControlId[..Math.Min(8, control.ControlId.Length)]})" + : $"{control.ControlType} ({control.ControlId[..Math.Min(8, control.ControlId.Length)]})"; + + private RenderFragment RenderFieldSelect(string propName, FormTableDefinition? table, string emptyText) => @; + + private static bool IsLookupChoiceControl(string? controlType) + => controlType is "lookup" or "comboBox" or "listBox" or "optionGroup"; + // ===== ChildTabs Configuration Methods ===== private void OnTabsChanged(List tabs) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor index 3510a2d3..0a85f943 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/Toolbox.razor @@ -1,4 +1,6 @@ @using CSharpDB.Admin.Forms.Components.Designer +@using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models @implements IDisposable
@@ -13,86 +15,21 @@
-
-
Layout
- -
- -
-
Input Controls
- - - - - - - - - -
- -
-
Data
- - -
- -
-
Automation
- -
+ @foreach (var group in GetToolboxGroups()) + { +
+
@group.Name
+ @foreach (FormControlDescriptor control in group.Controls) + { + + } +
+ }
Clipboard
@@ -155,25 +92,20 @@ @code { [CascadingParameter] public DesignerState State { get; set; } = default!; - - private const string ToolLabel = "label"; - private const string ToolText = "text"; - private const string ToolTextarea = "textarea"; - private const string ToolNumber = "number"; - private const string ToolDate = "date"; - private const string ToolCheckbox = "checkbox"; - private const string ToolRadio = "radio"; - private const string ToolSelect = "select"; - private const string ToolDatagrid = "datagrid"; - private const string ToolChildTabs = "childtabs"; - private const string ToolLookup = "lookup"; - private const string ToolComputed = "computed"; - private const string ToolCommandButton = "commandButton"; + [Inject] public IFormControlRegistry ControlRegistry { get; set; } = DefaultFormControlRegistry.Instance; protected override void OnInitialized() => State.OnChange += OnStateChanged; public void Dispose() => State.OnChange -= OnStateChanged; private void OnStateChanged() => InvokeAsync(StateHasChanged); + private IReadOnlyList<(string Name, IReadOnlyList Controls)> GetToolboxGroups() + => ControlRegistry.GetToolboxControls() + .GroupBy(control => control.ToolboxGroup) + .Select(group => ( + Name: group.Key, + Controls: (IReadOnlyList)group.ToArray())) + .ToArray(); + private string ToolClass(string? tool) => "toolbox-item" + (State.ActiveTool == tool ? " active" : ""); diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs new file mode 100644 index 00000000..f91bd82e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormControlRegistry.cs @@ -0,0 +1,12 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Contracts; + +public interface IFormControlRegistry +{ + IReadOnlyList Controls { get; } + + bool TryGetControl(string controlType, out FormControlDescriptor descriptor); + + IReadOnlyList GetToolboxControls(); +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs b/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs index c78ce407..9c9801f1 100644 --- a/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs +++ b/src/CSharpDB.Admin.Forms/Contracts/IFormRecordService.cs @@ -16,5 +16,6 @@ public interface IFormRecordService Task>> ListFilteredRecordsAsync(FormTableDefinition table, string filterField, object? filterValue, CancellationToken ct = default); Task> CreateRecordAsync(FormTableDefinition table, Dictionary values, CancellationToken ct = default); Task> UpdateRecordAsync(FormTableDefinition table, object pkValue, Dictionary values, CancellationToken ct = default); + Task SaveAttachmentAsync(FormAttachmentTableBinding binding, object parentValue, FormAttachmentValue attachment, CancellationToken ct = default); Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default); } diff --git a/src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs b/src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs new file mode 100644 index 00000000..e2bd8111 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormAttachmentTableBinding.cs @@ -0,0 +1,11 @@ +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormAttachmentTableBinding( + string TableName, + string ForeignKeyField, + string BlobField, + string? FileNameField = null, + string? ContentTypeField = null, + string? FileSizeField = null, + string? ControlIdField = null, + string? ControlId = null); diff --git a/src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs b/src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs new file mode 100644 index 00000000..f60b6eec --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormAttachmentValue.cs @@ -0,0 +1,18 @@ +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormAttachmentValue( + byte[]? Bytes, + string? FileName, + string? ContentType, + long? FileSize, + bool ClearExisting) +{ + public static string GetRecordKey(string controlId) + => $"__formAttachment:{controlId}"; + + public static FormAttachmentValue FromFile(byte[] bytes, string fileName, string? contentType, long fileSize) + => new(bytes, fileName, contentType, fileSize, ClearExisting: false); + + public static FormAttachmentValue Clear() + => new(null, null, null, null, ClearExisting: true); +} diff --git a/src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs b/src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs new file mode 100644 index 00000000..d08d511e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormControlContexts.cs @@ -0,0 +1,34 @@ +using CSharpDB.Admin.Forms.Components.Designer; + +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormControlDesignContext( + ControlDefinition Control, + DesignerState State, + bool IsSelected, + Rect EffectiveRect, + FormControlDescriptor Descriptor); + +public sealed record FormControlRuntimeContext( + FormDefinition Form, + ControlDefinition Control, + FormControlDescriptor Descriptor, + FormTableDefinition? TableDefinition, + Dictionary Record, + string? FieldName, + object? BoundValue, + IReadOnlyList Choices, + bool IsEnabled, + bool IsReadOnly, + string? ValidationError, + int TabIndex, + Func SetValueAsync, + Func?, Task> DispatchEventAsync); + +public sealed record FormControlPropertyContext( + ControlDefinition Control, + FormControlDescriptor Descriptor, + FormTableDefinition? SourceTableDefinition, + IReadOnlyList? AvailableTables, + IReadOnlyList? AvailableForms, + Func SetPropertyAsync); diff --git a/src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs b/src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs new file mode 100644 index 00000000..c086e004 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Models/FormControlDescriptor.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Components; + +namespace CSharpDB.Admin.Forms.Models; + +public sealed record FormControlDescriptor +{ + public required string ControlType { get; init; } + public required string DisplayName { get; init; } + public string ToolboxGroup { get; init; } = "Custom"; + public string IconText { get; init; } = "?"; + public string? Description { get; init; } + public double DefaultWidth { get; init; } = 320; + public double DefaultHeight { get; init; } = 34; + public bool SupportsBinding { get; init; } = true; + public bool ParticipatesInTabOrder { get; init; } = true; + public bool ShowInToolbox { get; init; } = true; + public int ToolboxGroupOrder { get; init; } = 100; + public int ToolboxOrder { get; init; } = 100; + public IReadOnlyDictionary DefaultProps { get; init; } = + new Dictionary(); + public IReadOnlyList PropertyDescriptors { get; init; } = + []; + public Type? DesignerPreviewComponentType { get; init; } + public Type? RuntimeComponentType { get; init; } + public Type? PropertyEditorComponentType { get; init; } + public bool ReplaceBuiltInRuntime { get; init; } + public bool IsBuiltIn { get; init; } + + public Dictionary CreateDefaultProps() + => DefaultProps.ToDictionary(pair => pair.Key, pair => CloneDefaultValue(pair.Value), StringComparer.Ordinal); + + private static object? CloneDefaultValue(object? value) + { + if (value is null) + return null; + + if (value is JsonElement json) + return json.Clone(); + + if (value is object?[] array) + return array.Select(CloneDefaultValue).ToArray(); + + if (value is IReadOnlyDictionary readOnlyDictionary) + return readOnlyDictionary.ToDictionary(pair => pair.Key, pair => CloneDefaultValue(pair.Value), StringComparer.Ordinal); + + if (value is IDictionary dictionary) + return dictionary.ToDictionary(pair => pair.Key, pair => CloneDefaultValue(pair.Value), StringComparer.Ordinal); + + return value; + } + + public static void ValidateComponentType(Type? componentType, string propertyName) + { + if (componentType is not null && !typeof(IComponent).IsAssignableFrom(componentType)) + throw new ArgumentException($"{propertyName} must implement {nameof(IComponent)}.", propertyName); + } +} + +public sealed record FormControlPropertyDescriptor +{ + public required string Name { get; init; } + public required string Label { get; init; } + public FormControlPropertyEditor Editor { get; init; } = FormControlPropertyEditor.Text; + public object? DefaultValue { get; init; } + public string? Placeholder { get; init; } + public string? HelpText { get; init; } + public IReadOnlyList Options { get; init; } = []; +} + +public sealed record FormControlPropertyOption(string Value, string Label); + +public enum FormControlPropertyEditor +{ + Text, + TextArea, + Number, + Checkbox, + Select, +} diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index 79a12384..47d5972d 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -3,6 +3,7 @@ @using CSharpDB.Client @using CSharpDB.Client.Models @using CSharpDB.Admin.Forms.Contracts +@using CSharpDB.Admin.Forms.Models @using CSharpDB.Primitives @implements IDisposable @implements IFormActionRuntime @@ -12,7 +13,9 @@ @inject IValidationInferenceService ValidationService @inject IJSRuntime JS -
+
+ @if (ShowToolbar) + {
@if (!string.IsNullOrWhiteSpace(BackHref)) { @@ -65,6 +68,7 @@ }
+ } @if (_error is not null) { @@ -104,7 +108,7 @@
} - @if (_form is not null) + @if (_form is not null && ShowRecordList) {
OnOpenDesigner { get; set; } [Parameter] public bool ShowDesignerButton { get; set; } = true; + [Parameter] public bool ShowToolbar { get; set; } = true; + [Parameter] public bool ShowRecordList { get; set; } = true; + [Parameter] public bool Embedded { get; set; } [Parameter] public string? BackHref { get; set; } [Parameter] public string BackLabel { get; set; } = "Forms"; [Parameter] public EventCallback OnOpenForm { get; set; } @@ -278,6 +285,19 @@ ? !_isNew && _recordPageIndex >= 0 && (_recordPageIndex < _records.Count - 1 || _hasFocusedNextRecords) : !_isNew && CurrentRecordOrdinal < _totalRecords; + private string GetDataEntryLayoutClass() + { + List classes = ["data-entry-layout"]; + if (!ShowRecordList) + classes.Add("data-entry-no-record-list"); + if (!ShowToolbar) + classes.Add("data-entry-no-toolbar"); + if (Embedded) + classes.Add("data-entry-embedded"); + + return string.Join(" ", classes); + } + protected override async Task OnParametersSetAsync() { if (string.IsNullOrWhiteSpace(FormId)) @@ -298,7 +318,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender) + if (!firstRender || !ShowRecordList) return; try @@ -477,15 +497,18 @@ choices[field.Name] = field.Choices; } - foreach (var control in form.Controls.Where(control => control.ControlType == "lookup")) + foreach (var control in form.Controls.Where(FormChoiceResolver.UsesLookupChoices)) { string? bindingField = control.Binding?.FieldName; - string? lookupTableName = control.Props.Values.TryGetValue("lookupTable", out var lookupTable) ? lookupTable?.ToString() : null; - string? displayField = control.Props.Values.TryGetValue("displayField", out var display) ? display?.ToString() : null; - string? valueField = control.Props.Values.TryGetValue("valueField", out var value) ? value?.ToString() : null; - - if (string.IsNullOrWhiteSpace(bindingField) || - string.IsNullOrWhiteSpace(lookupTableName) || + FormChoiceResolver.TryGetString(control.Props.Values, "lookupTable", out string lookupTableName); + FormChoiceResolver.TryGetString(control.Props.Values, "displayField", out string displayField); + FormChoiceResolver.TryGetString(control.Props.Values, "valueField", out string valueField); + int rowLimit = FormChoiceResolver.ReadInt(control.Props.Values, "rowLimit", 500); + IReadOnlyList displayFields = control.Props.Values.TryGetValue("displayFields", out object? fields) + ? FormChoiceResolver.ReadStringList(fields) + : []; + + if (string.IsNullOrWhiteSpace(lookupTableName) || string.IsNullOrWhiteSpace(displayField) || string.IsNullOrWhiteSpace(valueField)) { @@ -496,13 +519,16 @@ if (lookupTableDef is null) continue; - List> lookupRows = await RecordService.ListRecordsAsync(lookupTableDef); - var enumChoices = lookupRows - .Select(row => new EnumChoice(LookupField(row, valueField), LookupField(row, displayField))) - .Where(choice => !string.IsNullOrWhiteSpace(choice.Value)) - .ToList(); + FormRecordPage lookupPage = await RecordService.ListRecordPageAsync(lookupTableDef, 1, Math.Clamp(rowLimit, 1, 5000)); + IReadOnlyList enumChoices = FormChoiceResolver.BuildLookupChoices( + lookupPage.Records, + valueField, + displayField, + displayFields); - choices[bindingField] = enumChoices; + choices[control.ControlId] = enumChoices; + if (!string.IsNullOrWhiteSpace(bindingField)) + choices[bindingField] = enumChoices; } return choices; @@ -591,6 +617,7 @@ return; Dictionary created = await RecordService.CreateRecordAsync(_table, recordToSave); + await PersistAttachmentTableValuesAsync(recordToSave, created); _currentRecord = CloneRecord(created); _isNew = false; if (TryGetPrimaryKeyValue(created, out object? createdPk) && createdPk is not null) @@ -611,6 +638,7 @@ return; Dictionary updated = await RecordService.UpdateRecordAsync(_table, pkValue!, recordToSave); + await PersistAttachmentTableValuesAsync(recordToSave, updated); UpdateVisibleCurrentRecord(updated); await DispatchFormEventAsync(FormEventKind.AfterUpdate, updated); } @@ -631,6 +659,77 @@ } } + private async Task PersistAttachmentTableValuesAsync( + IReadOnlyDictionary pendingRecord, + IReadOnlyDictionary savedRecord) + { + if (_form is null || _table is null) + return; + + foreach (ControlDefinition control in _form.Controls.Where(IsAttachmentTableControl)) + { + string pendingKey = FormAttachmentValue.GetRecordKey(control.ControlId); + if (!pendingRecord.TryGetValue(pendingKey, out object? pendingValue) || pendingValue is not FormAttachmentValue attachment) + continue; + + FormAttachmentTableBinding binding = BuildAttachmentTableBinding(control); + string parentKeyField = GetControlStringProp(control, "parentKeyField"); + if (string.IsNullOrWhiteSpace(parentKeyField)) + parentKeyField = RecordService.GetPrimaryKeyColumn(_table); + + object? parentValue = ReadRecordValue(savedRecord, parentKeyField) ?? ReadRecordValue(pendingRecord, parentKeyField); + if (parentValue is null || string.IsNullOrWhiteSpace(parentValue.ToString())) + throw new InvalidOperationException($"Attachment control '{control.ControlId}' requires parent key field '{parentKeyField}'."); + + await RecordService.SaveAttachmentAsync(binding, parentValue, attachment); + } + } + + private static bool IsAttachmentTableControl(ControlDefinition control) + => control.ControlType is "attachment" or "image" && + string.Equals(GetControlStringProp(control, "storageMode"), "attachmentTable", StringComparison.OrdinalIgnoreCase); + + private static FormAttachmentTableBinding BuildAttachmentTableBinding(ControlDefinition control) + { + string tableName = GetControlStringProp(control, "attachmentTable"); + string foreignKeyField = GetControlStringProp(control, "attachmentForeignKeyField"); + string blobField = GetControlStringProp(control, "attachmentBlobField"); + + if (string.IsNullOrWhiteSpace(tableName) || + string.IsNullOrWhiteSpace(foreignKeyField) || + string.IsNullOrWhiteSpace(blobField)) + { + throw new InvalidOperationException($"Attachment control '{control.ControlId}' is missing attachment table, foreign key field, or blob field."); + } + + return new FormAttachmentTableBinding( + tableName, + foreignKeyField, + blobField, + NullIfWhiteSpace(GetControlStringProp(control, "attachmentFileNameField")), + NullIfWhiteSpace(GetControlStringProp(control, "attachmentContentTypeField")), + NullIfWhiteSpace(GetControlStringProp(control, "attachmentFileSizeField")), + NullIfWhiteSpace(GetControlStringProp(control, "attachmentControlIdField")), + control.ControlId); + } + + private static string GetControlStringProp(ControlDefinition control, string key) + => FormChoiceResolver.TryGetString(control.Props.Values, key, out string value) + ? value + : string.Empty; + + private static object? ReadRecordValue(IReadOnlyDictionary record, string key) + { + if (record.TryGetValue(key, out object? value)) + return value; + + string? actualKey = record.Keys.FirstOrDefault(candidate => string.Equals(candidate, key, StringComparison.OrdinalIgnoreCase)); + return actualKey is null ? null : record[actualKey]; + } + + private static string? NullIfWhiteSpace(string value) + => string.IsNullOrWhiteSpace(value) ? null : value; + private async Task DeleteRecord() { if (_table is null || !TryGetPrimaryKeyValue(_currentRecord, out object? pkValue)) diff --git a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs index e3d2e674..0020b379 100644 --- a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddCSharpDbAdminForms(this IServiceCollection s { services.TryAddSingleton(DbCommandRegistry.Empty); services.TryAddSingleton(NullFormActionRuntime.Instance); + services.TryAddFormControlRegistry(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -29,4 +30,32 @@ public static IServiceCollection AddCSharpDbAdminForms( services.AddSingleton(DbCommandRegistry.Create(configureCommands)); return services.AddCSharpDbAdminForms(); } + + public static IServiceCollection AddCSharpDbAdminFormControls( + this IServiceCollection services, + Action configureControls) + { + ArgumentNullException.ThrowIfNull(configureControls); + + services.AddSingleton( + new DelegateFormControlRegistryConfiguration(configureControls)); + services.TryAddFormControlRegistry(); + return services; + } + + private static IServiceCollection TryAddFormControlRegistry(this IServiceCollection services) + { + services.TryAddSingleton(sp => + { + var builder = new FormControlRegistryBuilder(); + BuiltInFormControlDescriptors.AddTo(builder); + + foreach (IFormControlRegistryConfiguration configuration in sp.GetServices()) + configuration.Configure(builder); + + return builder.Build(); + }); + + return services; + } } diff --git a/src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs b/src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs new file mode 100644 index 00000000..76ba2205 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/BuiltInFormControlDescriptors.cs @@ -0,0 +1,133 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class BuiltInFormControlDescriptors +{ + public static void AddTo(FormControlRegistryBuilder builder) + { + builder + .Add(BuiltIn("label", "Label", "Layout", "A", 180, 34, supportsBinding: false, participatesInTabOrder: false, 10, 10, "Static text label", + new Dictionary { ["text"] = "Label" })) + .Add(BuiltIn("text", "Text", "Input Controls", "\u2328", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 10, "Text input field")) + .Add(BuiltIn("textarea", "Textarea", "Input Controls", "\u2263", 320, 80, supportsBinding: true, participatesInTabOrder: true, 20, 20, "Multi-line text area")) + .Add(BuiltIn("number", "Number", "Input Controls", "#", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 30, "Number input field")) + .Add(BuiltIn("date", "Date", "Input Controls", "\U0001F4C5", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 40, "Date picker")) + .Add(BuiltIn("checkbox", "Checkbox", "Input Controls", "\u2611", 200, 34, supportsBinding: true, participatesInTabOrder: true, 20, 50, "Checkbox", + new Dictionary { ["text"] = "Checkbox" })) + .Add(BuiltIn("radio", "Radio", "Input Controls", "\u25C9", 200, 80, supportsBinding: true, participatesInTabOrder: true, 20, 60, "Radio button group")) + .Add(BuiltIn("select", "Select", "Input Controls", "\u25BE", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 70, "Dropdown select", + ChoiceDefaults())) + .Add(BuiltIn("comboBox", "Combo Box", "Input Controls", "\u2327", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 80, "Searchable single-select with optional custom entry", + ChoiceDefaults(new Dictionary { ["placeholder"] = "Search or select", ["allowCustomValue"] = false }))) + .Add(BuiltIn("listBox", "List Box", "Input Controls", "\u2630", 260, 120, supportsBinding: true, participatesInTabOrder: true, 20, 90, "Always-visible single-select list", + ChoiceDefaults(new Dictionary { ["visibleRows"] = 5, ["multiSelect"] = false, ["multiValueDelimiter"] = ";" }))) + .Add(BuiltIn("lookup", "Lookup", "Input Controls", "\U0001F50D", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 100, "Lookup combo box loaded from a table", + new Dictionary { ["lookupTable"] = "", ["displayField"] = "", ["valueField"] = "", ["placeholder"] = "-- Select --" })) + .Add(BuiltIn("optionGroup", "Option Group", "Input Controls", "\u25C9", 220, 100, supportsBinding: true, participatesInTabOrder: true, 20, 110, "Bound scalar option group", + ChoiceDefaults(new Dictionary { ["orientation"] = "vertical", ["buttonStyle"] = false }))) + .Add(BuiltIn("toggleButton", "Toggle", "Input Controls", "\u25FC", 160, 34, supportsBinding: true, participatesInTabOrder: true, 20, 120, "Boolean or scalar toggle button", + new Dictionary { ["text"] = "Toggle", ["trueValue"] = true, ["falseValue"] = false })) + .Add(BuiltIn("computed", "Computed", "Input Controls", "\u03A3", 320, 34, supportsBinding: true, participatesInTabOrder: true, 20, 130, "Computed field", + new Dictionary { ["formula"] = "", ["format"] = "" })) + .Add(BuiltIn("datagrid", "DataGrid", "Data", "\u2637", 560, 200, supportsBinding: false, participatesInTabOrder: false, 30, 10, "Table data grid", + new Dictionary + { + ["dataGridMode"] = "standalone", + ["childTable"] = "", + ["foreignKeyField"] = "", + ["parentKeyField"] = "", + ["foreignKeyName"] = "", + ["visibleColumns"] = Array.Empty(), + ["allowAdd"] = true, + ["allowDelete"] = true, + ["allowEdit"] = true, + })) + .Add(BuiltIn("childtabs", "Child Tabs", "Data", "\u2630", 600, 280, supportsBinding: false, participatesInTabOrder: false, 30, 20, "Tab-based child forms with nesting", + new Dictionary { ["tabs"] = Array.Empty() })) + .Add(BuiltIn("tabControl", "Tab Control", "Data", "\u25AB", 600, 300, supportsBinding: false, participatesInTabOrder: false, 30, 30, "General tab container for form controls", + new Dictionary + { + ["tabs"] = new object?[] + { + new Dictionary { ["id"] = "page1", ["label"] = "Page 1" }, + new Dictionary { ["id"] = "page2", ["label"] = "Page 2" }, + }, + })) + .Add(BuiltIn("subform", "Subform", "Data", "\u25A3", 640, 320, supportsBinding: false, participatesInTabOrder: false, 30, 40, "Embedded saved form linked to the parent record", + new Dictionary { ["formId"] = "", ["parentKeyField"] = "", ["foreignKeyField"] = "", ["showToolbar"] = true, ["showRecordList"] = true })) + .Add(BuiltIn("attachment", "Attachment", "Data", "\u2398", 360, 74, supportsBinding: true, participatesInTabOrder: true, 30, 50, "BLOB attachment upload", + AttachmentDefaults("attachment"))) + .Add(BuiltIn("image", "Image", "Data", "\u25A7", 360, 240, supportsBinding: true, participatesInTabOrder: true, 30, 60, "BLOB image upload and preview", + AttachmentDefaults("image"))) + .Add(BuiltIn("commandButton", "Button", "Automation", "\u25B6", 160, 34, supportsBinding: false, participatesInTabOrder: true, 40, 10, "Runs a trusted command", + new Dictionary { ["text"] = "Button", ["commandName"] = "" })); + } + + private static FormControlDescriptor BuiltIn( + string controlType, + string displayName, + string toolboxGroup, + string iconText, + double defaultWidth, + double defaultHeight, + bool supportsBinding, + bool participatesInTabOrder, + int groupOrder, + int order, + string description, + IReadOnlyDictionary? defaultProps = null) + => new() + { + ControlType = controlType, + DisplayName = displayName, + ToolboxGroup = toolboxGroup, + IconText = iconText, + Description = description, + DefaultWidth = defaultWidth, + DefaultHeight = defaultHeight, + SupportsBinding = supportsBinding, + ParticipatesInTabOrder = participatesInTabOrder, + ToolboxGroupOrder = groupOrder, + ToolboxOrder = order, + DefaultProps = defaultProps ?? new Dictionary(), + IsBuiltIn = true, + }; + + private static Dictionary ChoiceDefaults(Dictionary? extra = null) + { + var props = new Dictionary + { + ["options"] = new object?[] + { + new Dictionary { ["value"] = "1", ["label"] = "Option 1" }, + new Dictionary { ["value"] = "2", ["label"] = "Option 2" }, + }, + }; + + if (extra is not null) + { + foreach (KeyValuePair pair in extra) + props[pair.Key] = pair.Value; + } + + return props; + } + + private static Dictionary AttachmentDefaults(string controlType) + => new() + { + ["storageMode"] = "blobField", + ["fileNameField"] = "", + ["contentTypeField"] = "", + ["fileSizeField"] = "", + ["attachmentTable"] = "", + ["attachmentForeignKeyField"] = "", + ["attachmentBlobField"] = "", + ["attachmentFileNameField"] = "", + ["attachmentContentTypeField"] = "", + ["attachmentFileSizeField"] = "", + ["attachmentControlIdField"] = "", + ["accept"] = controlType == "image" ? "image/*" : "", + }; +} diff --git a/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs b/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs index 19ddd68d..29f2c04c 100644 --- a/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs +++ b/src/CSharpDB.Admin.Forms/Services/DbFormRecordService.cs @@ -297,6 +297,45 @@ SELECT COUNT(*) AS RowCount ?? throw new InvalidOperationException("The updated record could not be reloaded."); } + public async Task SaveAttachmentAsync(FormAttachmentTableBinding binding, object parentValue, FormAttachmentValue attachment, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(binding); + ArgumentNullException.ThrowIfNull(parentValue); + ArgumentNullException.ThrowIfNull(attachment); + + string tableName = FormSql.RequireIdentifier(binding.TableName, nameof(binding.TableName)); + string foreignKeyField = FormSql.RequireIdentifier(binding.ForeignKeyField, nameof(binding.ForeignKeyField)); + string blobField = FormSql.RequireIdentifier(binding.BlobField, nameof(binding.BlobField)); + string whereClause = $"{foreignKeyField} = {FormSql.FormatLiteral(parentValue)}"; + + if (!string.IsNullOrWhiteSpace(binding.ControlIdField) && !string.IsNullOrWhiteSpace(binding.ControlId)) + { + string controlIdField = FormSql.RequireIdentifier(binding.ControlIdField, nameof(binding.ControlIdField)); + whereClause += $" AND {controlIdField} = {FormSql.FormatLiteral(binding.ControlId)}"; + } + + FormSql.ThrowIfError(await dbClient.ExecuteSqlAsync($""" + DELETE FROM {tableName} + WHERE {whereClause}; + """, ct)); + + if (attachment.ClearExisting || attachment.Bytes is not { Length: > 0 } bytes) + return; + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [binding.ForeignKeyField] = parentValue, + [binding.BlobField] = bytes, + }; + + AddOptionalAttachmentValue(values, binding.FileNameField, attachment.FileName); + AddOptionalAttachmentValue(values, binding.ContentTypeField, attachment.ContentType); + AddOptionalAttachmentValue(values, binding.FileSizeField, attachment.FileSize); + AddOptionalAttachmentValue(values, binding.ControlIdField, binding.ControlId); + + await dbClient.InsertRowAsync(binding.TableName, values, ct); + } + public Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default) => dbClient.DeleteRowAsync(table.TableName, GetPrimaryKeyColumn(table), pkValue, ct); @@ -431,6 +470,12 @@ private static int FindRecordIndex(IReadOnlyList> re return filtered; } + private static void AddOptionalAttachmentValue(Dictionary values, string? fieldName, object? value) + { + if (!string.IsNullOrWhiteSpace(fieldName)) + values[fieldName] = value; + } + private static Dictionary BuildEmptyInsertValues(FormTableDefinition table) { string pkColumn = GetSinglePrimaryKeyColumn(table); diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs new file mode 100644 index 00000000..f1681f73 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormControlRegistry.cs @@ -0,0 +1,15 @@ +using CSharpDB.Admin.Forms.Contracts; + +namespace CSharpDB.Admin.Forms.Services; + +internal static class DefaultFormControlRegistry +{ + public static IFormControlRegistry Instance { get; } = Create(); + + private static IFormControlRegistry Create() + { + var builder = new FormControlRegistryBuilder(); + BuiltInFormControlDescriptors.AddTo(builder); + return builder.Build(); + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs index 57d27b9a..022d20cb 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormGenerator.cs @@ -63,6 +63,7 @@ private static string PickControlType(FormFieldDefinition field) { FieldDataType.Boolean => "checkbox", FieldDataType.Date or FieldDataType.DateTime => "date", + FieldDataType.Blob => "attachment", FieldDataType.Int32 or FieldDataType.Int64 or FieldDataType.Decimal or FieldDataType.Double => "number", _ => "text", }; diff --git a/src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs b/src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs new file mode 100644 index 00000000..e6fd975b --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormChoiceResolver.cs @@ -0,0 +1,268 @@ +using System.Globalization; +using System.Text.Json; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public static class FormChoiceResolver +{ + private static readonly HashSet s_choiceControlTypes = new(StringComparer.OrdinalIgnoreCase) + { + "select", + "lookup", + "comboBox", + "listBox", + "optionGroup", + "radio", + }; + + public static bool IsChoiceControl(ControlDefinition control) + => s_choiceControlTypes.Contains(control.ControlType); + + public static bool UsesLookupChoices(ControlDefinition control) + => IsChoiceControl(control) + && TryGetString(control.Props.Values, "lookupTable", out _) + && TryGetString(control.Props.Values, "valueField", out _) + && TryGetString(control.Props.Values, "displayField", out _); + + public static IReadOnlyList ResolveChoices( + ControlDefinition control, + string? fieldName, + IReadOnlyDictionary>? runtimeChoices, + FormFieldDefinition? fieldDefinition = null) + { + IReadOnlyList staticChoices = ReadOptions(control.Props.Values.TryGetValue("options", out object? options) ? options : null); + if (staticChoices.Count > 0) + return staticChoices; + + if (runtimeChoices is not null && + runtimeChoices.TryGetValue(control.ControlId, out IReadOnlyList? controlChoices) && + controlChoices is not null) + { + return controlChoices; + } + + if (!string.IsNullOrWhiteSpace(fieldName) && + runtimeChoices is not null && + runtimeChoices.TryGetValue(fieldName, out IReadOnlyList? fieldChoices) && + fieldChoices is not null) + { + return fieldChoices; + } + + return fieldDefinition?.Choices is { Count: > 0 } schemaChoices + ? schemaChoices + : []; + } + + public static IReadOnlyList BuildLookupChoices( + IEnumerable> rows, + string valueField, + string displayField, + IReadOnlyList? displayFields = null) + { + string[] effectiveDisplayFields = displayFields is { Count: > 0 } + ? displayFields.Where(field => !string.IsNullOrWhiteSpace(field)).ToArray() + : [displayField]; + + return rows + .Select(row => new EnumChoice( + LookupField(row, valueField), + BuildDisplayLabel(row, effectiveDisplayFields))) + .Where(choice => !string.IsNullOrWhiteSpace(choice.Value)) + .ToArray(); + } + + public static IReadOnlyList ReadOptions(object? value) + { + value = NormalizeJsonValue(value); + if (value is null) + return []; + + if (value is JsonElement json) + return ReadOptionsFromJson(json); + + if (value is IEnumerable enumChoices) + return enumChoices.ToArray(); + + if (value is IEnumerable items) + { + return items + .Select(ReadOption) + .Where(choice => choice is not null) + .Select(choice => choice!) + .ToArray(); + } + + return []; + } + + public static IReadOnlyList ReadStringList(object? value) + { + value = NormalizeJsonValue(value); + if (value is null) + return []; + + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + return json.EnumerateArray() + .Select(element => element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString()) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Select(text => text!) + .ToArray(); + + if (value is IEnumerable items) + return items + .Select(item => NormalizeJsonValue(item)?.ToString()) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Select(text => text!) + .ToArray(); + + string? single = value.ToString(); + return string.IsNullOrWhiteSpace(single) + ? [] + : single.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + public static int ReadInt(IReadOnlyDictionary props, string key, int fallback) + { + if (!props.TryGetValue(key, out object? value) || value is null) + return fallback; + + value = NormalizeJsonValue(value); + return value switch + { + int i => i, + long l => checked((int)l), + double d => checked((int)d), + decimal m => checked((int)m), + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt32(out int i) => i, + _ when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) => parsed, + _ => fallback, + }; + } + + public static bool TryGetString(IReadOnlyDictionary props, string key, out string value) + { + value = string.Empty; + if (!props.TryGetValue(key, out object? raw) || raw is null) + return false; + + raw = NormalizeJsonValue(raw); + value = raw?.ToString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(value); + } + + private static EnumChoice? ReadOption(object? value) + { + value = NormalizeJsonValue(value); + if (value is null) + return null; + + if (value is JsonElement json) + return ReadOptionFromJson(json); + + if (value is IReadOnlyDictionary readOnly) + return ReadOptionFromDictionary(readOnly); + + if (value is IDictionary dictionary) + return ReadOptionFromDictionary(dictionary.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase)); + + string? scalar = value.ToString(); + return string.IsNullOrWhiteSpace(scalar) + ? null + : new EnumChoice(scalar, scalar); + } + + private static EnumChoice? ReadOptionFromDictionary(IReadOnlyDictionary dictionary) + { + string optionValue = ReadDictionaryText(dictionary, "value"); + if (string.IsNullOrWhiteSpace(optionValue)) + return null; + + string label = ReadDictionaryText(dictionary, "label"); + return new EnumChoice(optionValue, string.IsNullOrWhiteSpace(label) ? optionValue : label); + } + + private static string ReadDictionaryText(IReadOnlyDictionary dictionary, string key) + { + if (!dictionary.TryGetValue(key, out object? value) || value is null) + return string.Empty; + + return NormalizeJsonValue(value)?.ToString() ?? string.Empty; + } + + private static IReadOnlyList ReadOptionsFromJson(JsonElement json) + { + if (json.ValueKind != JsonValueKind.Array) + return []; + + return json.EnumerateArray() + .Select(ReadOptionFromJson) + .Where(choice => choice is not null) + .Select(choice => choice!) + .ToArray(); + } + + private static EnumChoice? ReadOptionFromJson(JsonElement json) + { + if (json.ValueKind == JsonValueKind.Object) + { + string optionValue = json.TryGetProperty("value", out JsonElement valueElement) + ? ReadJsonText(valueElement) + : string.Empty; + if (string.IsNullOrWhiteSpace(optionValue)) + return null; + + string label = json.TryGetProperty("label", out JsonElement labelElement) + ? ReadJsonText(labelElement) + : optionValue; + return new EnumChoice(optionValue, string.IsNullOrWhiteSpace(label) ? optionValue : label); + } + + string scalar = ReadJsonText(json); + return string.IsNullOrWhiteSpace(scalar) + ? null + : new EnumChoice(scalar, scalar); + } + + private static string ReadJsonText(JsonElement value) + => value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ToString(); + + private static string LookupField(IReadOnlyDictionary row, string fieldName) + { + if (row.TryGetValue(fieldName, out object? value) && value is not null) + return value.ToString() ?? string.Empty; + + string? actualKey = row.Keys.FirstOrDefault(candidate => string.Equals(candidate, fieldName, StringComparison.OrdinalIgnoreCase)); + return actualKey is not null && row.TryGetValue(actualKey, out value) && value is not null + ? value.ToString() ?? string.Empty + : string.Empty; + } + + private static string BuildDisplayLabel(IReadOnlyDictionary row, IReadOnlyList displayFields) + { + string label = string.Join(" - ", displayFields.Select(field => LookupField(row, field)).Where(value => value.Length > 0)); + return label.Length == 0 && displayFields.Count > 0 + ? LookupField(row, displayFields[0]) + : label; + } + + private static object? NormalizeJsonValue(object? value) + => value is JsonElement json ? NormalizeJsonElement(json) : value; + + private static object? NormalizeJsonElement(JsonElement json) + { + return json.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => json.GetString(), + JsonValueKind.Number when json.TryGetInt64(out long integer) => integer, + JsonValueKind.Number => json.GetDouble(), + _ => json, + }; + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs b/src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs new file mode 100644 index 00000000..140fce96 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormControlRegistry.cs @@ -0,0 +1,33 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class FormControlRegistry : IFormControlRegistry +{ + private readonly Dictionary _controls; + private readonly IReadOnlyList _orderedControls; + + internal FormControlRegistry(IEnumerable controls) + { + _orderedControls = controls + .OrderBy(control => control.ToolboxGroupOrder) + .ThenBy(control => control.ToolboxOrder) + .ThenBy(control => control.DisplayName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + _controls = _orderedControls.ToDictionary( + control => control.ControlType, + control => control, + StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList Controls => _orderedControls; + + public bool TryGetControl(string controlType, out FormControlDescriptor descriptor) + => _controls.TryGetValue(controlType, out descriptor!); + + public IReadOnlyList GetToolboxControls() + => _orderedControls + .Where(control => control.ShowInToolbox) + .ToArray(); +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs new file mode 100644 index 00000000..4534042e --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryBuilder.cs @@ -0,0 +1,61 @@ +using CSharpDB.Admin.Forms.Models; + +namespace CSharpDB.Admin.Forms.Services; + +public sealed class FormControlRegistryBuilder +{ + private readonly Dictionary _controls = new(StringComparer.OrdinalIgnoreCase); + + public FormControlRegistryBuilder Add(FormControlDescriptor descriptor) + { + ValidateDescriptor(descriptor); + + if (_controls.TryGetValue(descriptor.ControlType, out FormControlDescriptor? existing)) + { + if (existing.IsBuiltIn && descriptor.ReplaceBuiltInRuntime && descriptor.RuntimeComponentType is not null) + { + _controls[existing.ControlType] = existing with + { + RuntimeComponentType = descriptor.RuntimeComponentType, + ReplaceBuiltInRuntime = true, + }; + return this; + } + + throw new InvalidOperationException($"A form control with type '{descriptor.ControlType}' is already registered."); + } + + _controls.Add(descriptor.ControlType, descriptor); + return this; + } + + internal FormControlRegistry Build() => new(_controls.Values); + + private static void ValidateDescriptor(FormControlDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + if (string.IsNullOrWhiteSpace(descriptor.ControlType)) + throw new ArgumentException("Control type is required.", nameof(descriptor)); + if (string.IsNullOrWhiteSpace(descriptor.DisplayName)) + throw new ArgumentException("Display name is required.", nameof(descriptor)); + if (descriptor.DefaultWidth <= 0) + throw new ArgumentException("Default width must be greater than zero.", nameof(descriptor)); + if (descriptor.DefaultHeight <= 0) + throw new ArgumentException("Default height must be greater than zero.", nameof(descriptor)); + + FormControlDescriptor.ValidateComponentType(descriptor.DesignerPreviewComponentType, nameof(descriptor.DesignerPreviewComponentType)); + FormControlDescriptor.ValidateComponentType(descriptor.RuntimeComponentType, nameof(descriptor.RuntimeComponentType)); + FormControlDescriptor.ValidateComponentType(descriptor.PropertyEditorComponentType, nameof(descriptor.PropertyEditorComponentType)); + + foreach (FormControlPropertyDescriptor property in descriptor.PropertyDescriptors) + { + if (string.IsNullOrWhiteSpace(property.Name)) + throw new ArgumentException("Property descriptor name is required.", nameof(descriptor)); + if (string.IsNullOrWhiteSpace(property.Label)) + throw new ArgumentException("Property descriptor label is required.", nameof(descriptor)); + if (property.Editor == FormControlPropertyEditor.Select && property.Options.Count == 0) + throw new ArgumentException($"Select property '{property.Name}' must define options.", nameof(descriptor)); + } + } +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs new file mode 100644 index 00000000..9b8890a2 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Services/FormControlRegistryConfiguration.cs @@ -0,0 +1,12 @@ +namespace CSharpDB.Admin.Forms.Services; + +internal interface IFormControlRegistryConfiguration +{ + void Configure(FormControlRegistryBuilder builder); +} + +internal sealed class DelegateFormControlRegistryConfiguration(Action configure) + : IFormControlRegistryConfiguration +{ + public void Configure(FormControlRegistryBuilder builder) => configure(builder); +} diff --git a/src/CSharpDB.Admin.Forms/Services/FormSql.cs b/src/CSharpDB.Admin.Forms/Services/FormSql.cs index 83bd6720..f33cbdee 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormSql.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormSql.cs @@ -27,7 +27,7 @@ public static string FormatLiteral(object? value) long integer => integer.ToString(CultureInfo.InvariantCulture), double real => real.ToString(CultureInfo.InvariantCulture), string text => $"'{text.Replace("'", "''", StringComparison.Ordinal)}'", - byte[] => throw new InvalidOperationException("Blob literals are not supported."), + byte[] blob => $"X'{Convert.ToHexString(blob)}'", _ => $"'{Convert.ToString(normalized, CultureInfo.InvariantCulture)?.Replace("'", "''", StringComparison.Ordinal) ?? string.Empty}'", }; } diff --git a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css index b9f3e5d8..e4010418 100644 --- a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css +++ b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css @@ -369,6 +369,13 @@ margin-top: 4px; } +.pi-help-text { + color: var(--cdb-muted, #64748b); + font-size: 11px; + line-height: 1.35; + margin-top: 4px; +} + .pi-readonly { background: #f5f5f5 !important; color: #888; @@ -385,6 +392,20 @@ margin-bottom: 0; } +.pi-anchor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 8px; +} + +.pi-anchor-grid label { + display: flex; + align-items: center; + gap: 6px; + color: #555; + font-size: 12px; +} + .pi-btn { flex: 1; padding: 4px 8px; @@ -621,12 +642,20 @@ max-width: 100%; } +.form-renderer-fixed { + height: 100%; +} + .fr-control { position: absolute; - left: var(--fr-left); - top: var(--fr-top); - width: var(--fr-width); - height: var(--fr-height); + left: var(--fr-left, auto); + top: var(--fr-top, auto); + right: var(--fr-right, auto); + bottom: var(--fr-bottom, auto); + width: var(--fr-width, auto); + height: var(--fr-height, auto); + min-width: var(--fr-min-width, 0); + min-height: var(--fr-min-height, 0); box-sizing: border-box; } @@ -642,7 +671,8 @@ position: static; width: auto; height: auto; - min-height: var(--fr-height); + min-width: var(--fr-min-width, 0); + min-height: var(--fr-elastic-min-height, var(--fr-min-height, 0)); order: var(--fr-stack-order); grid-column: span var(--fr-grid-span); } @@ -735,8 +765,11 @@ position: static; left: auto; top: auto; + right: auto; + bottom: auto; width: 100%; height: auto; + min-width: 0; min-height: 44px; order: var(--fr-stack-order); grid-column: 1 / -1; @@ -810,8 +843,11 @@ position: static; left: auto; top: auto; + right: auto; + bottom: auto; width: 100%; height: auto; + min-width: 0; min-height: 44px; order: var(--fr-stack-order); grid-column: 1 / -1; @@ -896,6 +932,275 @@ cursor: pointer; } +.fr-listbox { + height: 100%; + padding: 4px; +} + +.fr-option-group { + display: flex; + gap: 6px; + height: 100%; + align-content: flex-start; + overflow: auto; +} + +.fr-option-vertical { + flex-direction: column; +} + +.fr-option-horizontal { + flex-direction: row; + flex-wrap: wrap; +} + +.fr-option { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; +} + +.fr-option-buttons .fr-option { + border: 1px solid var(--fd-border, #c9d2df); + border-radius: 4px; + padding: 4px 8px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); +} + +.fr-option-buttons .fr-option:has(input:checked) { + border-color: var(--fd-accent, #1a73e8); + background: var(--fd-accent-soft, #e8f0fe); + color: var(--fd-accent, #174ea6); +} + +.fr-toggle-button { + width: 100%; + height: 100%; + border: 1px solid var(--fd-border, #c0c0c0); + border-radius: 4px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + font: inherit; + cursor: pointer; +} + +.fr-toggle-button.active { + border-color: var(--fd-accent, #188038); + background: var(--fd-accent-soft, #e6f4ea); + color: var(--fd-accent, #137333); +} + +.fr-toggle-button:disabled { + opacity: 0.6; + cursor: default; +} + +.fr-tab-control { + display: flex; + flex-direction: column; + height: 100%; + min-height: 120px; + border: 1px solid var(--fd-border, #c9d2df); + border-radius: 4px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + overflow: hidden; +} + +.fr-tab-bar { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--fd-border, #c9d2df); + background: var(--fd-bg-tertiary, #f6f8fb); + padding: 4px 4px 0; +} + +.fr-tab-button { + border: 1px solid transparent; + border-bottom: none; + border-radius: 4px 4px 0 0; + background: transparent; + color: var(--fd-text-secondary, #4b5563); + padding: 6px 10px; + font: inherit; + cursor: pointer; +} + +.fr-tab-button.active { + border-color: var(--fd-border, #c9d2df); + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + margin-bottom: -1px; +} + +.fr-tab-page { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + padding: 12px; +} + +.fr-tab-page .form-renderer { + min-height: 100%; +} + +.fr-tab-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 80px; + color: var(--fd-text-muted, #999); + font-size: 12px; + font-style: italic; +} + +.fr-subform { + height: 100%; + min-height: 180px; + border: 1px solid var(--fd-border, #d0d7de); + border-radius: 4px; + overflow: hidden; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); +} + +.fr-subform .data-entry-layout { + height: 100%; +} + +.data-entry-no-record-list { + grid-template-columns: minmax(0, 1fr); +} + +.data-entry-no-record-list .de-toolbar, +.data-entry-no-record-list .de-error, +.data-entry-no-record-list .de-print-header, +.data-entry-no-record-list .de-form-area, +.data-entry-no-record-list .de-loading { + grid-column: 1; +} + +.data-entry-embedded { + height: 100%; + min-height: 0; + background: var(--fd-bg-primary, #fff); +} + +.data-entry-embedded .de-form-area { + margin: 0; + border-radius: 0; + box-shadow: none; + min-height: 0; +} + +.data-entry-no-toolbar { + grid-template-rows: 1fr; +} + +.fr-attachment, +.fr-image-control { + display: flex; + flex-direction: column; + gap: 6px; + height: 100%; + min-height: 60px; + border: 1px solid var(--fd-border, #d0d7de); + border-radius: 4px; + padding: 8px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + box-sizing: border-box; + overflow: hidden; +} + +.fr-attachment-summary { + color: var(--fd-text-secondary, #4b5563); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fr-attachment-actions, +.fr-image-actions { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.fr-attachment-actions input[type="file"], +.fr-image-actions input[type="file"] { + min-width: 0; + flex: 1; +} + +.fr-attachment-clear { + border: 1px solid var(--fd-border, #c9d2df); + border-radius: 4px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #1a1a1a); + padding: 4px 8px; + font: inherit; + cursor: pointer; +} + +.fr-attachment-clear:disabled { + opacity: 0.5; + cursor: default; +} + +.fr-image-control img { + flex: 1; + width: 100%; + min-height: 0; + border-radius: 3px; + background: var(--fd-bg-tertiary, #f6f8fb); +} + +.fr-image-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 80px; + color: var(--fd-text-muted, #999); + border: 1px dashed var(--fd-border, #c9d2df); + border-radius: 3px; +} + +.preview-option-group, +.preview-attachment, +.preview-image { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 100%; + border: 1px solid #a0a0a0; + border-radius: 3px; + padding: 6px; + background: #fafafa; + box-sizing: border-box; + color: #555; + font-size: 12px; +} + +.preview-option-group { + flex-direction: column; + align-items: flex-start; +} + +.preview-image { + justify-content: center; + border-style: dashed; +} + /* ===== Feature 4: Editable Form Name ===== */ .toolbar-title-editable { cursor: pointer; @@ -1637,16 +1942,22 @@ } .preview-childtabs-tab { + appearance: none; + background: transparent; + border: 0; + border-right: 1px solid #ddd; padding: 3px 10px; font-size: 10px; font-weight: 500; color: #555; - border-right: 1px solid #ddd; + font-family: inherit; + text-align: left; } -.preview-childtabs-tab:first-child { - color: #1a73e8; - background: #fff; +.preview-childtabs-tab:first-child, +.preview-childtabs-tab.active { + color: var(--fd-accent, #1a73e8); + background: var(--fd-bg-elevated, #fff); font-weight: 600; } @@ -1655,6 +1966,76 @@ overflow: hidden; } +.preview-tab-designer, +.preview-tab-designer * { + pointer-events: auto; +} + +.preview-tab-designer .preview-childtabs-tab { + cursor: pointer; +} + +.preview-tab-designer .preview-childtabs-tab:first-child:not(.active) { + background: transparent !important; + color: var(--fd-text-secondary, #555) !important; + font-weight: 500; +} + +.preview-tab-page { + position: relative; + overflow: auto; + background: var(--fd-bg-elevated, #fff); +} + +.preview-tab-page .preview-childtabs-empty { + min-height: 100%; +} + +.design-tab-child { + position: absolute; + box-sizing: border-box; + min-width: 24px; + min-height: 16px; + border: 1px solid var(--fd-border-light, #a0a0a0); + border-radius: 2px; + background: var(--fd-bg-elevated, #fff); + color: var(--fd-text, #333); + cursor: grab; + overflow: visible; +} + +.design-tab-child:hover, +.design-tab-child.selected { + border-color: var(--fd-accent, #1a73e8); +} + +.design-tab-child-content { + width: 100%; + height: 100%; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + padding: 2px 6px; + box-sizing: border-box; + pointer-events: none; +} + +.design-tab-child-type { + flex: 0 0 auto; + font-size: 10px; + font-weight: 600; + color: var(--fd-accent, #1a73e8); +} + +.design-tab-child-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; +} + .preview-childtabs-empty { display: flex; align-items: center; @@ -2081,6 +2462,7 @@ .ctp-tab-bar, .preview-datagrid-row.header-row, .preview-childtabs-tabbar, +.fr-tab-bar, .toolbox-header, .layers-header, .pi-header, @@ -2099,11 +2481,16 @@ .property-inspector, .de-record-list, .child-datagrid, +.fr-tab-control, +.fr-subform, +.fr-attachment, +.fr-image-control, .tce-tab-entry, .design-canvas, .de-form-area, .preview-datagrid, .preview-childtabs, +.design-tab-child, .child-tab-panel.ctp-nested { border-color: var(--fd-border); } @@ -2118,7 +2505,9 @@ .preview-label, .preview-checkbox, .preview-datagrid-row.header-row, -.preview-childtabs-tab:first-child { +.preview-childtabs-tab:first-child, +.preview-childtabs-tab.active, +.fr-tab-button.active { color: var(--fd-text); } @@ -2145,6 +2534,9 @@ .cdg-loading, .ctp-select-prompt, .fr-datagrid-placeholder, +.fr-tab-empty, +.fr-image-empty, +.fr-attachment-summary, .preview-childtabs-empty { color: var(--fd-text-muted); } @@ -2170,6 +2562,8 @@ .de-search-select, .de-search-input, .fr-input, +.fr-toggle-button, +.fr-attachment-clear, .fr-command-button, .preview-command-button, .cdg-cell-input, @@ -2260,6 +2654,9 @@ .de-record-row:hover, .ctp-tab:hover, .cdg-cell:hover, +.fr-tab-button:hover, +.fr-attachment-clear:hover, +.fr-toggle-button:hover, .fr-command-button:hover { background: var(--fd-bg-hover); color: var(--fd-text); @@ -2397,8 +2794,13 @@ .de-form-area, .child-datagrid, +.fr-tab-control, +.fr-subform, +.fr-attachment, +.fr-image-control, .preview-datagrid, -.preview-childtabs { +.preview-childtabs, +.design-tab-child { background: var(--fd-bg-elevated); color: var(--fd-text); } @@ -2458,6 +2860,7 @@ .de-record-row, .ctp-tab, .preview-childtabs-tab, +.fr-tab-button, .pi-field label, .tce-field label, .feb-field label, @@ -2474,6 +2877,10 @@ .toolbar-active, .cdg-selected, .preview-childtabs-tab:first-child, +.preview-childtabs-tab.active, +.fr-tab-button.active, +.fr-option-buttons .fr-option:has(input:checked), +.fr-toggle-button.active, .table-designer-grid tbody tr.selected { background: var(--fd-accent-soft) !important; color: var(--fd-accent) !important; @@ -2547,6 +2954,7 @@ .table-designer-preview, .preview-datagrid-row.header-row, .preview-childtabs-tabbar, +.fr-tab-bar, .ctp-tab-bar { background: var(--fd-bg-tertiary); } @@ -2555,6 +2963,10 @@ .de-record-row, .layer-row, .toolbox-group, +.fr-tab-bar, +.fr-tab-button.active, +.fr-attachment-clear, +.fr-image-empty, .pi-section, .tce-tab-entry, .feb-entry, diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor new file mode 100644 index 00000000..7f8e3baa --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor @@ -0,0 +1,43 @@ +@namespace CSharpDB.Admin.Components.Samples.FormControls +@using System.Globalization +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models + +
+
@Label
+
+ @for (int value = 1; value <= MaxRating; value++) + { + @value + } +
+
+ +@code { + [Parameter, EditorRequired] public FormControlDesignContext Context { get; set; } = default!; + + private string Label + => string.IsNullOrWhiteSpace(Context.Control.Binding?.FieldName) + ? "Rating" + : Context.Control.Binding!.FieldName; + + private int MaxRating => Math.Clamp(ReadIntProp("max", 5), 1, 10); + + private int PreviewValue => Math.Min(3, MaxRating); + + private int ReadIntProp(string key, int fallback) + { + if (!Context.Control.Props.Values.TryGetValue(key, out object? value) || value is null) + return fallback; + + return value switch + { + int i => i, + long l => (int)l, + double d => (int)d, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt32(out int parsed) => parsed, + _ when int.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) => parsed, + _ => fallback, + }; + } +} diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor.css b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor.css new file mode 100644 index 00000000..16bb84e9 --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor.css @@ -0,0 +1,41 @@ +.sample-rating-preview { + display: grid; + gap: 6px; + height: 100%; + min-height: 0; +} + +.sample-rating-preview-label { + color: var(--cdb-text, #1f2937); + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sample-rating-preview-scale { + display: flex; + gap: 4px; + min-width: 0; +} + +.sample-rating-preview-step { + align-items: center; + background: var(--cdb-surface-muted, #f8fafc); + border: 1px solid var(--cdb-border, #cbd5e1); + border-radius: 4px; + color: var(--cdb-text, #1f2937); + display: inline-flex; + font-size: 12px; + font-weight: 600; + height: 26px; + justify-content: center; + min-width: 28px; +} + +.sample-rating-preview-step.active { + background: var(--cdb-primary, #2563eb); + border-color: var(--cdb-primary, #2563eb); + color: var(--cdb-primary-contrast, #fff); +} diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor new file mode 100644 index 00000000..9d659f63 --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor @@ -0,0 +1,72 @@ +@namespace CSharpDB.Admin.Components.Samples.FormControls +@using System.Globalization +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models + +
+
+ + +
+
+ + +
+
+ + +
+
+ +@code { + [Parameter, EditorRequired] public FormControlPropertyContext Context { get; set; } = default!; + + private Task SetIntPropAsync(string key, object? value, int fallback) + => Context.SetPropertyAsync(key, Math.Clamp(ReadIntValue(value, fallback), 1, 10)); + + private int GetIntProp(string key, int fallback) + => Context.Control.Props.Values.TryGetValue(key, out object? value) + ? ReadIntValue(value, fallback) + : fallback; + + private string GetStringProp(string key, string fallback) + => Context.Control.Props.Values.TryGetValue(key, out object? value) && value is not null + ? value.ToString() ?? fallback + : fallback; + + private bool GetBoolProp(string key, bool fallback) + { + if (!Context.Control.Props.Values.TryGetValue(key, out object? value) || value is null) + return fallback; + + return value switch + { + bool b => b, + JsonElement json when json.ValueKind == JsonValueKind.True => true, + JsonElement json when json.ValueKind == JsonValueKind.False => false, + _ when bool.TryParse(value.ToString(), out bool parsed) => parsed, + _ => fallback, + }; + } + + private static int ReadIntValue(object? value, int fallback) + => value switch + { + int i => i, + long l => (int)l, + double d => (int)d, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt32(out int parsed) => parsed, + _ when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) => parsed, + _ => fallback, + }; +} diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor.css b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor.css new file mode 100644 index 00000000..dbef2577 --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor.css @@ -0,0 +1,4 @@ +.sample-rating-property-editor { + display: grid; + gap: 8px; +} diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor new file mode 100644 index 00000000..7c7154cf --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor @@ -0,0 +1,79 @@ +@namespace CSharpDB.Admin.Components.Samples.FormControls +@using System.Globalization +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models + +
+
@Label
+
+ @for (int value = 1; value <= MaxRating; value++) + { + int rating = value; + + } +
+ @if (!string.IsNullOrWhiteSpace(Context.ValidationError)) + { +
@Context.ValidationError
+ } +
+ +@code { + [Parameter, EditorRequired] public FormControlRuntimeContext Context { get; set; } = default!; + + private string Label + => string.IsNullOrWhiteSpace(Context.FieldName) + ? Context.Descriptor.DisplayName + : Context.FieldName!; + + private int MaxRating => Math.Clamp(ReadIntProp("max", 5), 1, 10); + + private int CurrentValue => Math.Clamp(ReadIntValue(Context.BoundValue, 0), 0, MaxRating); + + private bool IsLocked => !Context.IsEnabled || Context.IsReadOnly; + + private string ControlClass + => "sample-rating-control" + + (IsLocked ? " locked" : "") + + (!string.IsNullOrWhiteSpace(Context.ValidationError) ? " invalid" : ""); + + private async Task SetRatingAsync(int rating) + { + if (IsLocked) + return; + + await Context.SetValueAsync(rating); + await Context.DispatchEventAsync( + ControlEventKind.OnClick, + new Dictionary + { + ["rating"] = rating, + ["max"] = MaxRating, + }); + } + + private int ReadIntProp(string key, int fallback) + { + if (!Context.Control.Props.Values.TryGetValue(key, out object? value) || value is null) + return fallback; + + return ReadIntValue(value, fallback); + } + + private static int ReadIntValue(object? value, int fallback) + => value switch + { + int i => i, + long l => (int)l, + double d => (int)d, + JsonElement json when json.ValueKind == JsonValueKind.Number && json.TryGetInt32(out int parsed) => parsed, + _ when int.TryParse(value?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) => parsed, + _ => fallback, + }; +} diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor.css b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor.css new file mode 100644 index 00000000..92ee171a --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor.css @@ -0,0 +1,56 @@ +.sample-rating-control { + display: grid; + gap: 6px; + height: 100%; + min-height: 0; +} + +.sample-rating-label { + color: var(--cdb-text, #1f2937); + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sample-rating-buttons { + display: flex; + gap: 4px; + min-width: 0; +} + +.sample-rating-button { + align-items: center; + background: var(--cdb-surface-muted, #f8fafc); + border: 1px solid var(--cdb-border, #cbd5e1); + border-radius: 4px; + color: var(--cdb-text, #1f2937); + cursor: pointer; + display: inline-flex; + font-size: 12px; + font-weight: 600; + height: 26px; + justify-content: center; + min-width: 28px; +} + +.sample-rating-button.active { + background: var(--cdb-primary, #2563eb); + border-color: var(--cdb-primary, #2563eb); + color: var(--cdb-primary-contrast, #fff); +} + +.sample-rating-control.locked .sample-rating-button { + cursor: default; + opacity: 0.72; +} + +.sample-rating-control.invalid .sample-rating-button { + border-color: var(--cdb-danger, #dc2626); +} + +.sample-rating-error { + color: var(--cdb-danger, #dc2626); + font-size: 11px; +} diff --git a/src/CSharpDB.Admin/Components/Samples/FormControls/SampleRatingControlRegistration.cs b/src/CSharpDB.Admin/Components/Samples/FormControls/SampleRatingControlRegistration.cs new file mode 100644 index 00000000..81a24924 --- /dev/null +++ b/src/CSharpDB.Admin/Components/Samples/FormControls/SampleRatingControlRegistration.cs @@ -0,0 +1,30 @@ +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; + +namespace CSharpDB.Admin.Components.Samples.FormControls; + +public static class SampleRatingControlRegistration +{ + public static IServiceCollection AddSampleFormControls(this IServiceCollection services) + => services.AddCSharpDbAdminFormControls(builder => builder.Add(new FormControlDescriptor + { + ControlType = "sampleRating", + DisplayName = "Rating", + ToolboxGroup = "Custom", + IconText = "*", + Description = "Sample custom scalar rating control.", + DefaultWidth = 220, + DefaultHeight = 48, + SupportsBinding = true, + ParticipatesInTabOrder = true, + DefaultProps = new Dictionary + { + ["max"] = 5, + ["displayMode"] = "buttons", + ["required"] = false, + }, + DesignerPreviewComponentType = typeof(RatingDesignerPreview), + RuntimeComponentType = typeof(RatingRuntimeControl), + PropertyEditorComponentType = typeof(RatingPropertyEditor), + })); +} diff --git a/src/CSharpDB.Admin/Program.cs b/src/CSharpDB.Admin/Program.cs index 3a1c52f0..eb635e88 100644 --- a/src/CSharpDB.Admin/Program.cs +++ b/src/CSharpDB.Admin/Program.cs @@ -1,5 +1,6 @@ using CSharpDB.Admin.Configuration; using CSharpDB.Admin.Components; +using CSharpDB.Admin.Components.Samples.FormControls; using CSharpDB.Admin.Forms.Services; using CSharpDB.Admin.Reports.Services; using CSharpDB.Admin.Services; @@ -43,6 +44,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddCSharpDbAdminForms(); +if (builder.Configuration.GetValue("AdminForms:EnableSampleControls")) + builder.Services.AddSampleFormControls(); builder.Services.AddCSharpDbAdminReports(); var app = builder.Build(); diff --git a/src/CSharpDB.Client/Internal/EngineTransportClient.cs b/src/CSharpDB.Client/Internal/EngineTransportClient.cs index 08fe12be..213a90e2 100644 --- a/src/CSharpDB.Client/Internal/EngineTransportClient.cs +++ b/src/CSharpDB.Client/Internal/EngineTransportClient.cs @@ -1019,7 +1019,7 @@ private static string FormatSqlLiteral(object? value) long integer => integer.ToString(CultureInfo.InvariantCulture), double real => real.ToString(CultureInfo.InvariantCulture), string text => $"'{text.Replace("'", "''", StringComparison.Ordinal)}'", - byte[] => throw new CSharpDbClientException("Blob parameters are not supported by the engine-only client."), + byte[] blob => $"X'{Convert.ToHexString(blob)}'", _ => $"'{Convert.ToString(normalized, CultureInfo.InvariantCulture)?.Replace("'", "''", StringComparison.Ordinal) ?? string.Empty}'", }; } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/SampleFormControlsTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/SampleFormControlsTests.cs new file mode 100644 index 00000000..64b1ddff --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/SampleFormControlsTests.cs @@ -0,0 +1,29 @@ +using CSharpDB.Admin.Components.Samples.FormControls; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Forms.Tests.Admin; + +public sealed class SampleFormControlsTests +{ + [Fact] + public void AddSampleFormControls_RegistersCompiledRatingControl() + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + services.AddSampleFormControls(); + + using ServiceProvider provider = services.BuildServiceProvider(); + IFormControlRegistry registry = provider.GetRequiredService(); + + Assert.True(registry.TryGetControl("sampleRating", out FormControlDescriptor descriptor)); + Assert.Equal("Rating", descriptor.DisplayName); + Assert.Equal("Custom", descriptor.ToolboxGroup); + Assert.Equal(typeof(RatingDesignerPreview), descriptor.DesignerPreviewComponentType); + Assert.Equal(typeof(RatingRuntimeControl), descriptor.RuntimeComponentType); + Assert.Equal(typeof(RatingPropertyEditor), descriptor.PropertyEditorComponentType); + Assert.Equal(5, descriptor.CreateDefaultProps()["max"]); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs index e1bd334c..c5320acc 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/ChildDataGridTests.cs @@ -277,6 +277,9 @@ public Task SearchRecordPageAsync(FormTableDefinition table, str public Task> UpdateRecordAsync(FormTableDefinition table, object pkValue, Dictionary values, CancellationToken ct = default) => throw new NotSupportedException(); + public Task SaveAttachmentAsync(FormAttachmentTableBinding binding, object parentValue, FormAttachmentValue attachment, CancellationToken ct = default) + => throw new NotSupportedException(); + public Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default) => throw new NotSupportedException(); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs index 3e0d3dfb..74cfd85c 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/DesignerStateTests.cs @@ -306,6 +306,83 @@ public void UpdateControlEventBindings_ReplacesActionSequences() Assert.Equal("Queued.", step.Message); } + [Fact] + public void UpdateControlProps_UpdatesMultiplePropertiesWithOneUndoSnapshot() + { + var state = new DesignerState(); + ControlDefinition text = new( + "status", + "text", + new Rect(0, 0, 160, 32), + new BindingDefinition("Status", "TwoWay"), + PropertyBag.Empty, + null); + state.LoadForm(CreateForm() with { Controls = [text] }); + + state.UpdateControlProps( + "status", + new Dictionary + { + ["anchorLeft"] = true, + ["anchorRight"] = true, + ["minWidth"] = 120L, + }); + + ControlDefinition updated = Assert.Single(state.ToFormDefinition().Controls); + Assert.Equal(true, updated.Props.Values["anchorLeft"]); + Assert.Equal(true, updated.Props.Values["anchorRight"]); + Assert.Equal(120L, updated.Props.Values["minWidth"]); + + state.Undo(); + + ControlDefinition reverted = Assert.Single(state.ToFormDefinition().Controls); + Assert.Empty(reverted.Props.Values); + } + + [Fact] + public void DeleteSelected_RemovesTabChildren() + { + var state = new DesignerState(); + state.LoadForm(CreateForm() with + { + Controls = + [ + CreateTabControl("tabs"), + CreateTabChild("child", "tabs", "main"), + ], + }); + + state.SelectControl("tabs", addToSelection: false); + state.DeleteSelected(); + + Assert.Empty(state.ToFormDefinition().Controls); + } + + [Fact] + public void CopyPaste_RemapsCopiedTabChildrenToCopiedParent() + { + var state = new DesignerState(); + state.LoadForm(CreateForm() with + { + Controls = + [ + CreateTabControl("tabs"), + CreateTabChild("child", "tabs", "main"), + ], + }); + + state.SelectControl("tabs", addToSelection: false); + state.CopySelected(); + state.PasteClipboard(); + + FormDefinition saved = state.ToFormDefinition(); + Assert.Equal(4, saved.Controls.Count); + ControlDefinition pastedParent = Assert.Single(saved.Controls, control => control.ControlType == "tabControl" && control.ControlId != "tabs"); + ControlDefinition pastedChild = Assert.Single(saved.Controls, control => control.ControlId is not "child" && control.ControlType == "text"); + Assert.Equal(pastedParent.ControlId, pastedChild.Props.Values["parentControlId"]); + Assert.Equal("main", pastedChild.Props.Values["parentTabId"]); + } + private static FormDefinition CreateForm() => new( "customers-form", @@ -315,4 +392,32 @@ private static FormDefinition CreateForm() "sig:customers", new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), []); + + private static ControlDefinition CreateTabControl(string controlId) + => new( + controlId, + "tabControl", + new Rect(0, 0, 400, 240), + null, + new PropertyBag(new Dictionary + { + ["tabs"] = new object?[] + { + new Dictionary { ["id"] = "main", ["label"] = "Main" }, + }, + }), + null); + + private static ControlDefinition CreateTabChild(string controlId, string parentControlId, string parentTabId) + => new( + controlId, + "text", + new Rect(16, 48, 160, 32), + new BindingDefinition("Name", "TwoWay"), + new PropertyBag(new Dictionary + { + ["parentControlId"] = parentControlId, + ["parentTabId"] = parentTabId, + }), + null); } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryDesignerTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryDesignerTests.cs new file mode 100644 index 00000000..ddf27ea2 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryDesignerTests.cs @@ -0,0 +1,305 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using CSharpDB.Admin.Forms.Components.Designer; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Forms.Tests.Components.Designer; + +public sealed class FormControlRegistryDesignerTests +{ + [Fact] + public void Toolbox_GroupsCustomControlsFromRegistry() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + var toolbox = new Toolbox + { + State = new DesignerState(), + ControlRegistry = registry, + }; + + var groups = InvokeNonPublic Controls)>>( + toolbox, + "GetToolboxGroups"); + + Assert.Contains( + groups, + group => group.Name == "Custom" && group.Controls.Any(control => control.ControlType == "rating")); + } + + [Fact] + public void DesignCanvas_PlacesRegisteredControlWithDefaultSizePropsAndBinding() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + var state = new DesignerState(); + state.LoadForm(CreateForm([])); + state.ActiveTool = "rating"; + var canvas = new DesignCanvas + { + State = state, + ControlRegistry = registry, + }; + + InvokeNonPublic( + canvas, + "PlaceNewControl", + new PointerEventArgs + { + OffsetX = 13, + OffsetY = 21, + }); + + ControlDefinition control = Assert.Single(state.ToFormDefinition().Controls); + Assert.Equal("rating", control.ControlType); + Assert.Equal(new Rect(16, 24, 180, 42), control.Rect); + Assert.NotNull(control.Binding); + Assert.Equal(string.Empty, control.Binding!.FieldName); + Assert.Equal("star", control.Props.Values["displayMode"]); + Assert.Null(state.ActiveTool); + } + + [Fact] + public void DesignCanvas_PlacesNonBindingRegisteredControlWithoutBinding() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateBadgeDescriptor())); + var state = new DesignerState(); + state.LoadForm(CreateForm([])); + state.ActiveTool = "badge"; + var canvas = new DesignCanvas + { + State = state, + ControlRegistry = registry, + }; + + InvokeNonPublic( + canvas, + "PlaceNewControl", + new PointerEventArgs + { + OffsetX = 8, + OffsetY = 8, + }); + + ControlDefinition control = Assert.Single(state.ToFormDefinition().Controls); + Assert.Equal("badge", control.ControlType); + Assert.Null(control.Binding); + Assert.Equal("info", control.Props.Values["tone"]); + } + + [Fact] + public void DesignCanvas_UsesRegisteredDesignerPreviewComponent() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + ControlDefinition control = CreateRatingControl(); + var canvas = new DesignCanvas + { + State = new DesignerState(), + ControlRegistry = registry, + }; + + object?[] args = [control, null]; + bool resolved = InvokeNonPublic(canvas, "TryGetDesignerPreviewComponent", args); + Type componentType = Assert.IsAssignableFrom(args[1]); + Dictionary parameters = InvokeNonPublic>( + canvas, + "GetDesignerPreviewParameters", + control, + true, + control.Rect); + + Assert.True(resolved); + Assert.Equal(typeof(RatingPreviewComponent), componentType); + var context = Assert.IsType(parameters["Context"]); + Assert.Equal(control.ControlId, context.Control.ControlId); + Assert.True(context.IsSelected); + Assert.Equal("Rating", context.Descriptor.DisplayName); + } + + [Fact] + public void PropertyInspector_TypeDropdownUsesRegistry() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + var inspector = new PropertyInspector + { + State = new DesignerState(), + ControlRegistry = registry, + }; + + IReadOnlyList controls = InvokeNonPublic>( + inspector, + "GetTypeDropdownControls"); + + Assert.Contains(controls, control => control.ControlType == "rating"); + } + + [Fact] + public void PropertyInspector_GenericPropertyEditingUpdatesSelectedControlProps() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + ControlDefinition rating = CreateRatingControl(); + var state = new DesignerState(); + state.LoadForm(CreateForm([rating])); + state.SelectControl(rating.ControlId, addToSelection: false); + var inspector = new PropertyInspector + { + State = state, + ControlRegistry = registry, + }; + SetField(inspector, "_selected", rating); + + FormControlDescriptor descriptor = registry.Controls.Single(control => control.ControlType == "rating"); + FormControlPropertyDescriptor maxProperty = descriptor.PropertyDescriptors.Single(property => property.Name == "max"); + FormControlPropertyDescriptor requiredProperty = descriptor.PropertyDescriptors.Single(property => property.Name == "required"); + + InvokeNonPublic(inspector, "OnRegisteredPropertyChanged", maxProperty, "7"); + InvokeNonPublic(inspector, "OnRegisteredPropertyChanged", requiredProperty, true); + + ControlDefinition updated = Assert.Single(state.ToFormDefinition().Controls); + Assert.Equal(7L, updated.Props.Values["max"]); + Assert.Equal(true, updated.Props.Values["required"]); + } + + [Fact] + public async Task PropertyInspector_CustomPropertyEditorContextUpdatesProps() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + ControlDefinition rating = CreateRatingControl(); + var state = new DesignerState(); + state.LoadForm(CreateForm([rating])); + state.SelectControl(rating.ControlId, addToSelection: false); + var inspector = new PropertyInspector + { + State = state, + ControlRegistry = registry, + }; + SetField(inspector, "_selected", rating); + FormControlDescriptor descriptor = registry.Controls.Single(control => control.ControlType == "rating"); + + Dictionary parameters = InvokeNonPublic>( + inspector, + "GetPropertyEditorParameters", + descriptor); + var context = Assert.IsType(parameters["Context"]); + + await context.SetPropertyAsync("displayMode", "compact"); + + ControlDefinition updated = Assert.Single(state.ToFormDefinition().Controls); + Assert.Equal("compact", updated.Props.Values["displayMode"]); + } + + private static IFormControlRegistry CreateRegistry(Action configure) + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + services.AddCSharpDbAdminFormControls(configure); + using ServiceProvider provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private static FormControlDescriptor CreateRatingDescriptor() + => new() + { + ControlType = "rating", + DisplayName = "Rating", + ToolboxGroup = "Custom", + IconText = "*", + DefaultWidth = 180, + DefaultHeight = 42, + SupportsBinding = true, + ParticipatesInTabOrder = true, + DefaultProps = new Dictionary { ["displayMode"] = "star" }, + DesignerPreviewComponentType = typeof(RatingPreviewComponent), + RuntimeComponentType = typeof(RatingRuntimeComponent), + PropertyEditorComponentType = typeof(RatingPropertyEditorComponent), + PropertyDescriptors = + [ + new FormControlPropertyDescriptor + { + Name = "max", + Label = "Max", + Editor = FormControlPropertyEditor.Number, + DefaultValue = 5L, + }, + new FormControlPropertyDescriptor + { + Name = "required", + Label = "Required", + Editor = FormControlPropertyEditor.Checkbox, + DefaultValue = false, + }, + ], + }; + + private static FormControlDescriptor CreateBadgeDescriptor() + => new() + { + ControlType = "badge", + DisplayName = "Badge", + ToolboxGroup = "Custom", + IconText = "B", + DefaultWidth = 96, + DefaultHeight = 28, + SupportsBinding = false, + ParticipatesInTabOrder = false, + DefaultProps = new Dictionary { ["tone"] = "info" }, + }; + + private static ControlDefinition CreateRatingControl() + => new( + "rating1", + "rating", + new Rect(0, 0, 180, 42), + new BindingDefinition("Rating", "TwoWay"), + new PropertyBag(new Dictionary { ["displayMode"] = "star" }), + null); + + private static FormDefinition CreateForm(IReadOnlyList controls) + => new( + "custom-form", + "Custom Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + controls); + + private static void InvokeNonPublic(object instance, string methodName, params object?[] args) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + method.Invoke(instance, args); + } + + private static T InvokeNonPublic(object instance, string methodName, params object?[] args) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + return (T)method.Invoke(instance, args)!; + } + + private static void SetField(object instance, string fieldName, object? value) + { + FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Field '{fieldName}' was not found."); + field.SetValue(instance, value); + } + + private sealed class RatingPreviewComponent : ComponentBase + { + [Parameter] public FormControlDesignContext Context { get; set; } = default!; + } + + private sealed class RatingRuntimeComponent : ComponentBase + { + [Parameter] public FormControlRuntimeContext Context { get; set; } = default!; + } + + private sealed class RatingPropertyEditorComponent : ComponentBase + { + [Parameter] public FormControlPropertyContext Context { get; set; } = default!; + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryRuntimeTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryRuntimeTests.cs new file mode 100644 index 00000000..19fc0000 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormControlRegistryRuntimeTests.cs @@ -0,0 +1,255 @@ +using System.Reflection; +using CSharpDB.Admin.Forms.Components.Designer; +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Forms.Tests.Components.Designer; + +public sealed class FormControlRegistryRuntimeTests +{ + [Fact] + public void FormRenderer_ResolvesCustomRuntimeComponent() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + ControlDefinition control = CreateRatingControl(); + var renderer = CreateRenderer(registry, DbCommandRegistry.Empty, CreateForm(control)); + + object?[] args = [control, null]; + bool resolved = InvokeNonPublic(renderer, "TryGetRuntimeComponent", args); + + Assert.True(resolved); + Assert.Equal(typeof(RatingRuntimeComponent), Assert.IsAssignableFrom(args[1])); + } + + [Fact] + public void FormRenderer_KeepsBuiltInRendererUnlessReplacementIsOptedIn() + { + IFormControlRegistry registry = CreateRegistry(); + ControlDefinition control = new( + "name", + "text", + new Rect(0, 0, 180, 32), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + null); + var renderer = CreateRenderer(registry, DbCommandRegistry.Empty, CreateForm(control)); + + object?[] args = [control, null]; + bool resolved = InvokeNonPublic(renderer, "TryGetRuntimeComponent", args); + + Assert.False(resolved); + Assert.Null(args[1]); + } + + [Fact] + public void FormRenderer_ResolvesBuiltInRuntimeReplacementWhenOptedIn() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(new FormControlDescriptor + { + ControlType = "text", + DisplayName = "Text Replacement", + RuntimeComponentType = typeof(RatingRuntimeComponent), + ReplaceBuiltInRuntime = true, + })); + ControlDefinition control = new( + "name", + "text", + new Rect(0, 0, 180, 32), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + null); + var renderer = CreateRenderer(registry, DbCommandRegistry.Empty, CreateForm(control)); + + object?[] args = [control, null]; + bool resolved = InvokeNonPublic(renderer, "TryGetRuntimeComponent", args); + + Assert.True(resolved); + Assert.Equal(typeof(RatingRuntimeComponent), Assert.IsAssignableFrom(args[1])); + } + + [Fact] + public void FormRenderer_BuildsRuntimeContextForCustomComponent() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + ControlDefinition control = CreateRatingControl() with + { + Props = new PropertyBag(new Dictionary + { + ["enabled"] = false, + ["readOnly"] = true, + }), + }; + var renderer = CreateRenderer( + registry, + DbCommandRegistry.Empty, + CreateForm(control), + record: new Dictionary { ["Rating"] = "3" }, + choices: new Dictionary> + { + ["Rating"] = [new EnumChoice("3", "Three")], + }); + + Dictionary parameters = InvokeNonPublic>( + renderer, + "GetRuntimeComponentParameters", + control, + "Rating", + "Rating is required.", + 4); + var context = Assert.IsType(parameters["Context"]); + + Assert.Equal("custom-form", context.Form.FormId); + Assert.Equal(control.ControlId, context.Control.ControlId); + Assert.Equal("Rating", context.FieldName); + Assert.Equal("3", context.BoundValue); + Assert.Single(context.Choices); + Assert.False(context.IsEnabled); + Assert.True(context.IsReadOnly); + Assert.Equal("Rating is required.", context.ValidationError); + Assert.Equal(4, context.TabIndex); + } + + [Fact] + public async Task RuntimeContext_SetValueAndDispatchEventUseRendererRuntimePath() + { + List captured = []; + DbCommandRegistry commands = DbCommandRegistry.Create(builder => + { + builder.AddCommand("CaptureChange", context => + { + captured.Add(context); + return DbCommandResult.Success(); + }); + builder.AddCommand("CaptureClick", context => + { + captured.Add(context); + return DbCommandResult.Success(); + }); + }); + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + ControlDefinition control = CreateRatingControl() with + { + EventBindings = + [ + new ControlEventBinding(ControlEventKind.OnChange, "CaptureChange"), + new ControlEventBinding( + ControlEventKind.OnClick, + "CaptureClick", + new Dictionary { ["configured"] = "yes" }), + ], + }; + var record = new Dictionary { ["Rating"] = "2" }; + var renderer = CreateRenderer(registry, commands, CreateForm(control), record); + FormControlRuntimeContext context = GetRuntimeContext(renderer, control, "Rating"); + + await context.SetValueAsync("4"); + await context.DispatchEventAsync( + ControlEventKind.OnClick, + new Dictionary { ["source"] = "custom-runtime" }); + + Assert.Equal("4", record["Rating"]); + Assert.Equal(2, captured.Count); + Assert.Equal("CaptureChange", captured[0].CommandName); + Assert.Equal("OnChange", captured[0].Metadata["event"]); + Assert.Equal("4", captured[0].Arguments["value"].AsText); + Assert.Equal("2", captured[0].Arguments["oldValue"].AsText); + Assert.Equal("CaptureClick", captured[1].CommandName); + Assert.Equal("OnClick", captured[1].Metadata["event"]); + Assert.Equal("custom-runtime", captured[1].Arguments["source"].AsText); + Assert.Equal("yes", captured[1].Arguments["configured"].AsText); + } + + private static FormControlRuntimeContext GetRuntimeContext(FormRenderer renderer, ControlDefinition control, string fieldName) + { + Dictionary parameters = InvokeNonPublic>( + renderer, + "GetRuntimeComponentParameters", + control, + fieldName, + null, + 1); + return Assert.IsType(parameters["Context"]); + } + + private static FormRenderer CreateRenderer( + IFormControlRegistry registry, + DbCommandRegistry commands, + FormDefinition form, + Dictionary? record = null, + IReadOnlyDictionary>? choices = null) + { + var renderer = new FormRenderer(); + SetProperty(renderer, nameof(FormRenderer.Form), form); + SetProperty(renderer, nameof(FormRenderer.Record), record ?? new Dictionary()); + SetProperty(renderer, nameof(FormRenderer.Choices), choices); + SetProperty(renderer, nameof(FormRenderer.ControlRegistry), registry); + SetProperty(renderer, "Commands", commands); + return renderer; + } + + private static IFormControlRegistry CreateRegistry(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + if (configure is not null) + services.AddCSharpDbAdminFormControls(configure); + + using ServiceProvider provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private static FormControlDescriptor CreateRatingDescriptor() + => new() + { + ControlType = "rating", + DisplayName = "Rating", + ToolboxGroup = "Custom", + IconText = "*", + DefaultWidth = 180, + DefaultHeight = 42, + SupportsBinding = true, + RuntimeComponentType = typeof(RatingRuntimeComponent), + }; + + private static ControlDefinition CreateRatingControl() + => new( + "rating1", + "rating", + new Rect(0, 0, 180, 42), + new BindingDefinition("Rating", "TwoWay"), + PropertyBag.Empty, + null); + + private static FormDefinition CreateForm(ControlDefinition control) + => new( + "custom-form", + "Custom Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + [control]); + + private static void SetProperty(object instance, string propertyName, object? value) + { + PropertyInfo property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Property '{propertyName}' was not found."); + property.SetValue(instance, value); + } + + private static T InvokeNonPublic(object instance, string methodName, params object?[] args) + { + MethodInfo method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Method '{methodName}' was not found."); + return (T)method.Invoke(instance, args)!; + } + + private sealed class RatingRuntimeComponent : ComponentBase + { + [Parameter] public FormControlRuntimeContext Context { get; set; } = default!; + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs index 61ab892f..094045bd 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Components/Designer/FormRendererCommandButtonTests.cs @@ -341,6 +341,110 @@ public void ControlRules_ApplyVisibilityAndTextEffects() Assert.Equal("Matched", text); } + [Fact] + public void FixedLayout_EmitsAnchorAndMinimumSizeVariables() + { + ControlDefinition stretched = new( + "status", + "text", + new Rect(100, 50, 200, 32), + new BindingDefinition("Status", "TwoWay"), + new PropertyBag(new Dictionary + { + ["anchorLeft"] = true, + ["anchorTop"] = true, + ["anchorRight"] = true, + ["anchorBottom"] = false, + ["minWidth"] = 120L, + ["minHeight"] = 24L, + }), + null); + ControlDefinition bottomRight = new( + "notes", + "textarea", + new Rect(100, 50, 200, 32), + new BindingDefinition("Notes", "TwoWay"), + new PropertyBag(new Dictionary + { + ["anchorLeft"] = false, + ["anchorTop"] = false, + ["anchorRight"] = true, + ["anchorBottom"] = true, + }), + null); + var renderer = CreateRenderer(DbCommandRegistry.Empty, CreateForm(stretched)); + SetProperty(renderer, nameof(FormRenderer.AnchorCanvasWidth), 800d); + SetProperty(renderer, nameof(FormRenderer.AnchorCanvasHeight), 500d); + + string stretchedStyle = InvokeNonPublic(renderer, "GetControlStyle", stretched); + string bottomRightStyle = InvokeNonPublic(renderer, "GetControlStyle", bottomRight); + + Assert.Contains("--fr-left: 100px", stretchedStyle, StringComparison.Ordinal); + Assert.Contains("--fr-right: 500px", stretchedStyle, StringComparison.Ordinal); + Assert.Contains("--fr-width: auto", stretchedStyle, StringComparison.Ordinal); + Assert.Contains("--fr-height: 32px", stretchedStyle, StringComparison.Ordinal); + Assert.Contains("--fr-min-width: 120px", stretchedStyle, StringComparison.Ordinal); + Assert.Contains("--fr-min-height: 24px", stretchedStyle, StringComparison.Ordinal); + Assert.Contains("--fr-left: auto", bottomRightStyle, StringComparison.Ordinal); + Assert.Contains("--fr-top: auto", bottomRightStyle, StringComparison.Ordinal); + Assert.Contains("--fr-right: 500px", bottomRightStyle, StringComparison.Ordinal); + Assert.Contains("--fr-bottom: 418px", bottomRightStyle, StringComparison.Ordinal); + } + + [Fact] + public void FixedLayout_ScaleResizeModeEmitsPercentVariables() + { + ControlDefinition scaled = new( + "status", + "text", + new Rect(120, 50, 240, 100), + new BindingDefinition("Status", "TwoWay"), + new PropertyBag(new Dictionary { ["resizeMode"] = "scale" }), + null); + var renderer = CreateRenderer(DbCommandRegistry.Empty, CreateForm(scaled)); + SetProperty(renderer, nameof(FormRenderer.AnchorCanvasWidth), 1200d); + SetProperty(renderer, nameof(FormRenderer.AnchorCanvasHeight), 500d); + + string style = InvokeNonPublic(renderer, "GetControlStyle", scaled); + + Assert.Contains("--fr-left: 10%", style, StringComparison.Ordinal); + Assert.Contains("--fr-top: 10%", style, StringComparison.Ordinal); + Assert.Contains("--fr-width: 20%", style, StringComparison.Ordinal); + Assert.Contains("--fr-height: 20%", style, StringComparison.Ordinal); + Assert.Contains("--fr-right: auto", style, StringComparison.Ordinal); + Assert.Contains("--fr-bottom: auto", style, StringComparison.Ordinal); + } + + [Fact] + public async Task ListBox_MultiSelectStoresDelimitedValues() + { + ControlDefinition listBox = new( + "statusList", + "listBox", + new Rect(10, 20, 160, 120), + new BindingDefinition("Status", "TwoWay"), + new PropertyBag(new Dictionary + { + ["multiSelect"] = true, + ["multiValueDelimiter"] = "|", + }), + null); + EnumChoice[] choices = + [ + new("A", "Active"), + new("P", "Pending"), + new("C", "Closed"), + ]; + var renderer = CreateRenderer(DbCommandRegistry.Empty, CreateForm(listBox)); + + await InvokeNonPublicAsync(renderer, "SetListBoxValueAsync", listBox, "Status", new[] { "A", "C" }, choices); + + var record = (Dictionary)GetProperty(renderer, nameof(FormRenderer.Record))!; + Assert.Equal("A|C", record["Status"]); + Assert.True(InvokeNonPublic(renderer, "IsListBoxChoiceSelected", listBox, "Status", "A", choices)); + Assert.False(InvokeNonPublic(renderer, "IsListBoxChoiceSelected", listBox, "Status", "P", choices)); + } + private static FormRenderer CreateRenderer( DbCommandRegistry commands, FormDefinition form, diff --git a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs index cc436eb9..3dbc7858 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs @@ -857,6 +857,9 @@ public Task SearchRecordPageAsync(FormTableDefinition table, str public Task> UpdateRecordAsync(FormTableDefinition table, object pkValue, Dictionary values, CancellationToken ct = default) => inner.UpdateRecordAsync(table, pkValue, values, ct); + public Task SaveAttachmentAsync(FormAttachmentTableBinding binding, object parentValue, FormAttachmentValue attachment, CancellationToken ct = default) + => inner.SaveAttachmentAsync(binding, parentValue, attachment, ct); + public Task DeleteRecordAsync(FormTableDefinition table, object pkValue, CancellationToken ct = default) => inner.DeleteRecordAsync(table, pkValue, ct); } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/CustomControlRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/CustomControlRoundtripTests.cs new file mode 100644 index 00000000..89aa5981 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/CustomControlRoundtripTests.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Serialization; + +namespace CSharpDB.Admin.Forms.Tests.Serialization; + +public sealed class CustomControlRoundtripTests +{ + [Fact] + public void CustomControl_MetadataRoundTripsWithoutSchemaMigration() + { + var form = new FormDefinition( + "custom-form", + "Custom Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, [new Breakpoint("md", 0, null)]), + [ + new ControlDefinition( + "rating1", + "rating", + new Rect(10, 20, 180, 42), + new BindingDefinition("Rating", "TwoWay"), + new PropertyBag(new Dictionary + { + ["displayMode"] = "star", + ["max"] = 5L, + ["thresholds"] = new object?[] + { + new Dictionary { ["value"] = 3L, ["label"] = "Review" }, + }, + ["parentControlId"] = "tabs", + ["parentTabId"] = "details", + }), + null), + ]); + + string json = JsonSerializer.Serialize(form, JsonDefaults.Options); + FormDefinition deserialized = JsonSerializer.Deserialize(json, JsonDefaults.Options)!; + + ControlDefinition control = Assert.Single(deserialized.Controls); + Assert.Equal("rating", control.ControlType); + Assert.Equal("Rating", control.Binding!.FieldName); + Assert.Equal("star", control.Props.Values["displayMode"]); + Assert.Equal(5L, control.Props.Values["max"]); + Assert.Equal("tabs", control.Props.Values["parentControlId"]); + Assert.Equal("details", control.Props.Values["parentTabId"]); + object?[] thresholds = Assert.IsType(control.Props.Values["thresholds"]); + var threshold = Assert.IsType>(thresholds[0]); + Assert.Equal(3L, threshold["value"]); + Assert.Equal("Review", threshold["label"]); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index edc7ae98..14c5f81b 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -407,6 +407,156 @@ public void ControlDefinition_LookupType_RoundTrips() Assert.Equal("-- Select product --", deserialized.Props.Values["placeholder"]); } + [Fact] + public void AccessParityControls_RoundTripThroughPropertyBag() + { + var form = new FormDefinition( + "access-v1", + "Access Parity", + "Documents", + 1, + "documents:v1", + new LayoutDefinition("absolute", 8, true, []), + [ + new ControlDefinition( + "combo", + "comboBox", + new Rect(0, 0, 240, 32), + new BindingDefinition("Status", "TwoWay"), + new PropertyBag(new Dictionary + { + ["options"] = new object?[] + { + new Dictionary { ["value"] = "A", ["label"] = "Active" }, + }, + ["allowCustomValue"] = true, + ["anchorLeft"] = true, + ["anchorTop"] = true, + ["anchorRight"] = true, + ["anchorBottom"] = false, + ["minWidth"] = 120L, + ["minHeight"] = 24L, + }), + null), + new ControlDefinition( + "multi", + "listBox", + new Rect(260, 0, 240, 96), + new BindingDefinition("Tags", "TwoWay"), + new PropertyBag(new Dictionary + { + ["options"] = new object?[] + { + new Dictionary { ["value"] = "A", ["label"] = "Alpha" }, + new Dictionary { ["value"] = "B", ["label"] = "Beta" }, + }, + ["multiSelect"] = true, + ["multiValueDelimiter"] = "|", + ["resizeMode"] = "scale", + }), + null), + new ControlDefinition( + "tabs", + "tabControl", + new Rect(0, 40, 500, 240), + null, + new PropertyBag(new Dictionary + { + ["tabs"] = new object?[] + { + new Dictionary { ["id"] = "main", ["label"] = "Main" }, + }, + }), + null), + new ControlDefinition( + "child", + "text", + new Rect(16, 56, 200, 32), + new BindingDefinition("Title", "TwoWay"), + new PropertyBag(new Dictionary + { + ["parentControlId"] = "tabs", + ["parentTabId"] = "main", + }), + null), + new ControlDefinition( + "sub", + "subform", + new Rect(0, 300, 500, 240), + null, + new PropertyBag(new Dictionary + { + ["formId"] = "child-form", + ["parentKeyField"] = "Id", + ["foreignKeyField"] = "DocumentId", + ["showToolbar"] = false, + ["showRecordList"] = true, + }), + null), + new ControlDefinition( + "file", + "attachment", + new Rect(0, 560, 360, 80), + new BindingDefinition("Payload", "TwoWay"), + new PropertyBag(new Dictionary + { + ["fileNameField"] = "PayloadName", + ["contentTypeField"] = "PayloadType", + ["fileSizeField"] = "PayloadSize", + ["storageMode"] = "attachmentTable", + ["attachmentTable"] = "DocumentAttachments", + ["attachmentForeignKeyField"] = "DocumentId", + ["attachmentBlobField"] = "Payload", + ["attachmentFileNameField"] = "Name", + ["attachmentContentTypeField"] = "ContentType", + ["attachmentFileSizeField"] = "Size", + ["attachmentControlIdField"] = "ControlId", + }), + null), + new ControlDefinition( + "photo", + "image", + new Rect(0, 660, 360, 220), + new BindingDefinition("Photo", "TwoWay"), + new PropertyBag(new Dictionary + { + ["accept"] = "image/*", + ["fit"] = "cover", + }), + null), + ]); + + string json = JsonSerializer.Serialize(form, Options); + FormDefinition deserialized = JsonSerializer.Deserialize(json, Options)!; + + Assert.Contains(deserialized.Controls, control => control.ControlType == "comboBox"); + Assert.Contains(deserialized.Controls, control => control.ControlType == "listBox"); + Assert.Contains(deserialized.Controls, control => control.ControlType == "tabControl"); + Assert.Contains(deserialized.Controls, control => control.ControlType == "subform"); + Assert.Contains(deserialized.Controls, control => control.ControlType == "attachment"); + Assert.Contains(deserialized.Controls, control => control.ControlType == "image"); + + ControlDefinition combo = Assert.Single(deserialized.Controls, control => control.ControlId == "combo"); + Assert.Equal(true, combo.Props.Values["anchorLeft"]); + Assert.Equal(true, combo.Props.Values["anchorTop"]); + Assert.Equal(true, combo.Props.Values["anchorRight"]); + Assert.Equal(false, combo.Props.Values["anchorBottom"]); + Assert.Equal(120L, combo.Props.Values["minWidth"]); + Assert.Equal(24L, combo.Props.Values["minHeight"]); + ControlDefinition multi = Assert.Single(deserialized.Controls, control => control.ControlId == "multi"); + Assert.Equal(true, multi.Props.Values["multiSelect"]); + Assert.Equal("|", multi.Props.Values["multiValueDelimiter"]); + Assert.Equal("scale", multi.Props.Values["resizeMode"]); + ControlDefinition tabChild = Assert.Single(deserialized.Controls, control => control.ControlId == "child"); + Assert.Equal("tabs", tabChild.Props.Values["parentControlId"]); + Assert.Equal("main", tabChild.Props.Values["parentTabId"]); + ControlDefinition attachment = Assert.Single(deserialized.Controls, control => control.ControlId == "file"); + Assert.Equal("PayloadName", attachment.Props.Values["fileNameField"]); + Assert.Equal("attachmentTable", attachment.Props.Values["storageMode"]); + Assert.Equal("DocumentAttachments", attachment.Props.Values["attachmentTable"]); + Assert.Equal("Payload", attachment.Props.Values["attachmentBlobField"]); + } + [Fact] public void FieldDataType_SerializesAsCamelCaseString() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRecordServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRecordServiceTests.cs index 1b4065be..52f4e33a 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRecordServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DbFormRecordServiceTests.cs @@ -329,6 +329,150 @@ Price REAL Assert.Empty(remaining); } + [Fact] + public async Task CreateUpdateReloadAndClear_BlobValues() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE Documents ( + Id INTEGER PRIMARY KEY, + Payload BLOB, + FileName TEXT, + ContentType TEXT, + FileSize INTEGER + ); + """); + + var provider = new DbSchemaProvider(db.Client); + var service = new DbFormRecordService(db.Client); + FormTableDefinition documents = (await provider.GetTableDefinitionAsync("Documents"))!; + + byte[] insertedBytes = [0x01, 0x02, 0xFE]; + Dictionary created = await service.CreateRecordAsync( + documents, + new Dictionary + { + ["Id"] = 1L, + ["Payload"] = insertedBytes, + ["FileName"] = "one.bin", + ["ContentType"] = "application/octet-stream", + ["FileSize"] = insertedBytes.Length, + }, + TestContext.Current.CancellationToken); + + Assert.Equal(insertedBytes, Assert.IsType(created["Payload"])); + Assert.Equal("one.bin", created["FileName"]); + + byte[] updatedBytes = [0xAA, 0xBB, 0xCC, 0xDD]; + Dictionary updated = await service.UpdateRecordAsync( + documents, + 1L, + new Dictionary + { + ["Payload"] = updatedBytes, + ["FileName"] = "two.bin", + ["FileSize"] = updatedBytes.Length, + }, + TestContext.Current.CancellationToken); + + Assert.Equal(updatedBytes, Assert.IsType(updated["Payload"])); + Assert.Equal("two.bin", updated["FileName"]); + Assert.Equal(4L, updated["FileSize"]); + + Dictionary? reloaded = await service.GetRecordAsync(documents, 1L, TestContext.Current.CancellationToken); + Assert.NotNull(reloaded); + Assert.Equal(updatedBytes, Assert.IsType(reloaded!["Payload"])); + + Dictionary cleared = await service.UpdateRecordAsync( + documents, + 1L, + new Dictionary + { + ["Payload"] = null, + ["FileName"] = null, + ["ContentType"] = null, + ["FileSize"] = null, + }, + TestContext.Current.CancellationToken); + + Assert.Null(cleared["Payload"]); + Assert.Null(cleared["FileName"]); + Assert.Null(cleared["ContentType"]); + Assert.Null(cleared["FileSize"]); + } + + [Fact] + public async Task SaveAttachmentAsync_ReplacesAndClearsAttachmentRows() + { + await using var db = await TestDatabaseScope.CreateAsync(); + await db.ExecuteAsync( + """ + CREATE TABLE DocumentAttachments ( + Id INTEGER PRIMARY KEY, + DocumentId INTEGER NOT NULL, + ControlId TEXT, + Payload BLOB NOT NULL, + FileName TEXT, + ContentType TEXT, + FileSize INTEGER + ); + """); + + var service = new DbFormRecordService(db.Client); + var binding = new FormAttachmentTableBinding( + "DocumentAttachments", + "DocumentId", + "Payload", + FileNameField: "FileName", + ContentTypeField: "ContentType", + FileSizeField: "FileSize", + ControlIdField: "ControlId", + ControlId: "file"); + + await service.SaveAttachmentAsync( + binding, + 10L, + FormAttachmentValue.FromFile([0x01, 0x02], "one.bin", "application/octet-stream", 2), + TestContext.Current.CancellationToken); + + IReadOnlyList> rows = ReadRows(await db.Client.ExecuteSqlAsync( + "SELECT DocumentId, ControlId, Payload, FileName, ContentType, FileSize FROM DocumentAttachments;", + TestContext.Current.CancellationToken)); + Dictionary inserted = Assert.Single(rows); + Assert.Equal(10L, inserted["DocumentId"]); + Assert.Equal("file", inserted["ControlId"]); + Assert.Equal([0x01, 0x02], Assert.IsType(inserted["Payload"])); + Assert.Equal("one.bin", inserted["FileName"]); + Assert.Equal("application/octet-stream", inserted["ContentType"]); + Assert.Equal(2L, inserted["FileSize"]); + + await service.SaveAttachmentAsync( + binding, + 10L, + FormAttachmentValue.FromFile([0xAA, 0xBB, 0xCC], "two.bin", "application/octet-stream", 3), + TestContext.Current.CancellationToken); + + rows = ReadRows(await db.Client.ExecuteSqlAsync( + "SELECT Payload, FileName, FileSize FROM DocumentAttachments;", + TestContext.Current.CancellationToken)); + Dictionary replaced = Assert.Single(rows); + Assert.Equal([0xAA, 0xBB, 0xCC], Assert.IsType(replaced["Payload"])); + Assert.Equal("two.bin", replaced["FileName"]); + Assert.Equal(3L, replaced["FileSize"]); + + await service.SaveAttachmentAsync( + binding, + 10L, + FormAttachmentValue.Clear(), + TestContext.Current.CancellationToken); + + rows = ReadRows(await db.Client.ExecuteSqlAsync( + "SELECT Id FROM DocumentAttachments;", + TestContext.Current.CancellationToken)); + Assert.Empty(rows); + } + [Fact] public void GetPrimaryKeyColumn_RejectsCompositeKeys() { @@ -386,4 +530,25 @@ IsActive INTEGER NOT NULL INSERT INTO Customers VALUES (2, 'Grace', 0); INSERT INTO Customers VALUES (1, 'Ada', 1); """); + + private static IReadOnlyList> ReadRows(CSharpDB.Client.Models.SqlExecutionResult result) + { + if (!string.IsNullOrWhiteSpace(result.Error)) + throw new InvalidOperationException(result.Error); + + if (result.ColumnNames is null || result.Rows is null) + return []; + + var rows = new List>(result.Rows.Count); + foreach (object?[] row in result.Rows) + { + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < result.ColumnNames.Length && i < row.Length; i++) + dictionary[result.ColumnNames[i]] = row[i]; + + rows.Add(dictionary); + } + + return rows; + } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormGeneratorTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormGeneratorTests.cs index 08faa529..3372591a 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormGeneratorTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultFormGeneratorTests.cs @@ -54,6 +54,7 @@ public void GenerateDefault_SetsAbsoluteLayout() [InlineData(FieldDataType.Int64, "number")] [InlineData(FieldDataType.Decimal, "number")] [InlineData(FieldDataType.Double, "number")] + [InlineData(FieldDataType.Blob, "attachment")] [InlineData(FieldDataType.String, "text")] [InlineData(FieldDataType.Guid, "text")] public void GenerateDefault_MapsFieldToCorrectControlType(FieldDataType dataType, string expectedControlType) diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs index 79fb7552..6a02f52a 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/FormActionDiagnosticsTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Services; @@ -10,7 +11,7 @@ public sealed class FormActionDiagnosticsTests [Fact] public async Task DispatchActionSequence_EmitsDiagnosticEvent() { - List diagnostics = []; + var diagnostics = new ConcurrentQueue(); using IDisposable subscription = FormActionDiagnostics.Listener.Subscribe(new ActionObserver(diagnostics)); var dispatcher = new DefaultFormEventDispatcher(DbCommandRegistry.Empty); FormDefinition form = CreateForm( @@ -28,7 +29,7 @@ public async Task DispatchActionSequence_EmitsDiagnosticEvent() Assert.True(result.Succeeded); FormActionInvocationDiagnostic diagnostic = Assert.Single( - diagnostics, + diagnostics.ToArray(), diagnostic => diagnostic.FormId == "orders-form" && diagnostic.ActionKind == DbActionKind.ShowMessage); Assert.Equal(DbActionKind.ShowMessage, diagnostic.ActionKind); Assert.Equal("orders-form", diagnostic.FormId); @@ -58,7 +59,7 @@ private static FormDefinition CreateForm(DbActionSequence sequence) new FormEventBinding(FormEventKind.OnLoad, string.Empty, ActionSequence: sequence), ]); - private sealed class ActionObserver(List diagnostics) + private sealed class ActionObserver(ConcurrentQueue diagnostics) : IObserver> { public void OnCompleted() @@ -74,7 +75,7 @@ public void OnNext(KeyValuePair value) if (value.Key == FormActionDiagnostics.InvocationEventName && value.Value is FormActionInvocationDiagnostic diagnostic) { - diagnostics.Add(diagnostic); + diagnostics.Enqueue(diagnostic); } } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/FormChoiceResolverTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/FormChoiceResolverTests.cs new file mode 100644 index 00000000..e45d7771 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/FormChoiceResolverTests.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Forms.Tests.Services; + +public sealed class FormChoiceResolverTests +{ + [Fact] + public void ResolveChoices_PrefersStaticOptionsFromPropertyBag() + { + var control = new ControlDefinition( + "status", + "comboBox", + new Rect(0, 0, 200, 32), + new BindingDefinition("Status", "TwoWay"), + new PropertyBag(new Dictionary + { + ["options"] = new object?[] + { + new Dictionary { ["value"] = "A", ["label"] = "Active" }, + }, + }), + null); + var runtimeChoices = new Dictionary> + { + ["Status"] = [new EnumChoice("I", "Inactive")], + }; + + IReadOnlyList choices = FormChoiceResolver.ResolveChoices(control, "Status", runtimeChoices); + + EnumChoice choice = Assert.Single(choices); + Assert.Equal("A", choice.Value); + Assert.Equal("Active", choice.Label); + } + + [Fact] + public void BuildLookupChoices_UsesConfiguredDisplayFields() + { + var rows = new[] + { + new Dictionary + { + ["Id"] = 7L, + ["Code"] = "ALP", + ["Name"] = "Alpha", + }, + }; + + IReadOnlyList choices = FormChoiceResolver.BuildLookupChoices(rows, "Id", "Name", ["Code", "Name"]); + + EnumChoice choice = Assert.Single(choices); + Assert.Equal("7", choice.Value); + Assert.Equal("ALP - Alpha", choice.Label); + } + + [Fact] + public void ReadOptions_HandlesJsonElementArrays() + { + using JsonDocument document = JsonDocument.Parse( + """ + [{"value":"1","label":"One"}] + """); + + IReadOnlyList choices = FormChoiceResolver.ReadOptions(document.RootElement); + + EnumChoice choice = Assert.Single(choices); + Assert.Equal("1", choice.Value); + Assert.Equal("One", choice.Label); + } +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/FormControlRegistryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/FormControlRegistryTests.cs new file mode 100644 index 00000000..5a98b6a6 --- /dev/null +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/FormControlRegistryTests.cs @@ -0,0 +1,172 @@ +using CSharpDB.Admin.Forms.Contracts; +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; + +namespace CSharpDB.Admin.Forms.Tests.Services; + +public sealed class FormControlRegistryTests +{ + [Fact] + public void AddCSharpDbAdminForms_SeedsBuiltInControls() + { + IFormControlRegistry registry = CreateRegistry(); + + Assert.Contains(registry.Controls, control => control.ControlType == "comboBox" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "listBox" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "optionGroup" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "toggleButton" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "tabControl" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "subform" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "attachment" && control.IsBuiltIn); + Assert.Contains(registry.Controls, control => control.ControlType == "image" && control.IsBuiltIn); + } + + [Fact] + public void AddCSharpDbAdminFormControls_AddsCustomControlDescriptor() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(CreateRatingDescriptor())); + + Assert.True(registry.TryGetControl("RATING", out FormControlDescriptor descriptor)); + Assert.Equal("rating", descriptor.ControlType); + Assert.Equal("Rating", descriptor.DisplayName); + Assert.Equal("Custom", descriptor.ToolboxGroup); + Assert.Equal(180, descriptor.DefaultWidth); + Assert.Equal(42, descriptor.DefaultHeight); + Assert.Equal(typeof(RatingRuntimeComponent), descriptor.RuntimeComponentType); + Assert.Equal("star", descriptor.CreateDefaultProps()["displayMode"]); + Assert.Contains(registry.GetToolboxControls(), control => control.ControlType == "rating"); + } + + [Fact] + public void CreateDefaultProps_ClonesNestedDefaults() + { + var descriptor = new FormControlDescriptor + { + ControlType = "compound", + DisplayName = "Compound", + DefaultProps = new Dictionary + { + ["items"] = new object?[] + { + new Dictionary { ["label"] = "One" }, + }, + }, + }; + + Dictionary first = descriptor.CreateDefaultProps(); + Dictionary second = descriptor.CreateDefaultProps(); + + Assert.NotSame(first["items"], second["items"]); + var firstItems = Assert.IsType(first["items"]); + var secondItems = Assert.IsType(second["items"]); + Assert.NotSame(firstItems[0], secondItems[0]); + } + + [Fact] + public void DuplicateControlType_ThrowsWhenRegistryIsResolved() + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + services.AddCSharpDbAdminFormControls(builder => builder.Add(new FormControlDescriptor + { + ControlType = "text", + DisplayName = "Text Replacement", + })); + + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Throws(() => provider.GetRequiredService()); + } + + [Fact] + public void ReplaceBuiltInRuntime_RequiresExplicitOptIn() + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + services.AddCSharpDbAdminFormControls(builder => builder.Add(new FormControlDescriptor + { + ControlType = "text", + DisplayName = "Text Replacement", + RuntimeComponentType = typeof(RatingRuntimeComponent), + })); + + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Throws(() => provider.GetRequiredService()); + } + + [Fact] + public void ReplaceBuiltInRuntime_UpdatesBuiltInDescriptorWhenOptedIn() + { + IFormControlRegistry registry = CreateRegistry(builder => builder.Add(new FormControlDescriptor + { + ControlType = "text", + DisplayName = "Text Replacement", + RuntimeComponentType = typeof(RatingRuntimeComponent), + ReplaceBuiltInRuntime = true, + })); + + Assert.True(registry.TryGetControl("text", out FormControlDescriptor descriptor)); + Assert.True(descriptor.IsBuiltIn); + Assert.True(descriptor.ReplaceBuiltInRuntime); + Assert.Equal(typeof(RatingRuntimeComponent), descriptor.RuntimeComponentType); + Assert.Equal("Text", descriptor.DisplayName); + } + + [Fact] + public void InvalidComponentType_IsRejected() + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + services.AddCSharpDbAdminFormControls(builder => builder.Add(new FormControlDescriptor + { + ControlType = "invalid", + DisplayName = "Invalid", + RuntimeComponentType = typeof(string), + })); + + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.Throws(() => provider.GetRequiredService()); + } + + private static IFormControlRegistry CreateRegistry(Action? configure = null) + { + var services = new ServiceCollection(); + services.AddCSharpDbAdminForms(); + if (configure is not null) + services.AddCSharpDbAdminFormControls(configure); + + using ServiceProvider provider = services.BuildServiceProvider(); + return provider.GetRequiredService(); + } + + private static FormControlDescriptor CreateRatingDescriptor() + => new() + { + ControlType = "rating", + DisplayName = "Rating", + ToolboxGroup = "Custom", + IconText = "*", + DefaultWidth = 180, + DefaultHeight = 42, + SupportsBinding = true, + ParticipatesInTabOrder = true, + DefaultProps = new Dictionary { ["displayMode"] = "star" }, + RuntimeComponentType = typeof(RatingRuntimeComponent), + PropertyDescriptors = + [ + new FormControlPropertyDescriptor + { + Name = "max", + Label = "Max", + Editor = FormControlPropertyEditor.Number, + DefaultValue = 5L, + }, + ], + }; + + private sealed class RatingRuntimeComponent : ComponentBase + { + [Parameter] public FormControlRuntimeContext Context { get; set; } = default!; + } +} From b213e7e467b9a3846a393089db43cf7d17f40b91 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Fri, 1 May 2026 12:42:55 -0700 Subject: [PATCH 28/39] feat: Add developer extensibility section to README and new form control extensibility documentation --- docs/admin-forms-access-parity/README.md | 6 + .../form-control-extensibility.md | 154 ++++++++++++++++++ .../access-style-macro-actions.md | 31 +++- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 docs/admin-forms-access-parity/form-control-extensibility.md diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index 688210c8..d3434b2c 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -134,3 +134,9 @@ workflows." The highest leverage model changes are: - form-mode model Those foundations should be added before expanding the control palette too far. + +## Developer Extensibility + +Custom form controls can now be registered without changing saved form JSON. See +[Form Control Extensibility](form-control-extensibility.md) for the registry API, +component contexts, generic property schema, and the sample rating control. diff --git a/docs/admin-forms-access-parity/form-control-extensibility.md b/docs/admin-forms-access-parity/form-control-extensibility.md new file mode 100644 index 00000000..178bea87 --- /dev/null +++ b/docs/admin-forms-access-parity/form-control-extensibility.md @@ -0,0 +1,154 @@ +# Form Control Extensibility + +Admin Forms controls are persisted as `ControlDefinition.ControlType` plus a +free-form `PropertyBag`. The extensibility registry turns that existing wire +shape into a developer API: a host can add designer toolbox entries, placement +defaults, property editing, designer previews, and runtime rendering without +changing saved form JSON. + +## Registration + +Register built-ins with `AddCSharpDbAdminForms()`, then add custom controls with +`AddCSharpDbAdminFormControls(...)`: + +```csharp +using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; + +builder.Services.AddCSharpDbAdminForms(); +builder.Services.AddCSharpDbAdminFormControls(controls => +{ + controls.Add(new FormControlDescriptor + { + ControlType = "rating", + DisplayName = "Rating", + ToolboxGroup = "Custom", + IconText = "*", + DefaultWidth = 220, + DefaultHeight = 48, + SupportsBinding = true, + ParticipatesInTabOrder = true, + DefaultProps = new Dictionary + { + ["max"] = 5, + ["displayMode"] = "buttons", + }, + DesignerPreviewComponentType = typeof(RatingDesignerPreview), + RuntimeComponentType = typeof(RatingRuntimeControl), + PropertyEditorComponentType = typeof(RatingPropertyEditor), + }); +}); +``` + +The same registration must exist anywhere the form is designed or rendered. +Unknown control types are preserved in form metadata and render as placeholders. + +## Descriptor Fields + +`FormControlDescriptor` defines the designer and runtime contract: + +- `ControlType`: persisted control type string. +- `DisplayName`, `ToolboxGroup`, `IconText`, `Description`: toolbox and labels. +- `DefaultWidth`, `DefaultHeight`, `DefaultProps`: new-control placement. +- `SupportsBinding`: whether placement and the inspector create a field binding. +- `ParticipatesInTabOrder`: whether the control joins tab-order editing. +- `PropertyDescriptors`: generic property fields for simple custom props. +- `DesignerPreviewComponentType`: optional Blazor preview component. +- `RuntimeComponentType`: optional Blazor data-entry component. +- `PropertyEditorComponentType`: optional custom property editor. +- `ReplaceBuiltInRuntime`: lets a host explicitly replace a built-in runtime + renderer while keeping the built-in designer metadata. + +Duplicate custom control types fail when the registry is resolved. Built-in +runtime replacement also fails unless `ReplaceBuiltInRuntime = true`. + +## Component Contexts + +Custom components receive a single `Context` parameter. + +Designer previews use `FormControlDesignContext`: + +```csharp +[Parameter, EditorRequired] +public FormControlDesignContext Context { get; set; } = default!; +``` + +Runtime controls use `FormControlRuntimeContext`: + +```csharp +[Parameter, EditorRequired] +public FormControlRuntimeContext Context { get; set; } = default!; +``` + +The runtime context includes the form, control metadata, current record, bound +field/value, resolved choices, enabled/read-only state, validation error, +tab-index, `SetValueAsync`, and `DispatchEventAsync`. + +Custom property editors use `FormControlPropertyContext`: + +```csharp +[Parameter, EditorRequired] +public FormControlPropertyContext Context { get; set; } = default!; +``` + +Use `Context.SetPropertyAsync(name, value)` to write into the control +`PropertyBag`. + +## Generic Property Schema + +For simple controls, skip a custom property editor and define +`PropertyDescriptors`: + +```csharp +PropertyDescriptors = +[ + new FormControlPropertyDescriptor + { + Name = "max", + Label = "Maximum Rating", + Editor = FormControlPropertyEditor.Number, + DefaultValue = 5, + HelpText = "Allowed values are 1 through 10.", + }, + new FormControlPropertyDescriptor + { + Name = "displayMode", + Label = "Display Mode", + Editor = FormControlPropertyEditor.Select, + Options = + [ + new FormControlPropertyOption("buttons", "Buttons"), + new FormControlPropertyOption("compact", "Compact"), + ], + }, +]; +``` + +Generic editors support `Text`, `TextArea`, `Number`, `Checkbox`, and `Select`. + +## Sample Rating Control + +The Admin host includes a compiled sample custom control at: + +- `src/CSharpDB.Admin/Components/Samples/FormControls/RatingDesignerPreview.razor` +- `src/CSharpDB.Admin/Components/Samples/FormControls/RatingRuntimeControl.razor` +- `src/CSharpDB.Admin/Components/Samples/FormControls/RatingPropertyEditor.razor` +- `src/CSharpDB.Admin/Components/Samples/FormControls/SampleRatingControlRegistration.cs` + +It is disabled by default. Enable it for local testing with: + +```powershell +$env:AdminForms__EnableSampleControls = 'true' +dotnet run --project src\CSharpDB.Admin --urls http://127.0.0.1:61818 +``` + +The sample registers `sampleRating` under the `Custom` toolbox group. It binds to +a scalar field, writes the selected numeric rating through `SetValueAsync`, and +dispatches click events with runtime arguments. + +## Current Limits + +V1 custom controls are leaf controls. They can be placed inside existing +`tabControl` pages, but custom containers do not own or render child controls +yet. Custom components must be compiled into the host app or referenced +assemblies; form JSON never loads arbitrary component assemblies. diff --git a/docs/trusted-csharp-functions/access-style-macro-actions.md b/docs/trusted-csharp-functions/access-style-macro-actions.md index f3a68f36..21411eb1 100644 --- a/docs/trusted-csharp-functions/access-style-macro-actions.md +++ b/docs/trusted-csharp-functions/access-style-macro-actions.md @@ -11,7 +11,7 @@ Phase 8 extends Admin Forms action sequences with Access-style UI and data actio | `applyFilter` | Filters the current form record list when `target` is `form`; filters a rendered `datagrid` control when `target` is that control id. | | `clearFilter` | Clears the form or data-grid filter selected by `target`. | | `runSql` | Executes SQL only when the host enables SQL actions. `@name` parameters are resolved from action arguments. | -| `runProcedure` | Executes a named database procedure only when the host enables procedure actions. | +| `runProcedure` | Executes a named database procedure only when the host enables procedure actions. `target` is the procedure name; action arguments become procedure arguments. | | `setControlProperty` | Overrides rendered control properties such as `visible`, `enabled`, `readOnly`, `text`, `placeholder`, and bound `value`. | | `setControlVisibility`, `setControlEnabled`, `setControlReadOnly` | Short forms for the corresponding `setControlProperty` calls. | @@ -53,6 +53,35 @@ Use `target: "form"` for the parent form list. Use a DataGrid control id for chi The built-in admin tab host forwards those values to `DataEntry` as initial state. `mode: "new"` starts a writable form on a new record. `recordId` navigates to the requested primary key after load. `filter` or `where` applies an initial form filter. +## SQL And Procedure Actions + +Use `runProcedure` when the workflow should invoke reusable database-owned SQL, +for example allocating an order, receiving a purchase order, processing a +return, or returning a packaged operational snapshot. A procedure body can run +multiple SQL statements in one execution and can return follow-up result sets. + +```json +{ + "kind": "runProcedure", + "target": "AllocateOrder", + "arguments": { + "orderId": 7005, + "allocatedBy": "Wave Planner", + "note": "Allocated from form action sequence." + }, + "stopOnFailure": true +} +``` + +Use `runCommand` instead when the workflow needs host-owned C# behavior such as +email, queues, external APIs, filesystem access, or other services. A common +sequence is `runProcedure` for database updates followed by `runCommand` for a +host notification. + +Rendered Admin form runtimes can leave `runSql` and `runProcedure` disabled by +policy. That keeps database-mutating actions explicit at the host boundary even +though procedure definitions themselves are database metadata. + ## Conditional UI Rules Form-level `rules` apply control property effects whenever their condition is true: From de6807c83834079bcd283210b15b3714aa8316cf Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Fri, 1 May 2026 21:39:05 -0700 Subject: [PATCH 29/39] Add trusted validation callbacks --- .../Components/Designer/DesignerState.cs | 22 +- .../Components/Designer/FormRenderer.razor | 26 +- .../Designer/PropertyInspector.razor | 32 ++ .../Designer/ValidationRulesEditor.razor | 198 ++++++++ .../Contracts/IValidationInferenceService.cs | 4 + .../Evaluation/FormulaEvaluator.cs | 31 +- .../Models/FormDefinition.cs | 3 +- .../Pages/DataEntry.razor | 23 +- .../AdminFormsServiceCollectionExtensions.cs | 12 + .../Services/DefaultFormEventDispatcher.cs | 27 +- .../DefaultValidationInferenceService.cs | 472 +++++++++++++++++- .../Services/FormActionSequenceExecutor.cs | 27 +- .../Services/FormAutomationMetadata.cs | 28 ++ .../Services/FormCommandInvocation.cs | 4 + ...AdminReportsServiceCollectionExtensions.cs | 1 + .../Services/DefaultReportEventDispatcher.cs | 24 +- .../Services/DefaultReportPreviewService.cs | 40 +- .../Services/ReportFormulaEvaluator.cs | 61 ++- .../Components/Tabs/CallbacksTab.razor | 162 ++++++ src/CSharpDB.Admin/Program.cs | 2 + .../Services/HostCallbackCatalogService.cs | 198 +++++++- .../HostCallbackDiagnosticsHistoryService.cs | 63 +++ .../Services/HostCallbackReadinessService.cs | 11 +- src/CSharpDB.Admin/wwwroot/css/app.css | 76 +++ .../Pipelines/CSharpDbPipelineComponents.cs | 8 +- .../Pipelines/CSharpDbPipelineRunner.cs | 8 +- .../DefaultPipelineComponentFactory.cs | 8 +- .../Runtime/BuiltIns/TransformSupport.cs | 57 ++- .../Runtime/BuiltIns/Transforms.cs | 18 +- .../Runtime/PipelineOrchestrator.cs | 22 +- .../AutomationManifestValidation.cs | 99 +++- .../AutomationStubGeneration.cs | 44 +- .../DbAutomationMetadata.cs | 37 +- .../DbCallbackDiagnostics.cs | 208 +++++++- src/CSharpDB.Primitives/DbCommands.cs | 62 ++- src/CSharpDB.Primitives/DbExtensions.cs | 174 ++++++- src/CSharpDB.Primitives/DbFunctions.cs | 40 +- src/CSharpDB.Primitives/DbHostCallbacks.cs | 13 + src/CSharpDB.Primitives/DbValidationRules.cs | 411 +++++++++++++++ .../Admin/HostCallbackCatalogServiceTests.cs | 280 +++++++++++ .../Admin/HostCallbackPolicyServiceTests.cs | 2 +- .../HostCallbackReadinessServiceTests.cs | 6 + .../Pages/DataEntryTests.cs | 2 + .../Serialization/JsonRoundtripTests.cs | 72 +++ .../DefaultValidationInferenceServiceTests.cs | 212 ++++++++ .../AutomationManifestValidatorTests.cs | 42 +- .../AutomationStubGeneratorTests.cs | 35 +- .../CallbackDiagnosticsTests.cs | 161 ++++++ .../DbValidationRuleRegistryTests.cs | 104 ++++ .../DbExtensionPolicyTests.cs | 155 +++++- 50 files changed, 3674 insertions(+), 153 deletions(-) create mode 100644 src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor create mode 100644 src/CSharpDB.Admin/Services/HostCallbackDiagnosticsHistoryService.cs create mode 100644 src/CSharpDB.Primitives/DbValidationRules.cs create mode 100644 tests/CSharpDB.Tests/DbValidationRuleRegistryTests.cs diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs index 3ce67bda..8f432972 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs +++ b/src/CSharpDB.Admin.Forms/Components/Designer/DesignerState.cs @@ -9,6 +9,7 @@ public class DesignerState private readonly List _eventBindings = []; private readonly List _actionSequences = []; private readonly List _rules = []; + private readonly List _validationRules = []; private readonly Stack> _undoStack = new(); private readonly Stack> _redoStack = new(); @@ -23,6 +24,7 @@ public class DesignerState public IReadOnlyList EventBindings => _eventBindings; public IReadOnlyList ActionSequences => _actionSequences; public IReadOnlyList Rules => _rules; + public IReadOnlyList ValidationRules => _validationRules; public HashSet SelectedIds { get; } = []; // Active tool from toolbox (null = select mode) @@ -93,6 +95,8 @@ public void LoadForm(FormDefinition form) _actionSequences.AddRange(form.ActionSequences ?? []); _rules.Clear(); _rules.AddRange(form.Rules ?? []); + _validationRules.Clear(); + _validationRules.AddRange(form.ValidationRules ?? []); _undoStack.Clear(); _redoStack.Clear(); SelectedIds.Clear(); @@ -117,7 +121,7 @@ public FormDefinition ToFormDefinition() { return new FormDefinition( FormId, FormName, TableName, DefinitionVersion, SourceSchemaSignature, - Layout, _controls.ToList(), EventBindings: _eventBindings.ToList(), ActionSequences: _actionSequences.ToList(), Rules: _rules.ToList()); + Layout, _controls.ToList(), EventBindings: _eventBindings.ToList(), ActionSequences: _actionSequences.ToList(), Rules: _rules.ToList(), ValidationRules: _validationRules.ToList()); } public void UpdateEventBindings(IReadOnlyList bindings) @@ -141,6 +145,22 @@ public void UpdateRules(IReadOnlyList rules) NotifyChanged(); } + public void UpdateValidationRules(IReadOnlyList rules) + { + _validationRules.Clear(); + _validationRules.AddRange(rules); + NotifyChanged(); + } + + public void UpdateControlValidationOverride(string controlId, ValidationOverride? validationOverride) + { + var idx = _controls.FindIndex(c => c.ControlId == controlId); + if (idx < 0) return; + PushUndo(); + _controls[idx] = _controls[idx] with { ValidationOverride = validationOverride }; + NotifyChanged(); + } + public void UpdateControlEventBindings(string controlId, IReadOnlyList bindings) { var idx = _controls.FindIndex(c => c.ControlId == controlId); diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor index 06385366..64c761da 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/FormRenderer.razor @@ -6,6 +6,7 @@ @using CSharpDB.Admin.Forms.Services @using CSharpDB.Primitives @inject DbCommandRegistry Commands +@inject DbExtensionPolicy? CallbackPolicy
@foreach (var control in GetControlsToRender()) @@ -477,6 +478,7 @@ private const double DesktopCanvasWidth = 1200; private const double TabletCanvasWidth = 768; private const long DefaultMaxUploadBytes = 10 * 1024 * 1024; + private DbExtensionPolicy EffectiveCallbackPolicy => CallbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; private bool TryGetRuntimeComponent(ControlDefinition control, out Type componentType) { @@ -1251,7 +1253,10 @@ DbCommandDefinition? definition = null; if (!string.IsNullOrWhiteSpace(commandName) && !Commands.TryGetCommand(commandName, out definition)) { - await ReportCommandErrorAsync($"Unknown form command '{commandName}'."); + Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, "Click"); + string message = $"Unknown form command '{commandName}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(commandName, metadata, message); + await ReportCommandErrorAsync(message); return; } @@ -1270,7 +1275,11 @@ FormCommandInvocation.ReadArgumentsProperty(configuredArguments)); Dictionary metadata = FormCommandInvocation.BuildControlMetadata(Form, control, "Click"); - DbCommandResult result = await definition.InvokeAsync(arguments, metadata); + DbCommandResult result = await definition.InvokeAsync( + arguments, + metadata, + EffectiveCallbackPolicy, + DbExtensionHostMode.Embedded); if (!result.Succeeded) { string message = string.IsNullOrWhiteSpace(result.Message) @@ -1329,7 +1338,9 @@ { if (!Commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) { - await ReportCommandErrorAsync($"Unknown form command '{binding.CommandName}' for control event '{eventKind}'."); + string message = $"Unknown form command '{binding.CommandName}' for control event '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(binding.CommandName, metadata, message); + await ReportCommandErrorAsync(message); return; } @@ -1342,7 +1353,11 @@ string? commandFailureMessage = null; try { - DbCommandResult result = await definition.InvokeAsync(arguments, metadata); + DbCommandResult result = await definition.InvokeAsync( + arguments, + metadata, + EffectiveCallbackPolicy, + DbExtensionHostMode.Embedded); if (!result.Succeeded) { commandFailed = true; @@ -1388,7 +1403,8 @@ setFieldValue: SetActionFieldValueAsync, showMessage: ReportCommandErrorAsync, executeBuiltInFormAction: OnBuiltInAction, - actionRuntime: ActionRuntime); + actionRuntime: ActionRuntime, + callbackPolicy: EffectiveCallbackPolicy); if (!actionResult.Succeeded && binding.StopOnFailure) { diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index 0e00f6cc..d37beba7 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -59,6 +59,11 @@ Controls="State.Controls" RulesChanged="OnRulesChanged" />
+
+ + +
} else { @@ -331,6 +336,11 @@ @onchange="@(e => OnPropChanged(PropTabIndex, ParseLong(e.Value)))" />
+
+ + +
} else if (IsTabOrderControl(_selected)) { @@ -1828,6 +1838,28 @@ return Task.CompletedTask; } + private Task OnFormValidationRulesChanged(IReadOnlyList rules) + { + State.UpdateValidationRules(rules); + return Task.CompletedTask; + } + + private Task OnControlValidationRulesChanged(IReadOnlyList rules) + { + if (_selected is null) + return Task.CompletedTask; + + ValidationOverride? existing = _selected.ValidationOverride; + bool disableInferredRules = existing?.DisableInferredRules ?? false; + IReadOnlyList disableRuleIds = existing?.DisableRuleIds ?? []; + ValidationOverride? updated = rules.Count == 0 && !disableInferredRules && disableRuleIds.Count == 0 + ? null + : new ValidationOverride(disableInferredRules, rules.ToList(), disableRuleIds); + + State.UpdateControlValidationOverride(_selected.ControlId, updated); + return Task.CompletedTask; + } + private Task OnControlEventBindingsChanged(IReadOnlyList bindings) { if (_selected is not null) diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor b/src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor new file mode 100644 index 00000000..534d5883 --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Components/Designer/ValidationRulesEditor.razor @@ -0,0 +1,198 @@ +@using System.Text.Json +@using CSharpDB.Admin.Forms.Models +@using CSharpDB.Admin.Forms.Serialization +@using CSharpDB.Primitives +@inject DbValidationRuleRegistry ValidationRuleRegistry + +
+ @if (Rules.Count == 0) + { +
No validation rules
+ } + + @for (int i = 0; i < Rules.Count; i++) + { + var ruleIndex = i; + var rule = Rules[ruleIndex]; +
+
+
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + + @if (_parameterErrors.TryGetValue(ruleIndex, out string? error)) + { +
@error
+ } +
+
+ } + + +
+ +@code { + [Parameter, EditorRequired] public IReadOnlyList Rules { get; set; } = []; + [Parameter] public EventCallback> RulesChanged { get; set; } + + private readonly Dictionary _parameterErrors = []; + + private IReadOnlyList RegisteredRules + => ValidationRuleRegistry.Rules + .OrderBy(static rule => rule.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private async Task AddRule() + { + var updated = Rules + .Append(new ValidationRule(NextRuleName(), string.Empty, new Dictionary(StringComparer.OrdinalIgnoreCase))) + .ToList(); + await RulesChanged.InvokeAsync(updated); + } + + private async Task RemoveRule(int index) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated.RemoveAt(index); + _parameterErrors.Remove(index); + await RulesChanged.InvokeAsync(updated); + } + + private Task UpdateRuleFromDropdown(int index, ValidationRule rule, string value) + => string.IsNullOrWhiteSpace(value) + ? Task.CompletedTask + : ReplaceRule(index, rule with { RuleId = value.Trim() }); + + private Task UpdateRuleId(int index, ValidationRule rule, string value) + => ReplaceRule(index, rule with { RuleId = value.Trim() }); + + private Task UpdateMessage(int index, ValidationRule rule, string value) + => ReplaceRule(index, rule with { Message = value.Trim() }); + + private async Task UpdateParameters(int index, ValidationRule rule, string text) + { + try + { + IReadOnlyDictionary parameters = ParseParameters(text); + _parameterErrors.Remove(index); + await ReplaceRule(index, rule with { Parameters = parameters }); + } + catch (JsonException ex) + { + _parameterErrors[index] = $"Invalid JSON: {ex.Message}"; + } + catch (InvalidOperationException ex) + { + _parameterErrors[index] = ex.Message; + } + } + + private async Task ReplaceRule(int index, ValidationRule rule) + { + var updated = Rules.ToList(); + if (index < 0 || index >= updated.Count) + return; + + updated[index] = rule; + await RulesChanged.InvokeAsync(updated); + } + + private string GetRuleSelectValue(ValidationRule rule) + => RegisteredRules.Any(definition => string.Equals(definition.Name, rule.RuleId, StringComparison.OrdinalIgnoreCase)) + ? rule.RuleId + : string.Empty; + + private string NextRuleName() + { + HashSet existing = Rules + .Select(static rule => rule.RuleId) + .Where(static ruleId => !string.IsNullOrWhiteSpace(ruleId)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (DbValidationRuleDefinition definition in RegisteredRules) + { + if (!existing.Contains(definition.Name)) + return definition.Name; + } + + for (int i = 1; ; i++) + { + string candidate = $"Rule{i}"; + if (!existing.Contains(candidate)) + return candidate; + } + } + + private static string FormatParameters(IReadOnlyDictionary parameters) + => parameters.Count == 0 + ? "{ }" + : JsonSerializer.Serialize(parameters, new JsonSerializerOptions(JsonDefaults.Options) { WriteIndented = true }); + + private static IReadOnlyDictionary ParseParameters(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + using JsonDocument document = JsonDocument.Parse(text); + if (document.RootElement.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException("Parameters must be a JSON object."); + + return document.RootElement + .EnumerateObject() + .ToDictionary( + static property => property.Name, + static property => ReadJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase); + } + + private static object? ReadJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out long integer) ? integer : value.GetDouble(), + JsonValueKind.Object => value.EnumerateObject().ToDictionary( + static property => property.Name, + static property => ReadJsonValue(property.Value), + StringComparer.OrdinalIgnoreCase), + JsonValueKind.Array => value.EnumerateArray().Select(ReadJsonValue).ToArray(), + _ => value.ToString(), + }; +} diff --git a/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs b/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs index e40b91c1..335bf04b 100644 --- a/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs +++ b/src/CSharpDB.Admin.Forms/Contracts/IValidationInferenceService.cs @@ -6,4 +6,8 @@ public interface IValidationInferenceService { IReadOnlyList InferRules(FormFieldDefinition field); IReadOnlyList Evaluate(FormDefinition form, IDictionary record); + Task> EvaluateAsync( + FormDefinition form, + IDictionary record, + CancellationToken ct = default); } diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs index 5fb1a4f2..09d41cd1 100644 --- a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs @@ -26,6 +26,13 @@ public static class FormulaEvaluator string? formula, Func fieldResolver, DbFunctionRegistry? functions) + => Evaluate(formula, fieldResolver, functions, callbackPolicy: null); + + public static double? Evaluate( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { if (string.IsNullOrWhiteSpace(formula)) return null; @@ -36,7 +43,7 @@ public static class FormulaEvaluator try { - var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty); + var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty, callbackPolicy); var result = parser.ParseExpression(); // Ensure we consumed all input if (parser.Position < parser.Input.Length) @@ -124,13 +131,19 @@ private ref struct Parser public int Position; private readonly Func _fieldResolver; private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy? _callbackPolicy; - public Parser(string input, Func fieldResolver, DbFunctionRegistry functions) + public Parser( + string input, + Func fieldResolver, + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy) { Input = input.AsSpan(); Position = 0; _fieldResolver = fieldResolver; _functions = functions; + _callbackPolicy = callbackPolicy; } public double? ParseExpression() @@ -292,14 +305,25 @@ public Parser(string input, Func fieldResolver, DbFunctionRegis private double? InvokeFunction(string functionName, List arguments) { if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) + { + DbCallbackDiagnostics.WriteMissingScalarInvocation( + functionName, + arguments.Count, + CreateFormCallbackMetadata(functionName), + $"Unknown scalar function '{functionName}'."); return null; + } if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) return null; try { - DbValue value = definition.Invoke(arguments.ToArray(), CreateFormCallbackMetadata(functionName)); + DbValue[] dbArguments = arguments.ToArray(); + IReadOnlyDictionary? metadata = CreateFormCallbackMetadata(functionName); + DbValue value = _callbackPolicy is null + ? definition.Invoke(dbArguments, metadata) + : definition.Invoke(dbArguments, metadata, _callbackPolicy, DbExtensionHostMode.Embedded); return value.Type switch { DbType.Integer => value.AsInteger, @@ -326,6 +350,7 @@ private void SkipWhitespace() { ["surface"] = "AdminForms", ["location"] = $"formulas.functions.{functionName}", + ["correlationId"] = Guid.NewGuid().ToString("N"), } : null; } diff --git a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs index e7300191..7ed0c3f2 100644 --- a/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs +++ b/src/CSharpDB.Admin.Forms/Models/FormDefinition.cs @@ -14,4 +14,5 @@ public sealed record FormDefinition( IReadOnlyList? EventBindings = null, DbAutomationMetadata? Automation = null, IReadOnlyList? ActionSequences = null, - IReadOnlyList? Rules = null); + IReadOnlyList? Rules = null, + IReadOnlyList? ValidationRules = null); diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index 47d5972d..a58bcbaf 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -11,6 +11,8 @@ @inject IFormRecordService RecordService @inject ISchemaProvider SchemaProvider @inject IValidationInferenceService ValidationService +@inject DbFunctionRegistry Functions +@inject DbExtensionPolicy CallbackPolicy @inject IJSRuntime JS
@@ -594,11 +596,24 @@ return; } - var errors = ValidationService.Evaluate(_form, _currentRecord); + var errors = await ValidationService.EvaluateAsync(_form, _currentRecord); if (errors.Count > 0) { - _validationErrors = errors.ToDictionary(error => error.FieldName, error => error.Message, StringComparer.OrdinalIgnoreCase); - _error = $"Please fix {errors.Count} validation error(s) before saving."; + _validationErrors = errors + .Where(error => !string.IsNullOrWhiteSpace(error.FieldName)) + .GroupBy(error => error.FieldName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Message, StringComparer.OrdinalIgnoreCase); + + string[] globalErrors = errors + .Where(error => string.IsNullOrWhiteSpace(error.FieldName)) + .Select(error => error.Message) + .Where(static message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3) + .ToArray(); + _error = globalErrors.Length > 0 + ? string.Join(" ", globalErrors) + : $"Please fix {errors.Count} validation error(s) before saving."; return; } @@ -2121,7 +2136,7 @@ } return null; - }); + }, Functions, CallbackPolicy); } } } diff --git a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs index 0020b379..4bea54cd 100644 --- a/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Forms/Services/AdminFormsServiceCollectionExtensions.cs @@ -10,6 +10,8 @@ public static class AdminFormsServiceCollectionExtensions public static IServiceCollection AddCSharpDbAdminForms(this IServiceCollection services) { services.TryAddSingleton(DbCommandRegistry.Empty); + services.TryAddSingleton(DbValidationRuleRegistry.Empty); + services.TryAddSingleton(DbExtensionPolicies.DefaultHostCallbackPolicy); services.TryAddSingleton(NullFormActionRuntime.Instance); services.TryAddFormControlRegistry(); services.AddScoped(); @@ -31,6 +33,16 @@ public static IServiceCollection AddCSharpDbAdminForms( return services.AddCSharpDbAdminForms(); } + public static IServiceCollection AddCSharpDbAdminFormValidationRules( + this IServiceCollection services, + Action configureRules) + { + ArgumentNullException.ThrowIfNull(configureRules); + + services.AddSingleton(DbValidationRuleRegistry.Create(configureRules)); + return services.AddCSharpDbAdminForms(); + } + public static IServiceCollection AddCSharpDbAdminFormControls( this IServiceCollection services, Action configureControls) diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs index a72cfd3e..0db65f52 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultFormEventDispatcher.cs @@ -7,19 +7,30 @@ namespace CSharpDB.Admin.Forms.Services; public sealed class DefaultFormEventDispatcher : IFormEventDispatcher { private readonly DbCommandRegistry _commands; + private readonly DbExtensionPolicy _callbackPolicy; private readonly IFormActionRuntime _actionRuntime; public DefaultFormEventDispatcher(DbCommandRegistry commands) - : this(commands, NullFormActionRuntime.Instance) + : this(commands, DbExtensionPolicies.DefaultHostCallbackPolicy, NullFormActionRuntime.Instance) { } public DefaultFormEventDispatcher(DbCommandRegistry commands, IFormActionRuntime actionRuntime) + : this(commands, DbExtensionPolicies.DefaultHostCallbackPolicy, actionRuntime) + { + } + + public DefaultFormEventDispatcher( + DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, + IFormActionRuntime actionRuntime) { ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(callbackPolicy); ArgumentNullException.ThrowIfNull(actionRuntime); _commands = commands; + _callbackPolicy = callbackPolicy; _actionRuntime = actionRuntime; } @@ -49,14 +60,23 @@ public async Task DispatchAsync( if (!string.IsNullOrWhiteSpace(binding.CommandName)) { if (!_commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) - return FormEventDispatchResult.Failure($"Unknown form command '{binding.CommandName}' for event '{eventKind}'."); + { + string message = $"Unknown form command '{binding.CommandName}' for event '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(binding.CommandName, metadata, message); + return FormEventDispatchResult.Failure(message); + } Dictionary arguments = FormCommandInvocation.BuildArguments(record, binding.Arguments); bool commandFailed = false; string? commandFailureMessage = null; try { - DbCommandResult result = await definition.InvokeAsync(arguments, metadata, ct); + DbCommandResult result = await definition.InvokeAsync( + arguments, + metadata, + _callbackPolicy, + DbExtensionHostMode.Embedded, + ct); if (!result.Succeeded) { commandFailed = true; @@ -100,6 +120,7 @@ public async Task DispatchAsync( metadata, reusableSequences: form.ActionSequences, actionRuntime: actionRuntime, + callbackPolicy: _callbackPolicy, ct: ct); if (!actionResult.Succeeded && binding.StopOnFailure) diff --git a/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs b/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs index c9ebdccb..1cd4fd90 100644 --- a/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs +++ b/src/CSharpDB.Admin.Forms/Services/DefaultValidationInferenceService.cs @@ -1,10 +1,39 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Services; public sealed class DefaultValidationInferenceService : IValidationInferenceService { + private static readonly HashSet BuiltInRuleIds = new(StringComparer.OrdinalIgnoreCase) + { + "required", + "maxLength", + "range", + "regex", + "oneOf", + }; + + private readonly DbValidationRuleRegistry _validationRules; + private readonly DbExtensionPolicy _callbackPolicy; + + public DefaultValidationInferenceService() + : this(DbValidationRuleRegistry.Empty, DbExtensionPolicies.DefaultHostCallbackPolicy) + { + } + + public DefaultValidationInferenceService( + DbValidationRuleRegistry validationRules, + DbExtensionPolicy callbackPolicy) + { + _validationRules = validationRules; + _callbackPolicy = callbackPolicy; + } + public IReadOnlyList InferRules(FormFieldDefinition field) { var rules = new List(); @@ -67,46 +96,449 @@ public IReadOnlyList InferRules(FormFieldDefinition field) } public IReadOnlyList Evaluate(FormDefinition form, IDictionary record) + => EvaluateAsync(form, record).GetAwaiter().GetResult(); + + public async Task> EvaluateAsync( + FormDefinition form, + IDictionary record, + CancellationToken ct = default) { + ArgumentNullException.ThrowIfNull(form); + ArgumentNullException.ThrowIfNull(record); + var errors = new List(); + Dictionary recordValues = DbCommandArguments.FromObjectDictionary( + new Dictionary(record, StringComparer.OrdinalIgnoreCase)); - foreach (var control in form.Controls) + foreach (ControlDefinition control in form.Controls) { if (control.Binding is null) continue; - if (control.ValidationOverride?.DisableInferredRules == true) - continue; - string fieldName = control.Binding.FieldName; record.TryGetValue(fieldName, out object? value); - var disabledIds = control.ValidationOverride?.DisableRuleIds ?? []; - - if (!disabledIds.Contains("maxLength") && - control.Props.Values.TryGetValue("maxLength", out object? maxLength) && - value is string text && - maxLength is long max && - text.Length > max) - { - errors.Add(new ValidationError(fieldName, "maxLength", $"{fieldName} must be at most {max} characters.")); - } + IReadOnlyList disabledIds = control.ValidationOverride?.DisableRuleIds ?? []; - if (control.ValidationOverride?.AddRules is not { } addedRules) - continue; + if (control.ValidationOverride?.DisableInferredRules != true) + AddInferredControlErrors(errors, control, fieldName, value, disabledIds); - foreach (var rule in addedRules) + foreach (ValidationRule rule in control.ValidationOverride?.AddRules ?? []) { - if (disabledIds.Contains(rule.RuleId)) + if (disabledIds.Contains(rule.RuleId, StringComparer.OrdinalIgnoreCase)) continue; - if (rule.RuleId == "required" && IsEmpty(value)) - errors.Add(new ValidationError(fieldName, rule.RuleId, rule.Message)); + if (ApplyBuiltInRule(errors, rule, fieldName, value)) + continue; + + await InvokeValidationRuleAsync( + errors, + form, + control, + fieldName, + value, + recordValues, + rule, + DbValidationRuleScope.Field, + ct).ConfigureAwait(false); } } + foreach (ValidationRule rule in form.ValidationRules ?? []) + { + await InvokeValidationRuleAsync( + errors, + form, + control: null, + fieldName: null, + value: null, + recordValues, + rule, + DbValidationRuleScope.Form, + ct).ConfigureAwait(false); + } + return errors; } + private static void AddInferredControlErrors( + List errors, + ControlDefinition control, + string fieldName, + object? value, + IReadOnlyList disabledIds) + { + if (!disabledIds.Contains("required", StringComparer.OrdinalIgnoreCase) && + TryGetBoolean(control.Props.Values, "required", out bool required) && + required && + IsEmpty(value)) + { + errors.Add(new ValidationError(fieldName, "required", $"{fieldName} is required.")); + } + + if (!disabledIds.Contains("maxLength", StringComparer.OrdinalIgnoreCase) && + TryGetLong(control.Props.Values, "maxLength", out long maxLength) && + value is string text && + text.Length > maxLength) + { + errors.Add(new ValidationError(fieldName, "maxLength", $"{fieldName} must be at most {maxLength} characters.")); + } + + if (!disabledIds.Contains("range", StringComparer.OrdinalIgnoreCase) && + TryGetDoubleValue(value, out double numericValue)) + { + bool hasMin = TryGetDouble(control.Props.Values, "min", out double min); + bool hasMax = TryGetDouble(control.Props.Values, "max", out double maxValue); + if (hasMin && numericValue < min) + errors.Add(new ValidationError(fieldName, "range", $"{fieldName} must be at least {min.ToString(CultureInfo.InvariantCulture)}.")); + if (hasMax && numericValue > maxValue) + errors.Add(new ValidationError(fieldName, "range", $"{fieldName} must be at most {maxValue.ToString(CultureInfo.InvariantCulture)}.")); + } + + if (!disabledIds.Contains("regex", StringComparer.OrdinalIgnoreCase) && + TryGetString(control.Props.Values, "pattern", out string? pattern) && + pattern is not null && + value is string stringValue && + !Regex.IsMatch(stringValue, pattern)) + { + errors.Add(new ValidationError(fieldName, "regex", $"{fieldName} has an invalid format.")); + } + } + + private static bool ApplyBuiltInRule( + List errors, + ValidationRule rule, + string fieldName, + object? value) + { + if (!BuiltInRuleIds.Contains(rule.RuleId)) + return false; + + switch (rule.RuleId) + { + case "required" when IsEmpty(value): + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} is required."))); + break; + + case "maxLength" when value is string text && TryGetLong(rule.Parameters, "max", out long maxLength) && text.Length > maxLength: + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} must be at most {maxLength} characters."))); + break; + + case "range" when TryGetDoubleValue(value, out double numericValue): + bool hasMin = TryGetDouble(rule.Parameters, "min", out double min); + bool hasMax = TryGetDouble(rule.Parameters, "max", out double maxValue); + if ((hasMin && numericValue < min) || (hasMax && numericValue > maxValue)) + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} is outside the allowed range."))); + break; + + case "regex" when value is string stringValue && TryGetString(rule.Parameters, "pattern", out string? pattern) && pattern is not null && !Regex.IsMatch(stringValue, pattern): + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} has an invalid format."))); + break; + + case "oneOf" when !IsOneOfAllowedValue(value, rule.Parameters): + errors.Add(new ValidationError(fieldName, rule.RuleId, GetRuleMessage(rule, $"{fieldName} must be one of the allowed values."))); + break; + } + + return true; + } + + private async Task InvokeValidationRuleAsync( + List errors, + FormDefinition form, + ControlDefinition? control, + string? fieldName, + object? value, + IReadOnlyDictionary recordValues, + ValidationRule rule, + DbValidationRuleScope scope, + CancellationToken ct) + { + string ruleName = rule.RuleId?.Trim() ?? string.Empty; + string defaultFieldName = fieldName ?? string.Empty; + IReadOnlyDictionary metadata = CreateValidationMetadata(form, control, ruleName, scope); + + if (string.IsNullOrWhiteSpace(ruleName)) + { + errors.Add(new ValidationError(defaultFieldName, string.Empty, "Validation rule is missing a rule name.")); + return; + } + + if (!_validationRules.TryGetRule(ruleName, out DbValidationRuleDefinition? definition)) + { + string message = $"Validation rule '{ruleName}' is not registered in the current Admin host."; + DbCallbackDiagnostics.WriteMissingValidationInvocation(ruleName, metadata, message); + errors.Add(new ValidationError(defaultFieldName, ruleName, message)); + return; + } + + var context = DbValidationRuleContext.Create( + ruleName, + scope, + recordValues, + DbCommandArguments.FromObjectDictionary(rule.Parameters), + metadata) with + { + FormId = form.FormId, + FormName = form.Name, + TableName = form.TableName, + ControlId = control?.ControlId, + FieldName = fieldName, + Value = DbCommandArguments.FromObject(value), + FallbackMessage = rule.Message, + }; + + try + { + DbValidationRuleResult result = await definition + .InvokeAsync(context, _callbackPolicy, DbExtensionHostMode.Embedded, ct) + .ConfigureAwait(false); + + if (!result.Succeeded || result.Failures is { Count: > 0 }) + AddValidationRuleFailures(errors, result, defaultFieldName, ruleName, rule.Message); + } + catch (DbCallbackPolicyException ex) + { + string reason = ex.Decision.DenialReason ?? ex.Message; + errors.Add(new ValidationError(defaultFieldName, ruleName, $"Validation rule '{ruleName}' was denied by policy: {reason}")); + } + catch (TimeoutException ex) + { + errors.Add(new ValidationError(defaultFieldName, ruleName, ex.Message)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + errors.Add(new ValidationError(defaultFieldName, ruleName, $"Validation rule '{ruleName}' failed: {ex.Message}")); + } + } + + private static void AddValidationRuleFailures( + List errors, + DbValidationRuleResult result, + string defaultFieldName, + string ruleName, + string fallbackMessage) + { + if (result.Failures is { Count: > 0 } failures) + { + foreach (DbValidationFailure failure in failures) + { + string fieldName = failure.FieldName ?? string.Empty; + errors.Add(new ValidationError( + fieldName, + failure.RuleId ?? ruleName, + string.IsNullOrWhiteSpace(failure.Message) ? GetFallbackFailureMessage(ruleName, fallbackMessage, result.Message) : failure.Message)); + } + + return; + } + + errors.Add(new ValidationError( + defaultFieldName, + ruleName, + GetFallbackFailureMessage(ruleName, fallbackMessage, result.Message))); + } + + private static string GetFallbackFailureMessage(string ruleName, string fallbackMessage, string? resultMessage) + { + if (!string.IsNullOrWhiteSpace(resultMessage)) + return resultMessage; + if (!string.IsNullOrWhiteSpace(fallbackMessage)) + return fallbackMessage; + + return $"Validation rule '{ruleName}' failed."; + } + + private static IReadOnlyDictionary CreateValidationMetadata( + FormDefinition form, + ControlDefinition? control, + string ruleName, + DbValidationRuleScope scope) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "admin.forms", + ["ownerKind"] = "Form", + ["ownerId"] = form.FormId, + ["ownerName"] = form.Name, + ["formId"] = form.FormId, + ["formName"] = form.Name, + ["tableName"] = form.TableName, + ["validationScope"] = scope.ToString(), + ["ruleName"] = ruleName, + ["correlationId"] = Guid.NewGuid().ToString("N"), + }; + + if (scope == DbValidationRuleScope.Field && control is not null) + { + metadata["controlId"] = control.ControlId; + if (control.Binding?.FieldName is { Length: > 0 } fieldName) + metadata["fieldName"] = fieldName; + metadata["location"] = $"controls.{control.ControlId}.validationRules.{ruleName}"; + } + else + { + metadata["location"] = $"form.validationRules.{ruleName}"; + } + + return metadata; + } + + private static string GetRuleMessage(ValidationRule rule, string fallback) + => string.IsNullOrWhiteSpace(rule.Message) ? fallback : rule.Message; + private static bool IsEmpty(object? value) => value is null or "" || value is string text && string.IsNullOrWhiteSpace(text); + + private static bool TryGetBoolean(IReadOnlyDictionary values, string key, out bool result) + { + result = false; + if (!values.TryGetValue(key, out object? value) || value is null) + return false; + + if (value is bool boolValue) + { + result = boolValue; + return true; + } + + if (value is JsonElement json) + { + if (json.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + result = json.GetBoolean(); + return true; + } + + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is null) + return false; + + string? text = value.ToString(); + return text is not null && bool.TryParse(text, out result); + } + + private static bool TryGetString(IReadOnlyDictionary values, string key, out string? result) + { + result = null; + if (!values.TryGetValue(key, out object? value) || value is null) + return false; + + if (value is JsonElement json) + value = json.ValueKind == JsonValueKind.String ? json.GetString() : json.ToString(); + + result = value?.ToString(); + return !string.IsNullOrWhiteSpace(result); + } + + private static bool TryGetLong(IReadOnlyDictionary values, string key, out long result) + { + result = 0; + if (!values.TryGetValue(key, out object? value)) + return false; + + return TryGetLongValue(value, out result); + } + + private static bool TryGetDouble(IReadOnlyDictionary values, string key, out double result) + { + result = 0; + if (!values.TryGetValue(key, out object? value)) + return false; + + return TryGetDoubleValue(value, out result); + } + + private static bool TryGetLongValue(object? value, out long result) + { + result = 0; + if (value is null) + return false; + + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Number) + return json.TryGetInt64(out result); + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is null) + return false; + + if (value is IConvertible) + return long.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Integer, CultureInfo.InvariantCulture, out result); + + string? text = value.ToString(); + return text is not null && long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out result); + } + + private static bool TryGetDoubleValue(object? value, out double result) + { + result = 0; + if (value is null) + return false; + + if (value is JsonElement json) + { + if (json.ValueKind == JsonValueKind.Number) + return json.TryGetDouble(out result); + if (json.ValueKind == JsonValueKind.String) + value = json.GetString(); + } + + if (value is null) + return false; + + if (value is IConvertible) + return double.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), NumberStyles.Float, CultureInfo.InvariantCulture, out result); + + string? text = value.ToString(); + return text is not null && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out result); + } + + private static bool IsOneOfAllowedValue(object? value, IReadOnlyDictionary parameters) + { + if (!parameters.TryGetValue("values", out object? rawValues) || rawValues is null) + return true; + + string candidate = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + foreach (object? allowed in EnumerateValues(rawValues)) + { + string allowedText = Convert.ToString(allowed, CultureInfo.InvariantCulture) ?? string.Empty; + if (string.Equals(candidate, allowedText, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static IEnumerable EnumerateValues(object value) + { + if (value is JsonElement json && json.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in json.EnumerateArray()) + yield return item.ValueKind == JsonValueKind.String ? item.GetString() : item.ToString(); + yield break; + } + + if (value is IEnumerable objectValues) + { + foreach (object? item in objectValues) + yield return item; + yield break; + } + + if (value is System.Collections.IEnumerable values && value is not string) + { + foreach (object? item in values) + yield return item; + } + } } diff --git a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs index 146c4800..f720d9f2 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormActionSequenceExecutor.cs @@ -21,6 +21,7 @@ public static async Task ExecuteAsync( Func? showMessage = null, Func>? executeBuiltInFormAction = null, IFormActionRuntime? actionRuntime = null, + DbExtensionPolicy? callbackPolicy = null, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(sequence); @@ -39,6 +40,7 @@ public static async Task ExecuteAsync( showMessage, executeBuiltInFormAction, actionRuntime ?? NullFormActionRuntime.Instance, + callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy, ct, depth: 0); } @@ -55,6 +57,7 @@ private static async Task ExecuteCoreAsync( Func? showMessage, Func>? executeBuiltInFormAction, IFormActionRuntime actionRuntime, + DbExtensionPolicy callbackPolicy, CancellationToken ct, int depth) { @@ -68,6 +71,7 @@ private static async Task ExecuteCoreAsync( step, i, commands, + callbackPolicy, record, bindingArguments, runtimeArguments, @@ -98,6 +102,7 @@ private static async Task ExecuteStepAsync( DbActionStep step, int stepIndex, DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, IReadOnlyDictionary? record, IReadOnlyDictionary? bindingArguments, IReadOnlyDictionary? runtimeArguments, @@ -123,6 +128,7 @@ private static async Task ExecuteStepAsync( step, stepIndex, commands, + callbackPolicy, record, bindingArguments, runtimeArguments, @@ -163,6 +169,7 @@ private static async Task ExecuteStepCoreAsync( DbActionStep step, int stepIndex, DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, IReadOnlyDictionary? record, IReadOnlyDictionary? bindingArguments, IReadOnlyDictionary? runtimeArguments, @@ -193,7 +200,7 @@ private static async Task ExecuteStepCoreAsync( return step.Kind switch { - DbActionKind.RunCommand => await RunCommandAsync(sequence, step, stepIndex, commands, record, bindingArguments, runtimeArguments, metadata, ct), + DbActionKind.RunCommand => await RunCommandAsync(sequence, step, stepIndex, commands, callbackPolicy, record, bindingArguments, runtimeArguments, metadata, ct), DbActionKind.SetFieldValue => await SetFieldValueAsync(step, record, setFieldValue), DbActionKind.ShowMessage => await ShowMessageAsync(step, showMessage), DbActionKind.Stop => FormEventDispatchResult.Success(ReadMessage(step)), @@ -209,6 +216,7 @@ private static async Task ExecuteStepCoreAsync( showMessage, executeBuiltInFormAction, actionRuntime, + callbackPolicy, ct, depth), DbActionKind.OpenForm => await OpenFormAsync(sequence, step, stepIndex, record, bindingArguments, runtimeArguments, metadata, actionRuntime, executeBuiltInFormAction, ct), @@ -541,6 +549,7 @@ private static async Task RunCommandAsync( DbActionStep step, int stepIndex, DbCommandRegistry commands, + DbExtensionPolicy callbackPolicy, IReadOnlyDictionary? record, IReadOnlyDictionary? bindingArguments, IReadOnlyDictionary? runtimeArguments, @@ -551,7 +560,12 @@ private static async Task RunCommandAsync( return FormEventDispatchResult.Failure("RunCommand action requires a command name."); if (!commands.TryGetCommand(step.CommandName, out DbCommandDefinition definition)) - return FormEventDispatchResult.Failure($"Unknown form command '{step.CommandName}' for action sequence."); + { + Dictionary missingMetadata = BuildStepMetadata(sequence, step, stepIndex, metadata); + string message = $"Unknown form command '{step.CommandName}' for action sequence."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(step.CommandName, missingMetadata, message); + return FormEventDispatchResult.Failure(message); + } Dictionary arguments = DbCommandArguments.FromObjectDictionaries( record, @@ -562,7 +576,12 @@ private static async Task RunCommandAsync( try { - DbCommandResult result = await definition.InvokeAsync(arguments, stepMetadata, ct); + DbCommandResult result = await definition.InvokeAsync( + arguments, + stepMetadata, + callbackPolicy, + DbExtensionHostMode.Embedded, + ct); if (result.Succeeded) return FormEventDispatchResult.Success(result.Message); @@ -594,6 +613,7 @@ private static async Task RunActionSequenceAsync( Func? showMessage, Func>? executeBuiltInFormAction, IFormActionRuntime actionRuntime, + DbExtensionPolicy callbackPolicy, CancellationToken ct, int depth) { @@ -631,6 +651,7 @@ private static async Task RunActionSequenceAsync( showMessage, executeBuiltInFormAction, actionRuntime, + callbackPolicy, ct, depth + 1); } diff --git a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs index 7e7d669d..5bb214ca 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs @@ -7,6 +7,14 @@ public static class FormAutomationMetadata { private const string Surface = "admin.forms"; private static readonly string[] IgnoredFormulaFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + private static readonly HashSet BuiltInValidationRuleIds = new(StringComparer.OrdinalIgnoreCase) + { + "required", + "maxLength", + "range", + "regex", + "oneOf", + }; public static FormDefinition NormalizeForExport(FormDefinition form) { @@ -36,10 +44,13 @@ public static DbAutomationMetadata Build(FormDefinition form) AddActionSequence(builder, sequence, sequenceLocation); } + AddValidationRules(builder, form.ValidationRules, "form.validationRules"); + foreach (ControlDefinition control in form.Controls) { AddCommandButton(builder, control); AddComputedFormula(builder, control); + AddValidationRules(builder, control.ValidationOverride?.AddRules, $"controls.{control.ControlId}.validationRules"); foreach (ControlEventBinding binding in control.EventBindings ?? []) { string bindingLocation = $"controls.{control.ControlId}.events.{binding.Event}"; @@ -90,6 +101,23 @@ private static void AddActionSequence( } } + private static void AddValidationRules( + DbAutomationMetadataBuilder builder, + IReadOnlyList? rules, + string locationPrefix) + { + foreach (ValidationRule rule in rules ?? []) + { + if (string.IsNullOrWhiteSpace(rule.RuleId) || + BuiltInValidationRuleIds.Contains(rule.RuleId)) + { + continue; + } + + builder.AddValidationRule(rule.RuleId, Surface, $"{locationPrefix}.{rule.RuleId}"); + } + } + private static void AddScalarFunctions(DbAutomationMetadataBuilder builder, string? expression, string location) { foreach (DbAutomationScalarFunctionCall call in diff --git a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs index 1532041b..979431f3 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormCommandInvocation.cs @@ -26,6 +26,10 @@ public static Dictionary BuildMetadata(FormDefinition form) ["formId"] = form.FormId, ["formName"] = form.Name, ["tableName"] = form.TableName, + ["ownerKind"] = "Form", + ["ownerId"] = form.FormId, + ["ownerName"] = form.Name, + ["correlationId"] = Guid.NewGuid().ToString("N"), }; } diff --git a/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs b/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs index ce3760c4..fab89f31 100644 --- a/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs +++ b/src/CSharpDB.Admin.Reports/Services/AdminReportsServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class AdminReportsServiceCollectionExtensions public static IServiceCollection AddCSharpDbAdminReports(this IServiceCollection services) { services.TryAddSingleton(DbCommandRegistry.Empty); + services.TryAddSingleton(DbExtensionPolicies.DefaultHostCallbackPolicy); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs index 0d5903ff..483ddc6a 100644 --- a/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportEventDispatcher.cs @@ -4,8 +4,12 @@ namespace CSharpDB.Admin.Reports.Services; -public sealed class DefaultReportEventDispatcher(DbCommandRegistry commands) : IReportEventDispatcher +public sealed class DefaultReportEventDispatcher( + DbCommandRegistry commands, + DbExtensionPolicy? callbackPolicy = null) : IReportEventDispatcher { + private readonly DbExtensionPolicy _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; + public async Task DispatchAsync( ReportDefinition report, ReportSourceDefinition source, @@ -23,7 +27,12 @@ public async Task DispatchAsync( return ReportEventDispatchResult.Failure($"Report event '{eventKind}' has an empty command name."); if (!commands.TryGetCommand(binding.CommandName, out DbCommandDefinition definition)) - return ReportEventDispatchResult.Failure($"Unknown report command '{binding.CommandName}' for event '{eventKind}'."); + { + Dictionary missingMetadata = BuildMetadata(report, source, eventKind); + string message = $"Unknown report command '{binding.CommandName}' for event '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(binding.CommandName, missingMetadata, message); + return ReportEventDispatchResult.Failure(message); + } Dictionary arguments = DbCommandArguments.FromObjectDictionary(runtimeArguments, binding.Arguments); Dictionary metadata = BuildMetadata(report, source, eventKind); @@ -31,7 +40,12 @@ public async Task DispatchAsync( DbCommandResult result; try { - result = await definition.InvokeAsync(arguments, metadata, ct); + result = await definition.InvokeAsync( + arguments, + metadata, + _callbackPolicy, + DbExtensionHostMode.Embedded, + ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -64,6 +78,10 @@ private static Dictionary BuildMetadata( ["surface"] = "AdminReports", ["reportId"] = report.ReportId, ["reportName"] = report.Name, + ["ownerKind"] = "Report", + ["ownerId"] = report.ReportId, + ["ownerName"] = report.Name, + ["correlationId"] = Guid.NewGuid().ToString("N"), ["sourceKind"] = source.Kind.ToString(), ["sourceName"] = source.Name, ["event"] = eventKind.ToString(), diff --git a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs index 40e09d59..45b92466 100644 --- a/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs +++ b/src/CSharpDB.Admin.Reports/Services/DefaultReportPreviewService.cs @@ -9,12 +9,14 @@ public sealed class DefaultReportPreviewService( ICSharpDbClient dbClient, IReportSourceProvider sourceProvider, DbFunctionRegistry? functions = null, - IReportEventDispatcher? reportEvents = null) : IReportPreviewService + IReportEventDispatcher? reportEvents = null, + DbExtensionPolicy? callbackPolicy = null) : IReportPreviewService { internal const int MaxPreviewRows = 10000; internal const int MaxPreviewPages = 250; private const double PixelsPerInch = 96.0; private readonly IReportEventDispatcher _reportEvents = reportEvents ?? NullReportEventDispatcher.Instance; + private readonly DbExtensionPolicy _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; public async Task BuildPreviewAsync(ReportDefinition report, CancellationToken ct = default) { @@ -46,7 +48,7 @@ await DispatchReportEventOrThrowAsync( }, ct); - IReadOnlyList pages = Paginate(report, rows, functions ?? DbFunctionRegistry.Empty, out bool pageTruncated); + IReadOnlyList pages = Paginate(report, rows, functions ?? DbFunctionRegistry.Empty, _callbackPolicy, out bool pageTruncated); bool hasSchemaDrift = !string.Equals(source.SourceSchemaSignature, report.SourceSchemaSignature, StringComparison.Ordinal); string? warning = BuildWarning(rowTruncated, pageTruncated, hasSchemaDrift); @@ -177,6 +179,7 @@ private static IReadOnlyList Paginate( ReportDefinition report, List> rows, DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy, out bool pageTruncated) { pageTruncated = false; @@ -216,7 +219,7 @@ void StartPage() remainingBodyHeight = bodyHeight; if (pageHeader is not null) - currentBands.Add(RenderBand(pageHeader, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions)); + currentBands.Add(RenderBand(pageHeader, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy)); } void FinalizePage() @@ -225,7 +228,7 @@ void FinalizePage() return; if (pageFooter is not null) - currentBands.Add(RenderBand(pageFooter, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions)); + currentBands.Add(RenderBand(pageFooter, row: null, rows: [], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy)); pages.Add(new ReportPreviewPage(pages.Count + 1, currentBands.ToArray())); currentBands = null; @@ -259,7 +262,7 @@ bool TryAddBand(ReportRenderedBand band) return pages; } - if (reportHeader is not null && !TryAddBand(RenderBand(reportHeader, row: rows.FirstOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) + if (reportHeader is not null && !TryAddBand(RenderBand(reportHeader, row: rows.FirstOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -287,7 +290,7 @@ bool TryAddBand(ReportRenderedBand band) continue; IReadOnlyList> groupRows = rows.Skip(groupStartIndices[groupIndex]).Take(rowIndex - groupStartIndices[groupIndex]).ToArray(); - if (!TryAddBand(RenderBand(footerBand, row: rows[rowIndex - 1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) + if (!TryAddBand(RenderBand(footerBand, row: rows[rowIndex - 1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -310,7 +313,7 @@ bool TryAddBand(ReportRenderedBand band) if (headerBand is null) continue; - if (!TryAddBand(RenderBand(headerBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) + if (!TryAddBand(RenderBand(headerBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -320,7 +323,7 @@ bool TryAddBand(ReportRenderedBand band) havePreviousRow = true; - if (detailBand is not null && !TryAddBand(RenderBand(detailBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) + if (detailBand is not null && !TryAddBand(RenderBand(detailBand, row: row, rows: [row], pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -340,7 +343,7 @@ bool TryAddBand(ReportRenderedBand band) continue; IReadOnlyList> groupRows = rows.Skip(groupStartIndices[groupIndex]).ToArray(); - if (!TryAddBand(RenderBand(footerBand, row: rows[^1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions))) + if (!TryAddBand(RenderBand(footerBand, row: rows[^1], rows: groupRows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy))) { pageTruncated = truncated; return pages; @@ -349,7 +352,7 @@ bool TryAddBand(ReportRenderedBand band) } if (reportFooter is not null) - TryAddBand(RenderBand(reportFooter, row: rows.LastOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions)); + TryAddBand(RenderBand(reportFooter, row: rows.LastOrDefault(), rows: rows, pageNumber: pages.Count + 1, generatedUtc: generatedUtc, functions: functions, callbackPolicy: callbackPolicy)); FinalizePage(); pageTruncated = truncated; @@ -383,10 +386,11 @@ private static ReportRenderedBand RenderBand( IReadOnlyList> rows, int pageNumber, DateTime generatedUtc, - DbFunctionRegistry functions) + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy) { ReportRenderedControl[] renderedControls = band.Controls - .Select(control => RenderControl(control, row, rows, pageNumber, generatedUtc, functions)) + .Select(control => RenderControl(control, row, rows, pageNumber, generatedUtc, functions, callbackPolicy)) .ToArray(); return new ReportRenderedBand(band.BandId, band.BandKind, band.GroupId, band.Height, renderedControls); } @@ -397,13 +401,14 @@ private static ReportRenderedControl RenderControl( IReadOnlyList> rows, int pageNumber, DateTime generatedUtc, - DbFunctionRegistry functions) + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy) { string? text = control.ControlType switch { ReportControlType.Label => LookupProp(control.Props, "text"), ReportControlType.BoundText => ReportSql.FormatDisplayValue(LookupFieldValue(row, control.BoundFieldName), control.FormatString), - ReportControlType.CalculatedText => RenderCalculatedText(control, row, rows, pageNumber, generatedUtc, functions), + ReportControlType.CalculatedText => RenderCalculatedText(control, row, rows, pageNumber, generatedUtc, functions, callbackPolicy), _ => null, }; @@ -416,7 +421,8 @@ private static string RenderCalculatedText( IReadOnlyList> rows, int pageNumber, DateTime generatedUtc, - DbFunctionRegistry functions) + DbFunctionRegistry functions, + DbExtensionPolicy callbackPolicy) { string expression = control.Expression?.Trim() ?? string.Empty; string? prefix = LookupProp(control.Props, "prefix"); @@ -427,7 +433,7 @@ private static string RenderCalculatedText( "=PrintDate" => ReportSql.FormatDisplayValue(generatedUtc, control.FormatString), _ when ReportFormulaEvaluator.TryParseAggregate(expression, out string functionName, out string fieldName) => ReportSql.FormatDisplayValue(ReportFormulaEvaluator.EvaluateAggregate(functionName, rows.Select(item => LookupFieldValue(item, fieldName))), control.FormatString), - _ when ReportFormulaEvaluator.TryEvaluateScalar(expression, field => LookupFieldValue(row, field), functions, out object? scalarValue) + _ when ReportFormulaEvaluator.TryEvaluateScalar(expression, field => LookupFieldValue(row, field), functions, callbackPolicy, out object? scalarValue) => ReportSql.FormatDisplayValue(scalarValue, control.FormatString), _ when row is not null && ReportFormulaEvaluator.TryReadFieldReference(expression.TrimStart('='), out string boundFieldName) => ReportSql.FormatDisplayValue(LookupFieldValue(row, boundFieldName), control.FormatString), @@ -437,7 +443,7 @@ _ when row is not null { object? fieldValue = LookupFieldValue(row, field); return ReportSql.TryConvertToDouble(fieldValue, out double numeric) ? numeric : null; - }, functions), + }, functions, callbackPolicy), control.FormatString), _ => string.Empty, }; diff --git a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs index 06da94ae..00990bf4 100644 --- a/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Reports/Services/ReportFormulaEvaluator.cs @@ -14,6 +14,13 @@ public static class ReportFormulaEvaluator string? expression, Func fieldResolver, DbFunctionRegistry? functions) + => EvaluateNumeric(expression, fieldResolver, functions, callbackPolicy: null); + + public static double? EvaluateNumeric( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { if (string.IsNullOrWhiteSpace(expression)) return null; @@ -28,7 +35,7 @@ public static class ReportFormulaEvaluator try { - var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty); + var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty, callbackPolicy); double? result = parser.ParseExpression(); if (parser.Position < parser.Input.Length) return null; @@ -46,6 +53,14 @@ public static bool TryEvaluateScalar( Func fieldResolver, DbFunctionRegistry? functions, out object? value) + => TryEvaluateScalar(expression, fieldResolver, functions, callbackPolicy: null, out value); + + public static bool TryEvaluateScalar( + string? expression, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy, + out object? value) { value = null; if (functions == null || string.IsNullOrWhiteSpace(expression)) @@ -56,7 +71,7 @@ public static bool TryEvaluateScalar( return false; expr = expr[1..].Trim(); - if (!TryEvaluateFunctionCall(expr, fieldResolver, functions, out DbValue dbValue)) + if (!TryEvaluateFunctionCall(expr, fieldResolver, functions, callbackPolicy, out DbValue dbValue)) return false; value = FromDbValue(dbValue); @@ -141,13 +156,19 @@ private ref struct Parser private readonly Func _fieldResolver; private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy? _callbackPolicy; - public Parser(string input, Func fieldResolver, DbFunctionRegistry functions) + public Parser( + string input, + Func fieldResolver, + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy) { Input = input.AsSpan(); Position = 0; _fieldResolver = fieldResolver; _functions = functions; + _callbackPolicy = callbackPolicy; } public double? ParseExpression() @@ -348,14 +369,25 @@ public Parser(string input, Func fieldResolver, DbFunctionRegis private double? InvokeFunction(string functionName, List arguments) { if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) + { + DbCallbackDiagnostics.WriteMissingScalarInvocation( + functionName, + arguments.Count, + CreateReportCallbackMetadata(functionName), + $"Unknown scalar function '{functionName}'."); return null; + } if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) return null; try { - DbValue value = definition.Invoke(arguments.ToArray(), CreateReportCallbackMetadata(functionName)); + DbValue[] dbArguments = arguments.ToArray(); + IReadOnlyDictionary? metadata = CreateReportCallbackMetadata(functionName); + DbValue value = _callbackPolicy is null + ? definition.Invoke(dbArguments, metadata) + : definition.Invoke(dbArguments, metadata, _callbackPolicy, DbExtensionHostMode.Embedded); return value.Type switch { DbType.Integer => value.AsInteger, @@ -380,6 +412,7 @@ private static bool TryEvaluateFunctionCall( string expression, Func fieldResolver, DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy, out DbValue value) { value = DbValue.Null; @@ -393,11 +426,18 @@ private static bool TryEvaluateFunctionCall( string[] argumentTokens = SplitArguments(expression[(openParen + 1)..^1]); if (!functions.TryGetScalar(name, argumentTokens.Length, out var definition)) + { + DbCallbackDiagnostics.WriteMissingScalarInvocation( + name, + argumentTokens.Length, + CreateReportCallbackMetadata(name), + $"Unknown scalar function '{name}'."); return false; + } var arguments = new DbValue[argumentTokens.Length]; for (int i = 0; i < argumentTokens.Length; i++) - arguments[i] = EvaluateScalarArgument(argumentTokens[i].Trim(), fieldResolver, functions); + arguments[i] = EvaluateScalarArgument(argumentTokens[i].Trim(), fieldResolver, functions, callbackPolicy); if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) { @@ -405,16 +445,20 @@ private static bool TryEvaluateFunctionCall( return true; } - value = definition.Invoke(arguments, CreateReportCallbackMetadata(name)); + IReadOnlyDictionary? metadata = CreateReportCallbackMetadata(name); + value = callbackPolicy is null + ? definition.Invoke(arguments, metadata) + : definition.Invoke(arguments, metadata, callbackPolicy, DbExtensionHostMode.Embedded); return true; } private static DbValue EvaluateScalarArgument( string token, Func fieldResolver, - DbFunctionRegistry functions) + DbFunctionRegistry functions, + DbExtensionPolicy? callbackPolicy) { - if (TryEvaluateFunctionCall(token, fieldResolver, functions, out DbValue nestedValue)) + if (TryEvaluateFunctionCall(token, fieldResolver, functions, callbackPolicy, out DbValue nestedValue)) return nestedValue; if (token.StartsWith('\'') && token.EndsWith('\'') && token.Length >= 2) @@ -526,6 +570,7 @@ private static bool IsIdentifier(string value) { ["surface"] = "AdminReports", ["location"] = $"expressions.functions.{functionName}", + ["correlationId"] = Guid.NewGuid().ToString("N"), } : null; } diff --git a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor index 2a3e6ca9..e57587a9 100644 --- a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor @@ -1,7 +1,9 @@ @using CSharpDB.Primitives +@implements IDisposable @inject HostCallbackCatalogService CallbackCatalog @inject HostCallbackPolicyService CallbackPolicy @inject HostCallbackReadinessService CallbackReadiness +@inject HostCallbackDiagnosticsHistoryService CallbackDiagnosticsHistory @inject ToastService Toast @inject IJSRuntime JS @@ -38,6 +40,7 @@ +
RegistrationMissing
+ + + + + + + + + + + + + + + @foreach (DbCallbackInvocationDiagnostic diagnostic in _diagnostics.Take(50)) + { + + + + + + + + + + + + } + +
StartedCallbackResultPolicyErrorExceptionCorrelationOwnerLocation
@FormatStartedAt(diagnostic.StartedAtUtc) + @diagnostic.Name + @GetKindLabel(diagnostic.CallbackKind) @FormatArity(diagnostic.Arity) + + @GetDiagnosticStatus(diagnostic) + @if (!string.IsNullOrWhiteSpace(diagnostic.ResultMessage)) + { + @diagnostic.ResultMessage + } + + @FormatDiagnosticPolicy(diagnostic) + @if (!string.IsNullOrWhiteSpace(diagnostic.PolicyDenialReason)) + { + @diagnostic.PolicyDenialReason + } + @(diagnostic.ErrorCode ?? "-")@FormatExceptionType(diagnostic.ExceptionType)@FormatText(diagnostic.CorrelationId)@FormatOwner(diagnostic) + @FormatText(diagnostic.Surface) + @FormatText(diagnostic.Location) + @if (!string.IsNullOrWhiteSpace(diagnostic.EventName)) + { + @diagnostic.EventName + } +
+
+ } +
@code { @@ -286,6 +367,7 @@ private string _statusFilter = string.Empty; private string? _selectedKey; private HostCallbackReadinessReport? _readiness; + private IReadOnlyList _diagnostics = []; private IReadOnlyList FilteredEntries => _entries.Where(MatchesFilter).ToArray(); @@ -297,6 +379,8 @@ protected override async Task OnInitializedAsync() { + CallbackDiagnosticsHistory.Changed += OnDiagnosticsChanged; + LoadDiagnostics(); await RefreshAsync(); } @@ -309,12 +393,32 @@ { _entries = await CallbackCatalog.GetEntriesAsync(); _readiness = await CallbackReadiness.GetReadinessAsync(); + LoadDiagnostics(); ApplyTabSelection(); if (_selectedKey is null || !_entries.Any(entry => GetEntryKey(entry) == _selectedKey)) _selectedKey = _entries.Count > 0 ? GetEntryKey(_entries[0]) : null; } + private void LoadDiagnostics() + { + _diagnostics = CallbackDiagnosticsHistory.Snapshot(); + } + + private void OnDiagnosticsChanged() + { + _ = InvokeAsync(() => + { + LoadDiagnostics(); + StateHasChanged(); + }); + } + + private void ClearDiagnostics() + { + CallbackDiagnosticsHistory.Clear(); + } + private void ApplyTabSelection() { if (Tab.State.TryGetValue("SelectedCallbackName", out object? nameValue) && nameValue is string name && !string.IsNullOrWhiteSpace(name)) @@ -439,6 +543,7 @@ { AutomationCallbackKind.ScalarFunction => "Scalar function", AutomationCallbackKind.Command => "Command", + AutomationCallbackKind.ValidationRule => "Validation rule", _ => kind.ToString(), }; @@ -555,4 +660,61 @@ => decision.Status == DbExtensionCapabilityGrantStatus.Granted ? "callbacks-policy-badge allowed" : "callbacks-policy-badge denied"; + + private static string FormatStartedAt(DateTimeOffset? startedAt) + => startedAt?.ToLocalTime().ToString("HH:mm:ss") ?? "-"; + + private static string GetDiagnosticStatus(DbCallbackInvocationDiagnostic diagnostic) + { + if (diagnostic.TimedOut) + return "Timed out"; + if (diagnostic.Canceled) + return "Canceled"; + return diagnostic.Succeeded ? "Succeeded" : "Failed"; + } + + private static string GetDiagnosticStatusClass(DbCallbackInvocationDiagnostic diagnostic) + => diagnostic.Succeeded + ? "callbacks-policy-badge allowed" + : "callbacks-policy-badge denied"; + + private static string FormatDiagnosticPolicy(DbCallbackInvocationDiagnostic diagnostic) + => diagnostic.PolicyAllowed switch + { + true => "Allowed", + false => "Denied", + _ => "-", + }; + + private static string GetDiagnosticPolicyClass(DbCallbackInvocationDiagnostic diagnostic) + => diagnostic.PolicyAllowed switch + { + true => "callbacks-policy-badge allowed", + false => "callbacks-policy-badge denied", + _ => "callbacks-policy-badge missing", + }; + + private static string FormatExceptionType(string? exceptionType) + { + if (string.IsNullOrWhiteSpace(exceptionType)) + return "-"; + + int lastDot = exceptionType.LastIndexOf('.'); + return lastDot >= 0 && lastDot < exceptionType.Length - 1 + ? exceptionType[(lastDot + 1)..] + : exceptionType; + } + + private static string FormatText(string? value) + => string.IsNullOrWhiteSpace(value) ? "-" : value; + + private static string FormatOwner(DbCallbackInvocationDiagnostic diagnostic) + => string.IsNullOrWhiteSpace(diagnostic.OwnerKind) + ? "-" + : $"{diagnostic.OwnerKind}: {FormatText(diagnostic.OwnerName ?? diagnostic.OwnerId)}"; + + public void Dispose() + { + CallbackDiagnosticsHistory.Changed -= OnDiagnosticsChanged; + } } diff --git a/src/CSharpDB.Admin/Program.cs b/src/CSharpDB.Admin/Program.cs index eb635e88..4896c433 100644 --- a/src/CSharpDB.Admin/Program.cs +++ b/src/CSharpDB.Admin/Program.cs @@ -43,12 +43,14 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddCSharpDbAdminForms(); if (builder.Configuration.GetValue("AdminForms:EnableSampleControls")) builder.Services.AddSampleFormControls(); builder.Services.AddCSharpDbAdminReports(); var app = builder.Build(); +_ = app.Services.GetRequiredService(); // Warm the in-process database instance before any requests arrive. await using (var scope = app.Services.CreateAsyncScope()) diff --git a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs index ee512917..0484c1b2 100644 --- a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs +++ b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs @@ -1,9 +1,17 @@ +using System.Globalization; using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Services; using CSharpDB.Admin.Reports.Contracts; using CSharpDB.Admin.Reports.Models; +using CSharpDB.Admin.Reports.Services; +using CSharpDB.Client; +using CSharpDB.Client.Models; +using CSharpDB.Client.Pipelines; +using CSharpDB.Pipelines.Models; using CSharpDB.Primitives; using Microsoft.Extensions.DependencyInjection; +using ClientTriggerSchema = CSharpDB.Client.Models.TriggerSchema; namespace CSharpDB.Admin.Services; @@ -31,6 +39,40 @@ public sealed record HostCallbackCatalogEntry( public sealed class HostCallbackCatalogService { + private static readonly string[] SqlFunctionIgnoreList = + [ + "ABS", + "AVG", + "CAST", + "CHECK", + "COALESCE", + "COUNT", + "DATE", + "DATETIME", + "IFNULL", + "JULIANDAY", + "KEY", + "LENGTH", + "LOWER", + "LTRIM", + "MAX", + "MIN", + "NULLIF", + "PRINTF", + "RAISE", + "RANDOM", + "ROUND", + "RTRIM", + "STRFTIME", + "SUBSTR", + "SUBSTRING", + "SUM", + "TIME", + "TRIM", + "TYPEOF", + "UPPER", + ]; + private readonly IServiceProvider _services; public HostCallbackCatalogService(IServiceProvider services) @@ -42,9 +84,11 @@ public IReadOnlyList GetCallbacks() { DbFunctionRegistry functions = _services.GetService() ?? DbFunctionRegistry.Empty; DbCommandRegistry commands = _services.GetService() ?? DbCommandRegistry.Empty; + DbValidationRuleRegistry validationRules = _services.GetService() ?? DbValidationRuleRegistry.Empty; return functions.Callbacks .Concat(commands.Callbacks) + .Concat(validationRules.Callbacks) .OrderBy(static callback => callback.Kind) .ThenBy(static callback => callback.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(static callback => callback.Arity ?? -1) @@ -115,7 +159,12 @@ private async Task> GetReferencesAsync() { IReadOnlyList forms = await formRepository.ListAsync(); foreach (FormDefinition form in forms) - AddReferences(references, form.Automation, "Form", form.FormId, form.Name); + AddReferences( + references, + form.Automation ?? FormAutomationMetadata.Build(form), + "Form", + form.FormId, + form.Name); } catch { @@ -129,7 +178,12 @@ private async Task> GetReferencesAsync() { IReadOnlyList reports = await reportRepository.ListAsync(); foreach (ReportDefinition report in reports) - AddReferences(references, report.Automation, "Report", report.ReportId, report.Name); + AddReferences( + references, + report.Automation ?? ReportAutomationMetadata.Build(report), + "Report", + report.ReportId, + report.Name); } catch { @@ -137,6 +191,9 @@ private async Task> GetReferencesAsync() } } + if (_services.GetService() is { } dbClient) + await AddDatabaseReferencesAsync(references, dbClient); + return references .GroupBy( static reference => GetReferenceKey(reference), @@ -145,6 +202,130 @@ private async Task> GetReferencesAsync() .ToArray(); } + private static async Task AddDatabaseReferencesAsync(List references, ICSharpDbClient dbClient) + { + try + { + IReadOnlyList savedQueries = await dbClient.GetSavedQueriesAsync(); + foreach (SavedQueryDefinition query in savedQueries) + { + AddSqlScalarFunctionReferences( + references, + query.SqlText, + surface: "savedQueries", + locationPrefix: "sqlText", + ownerKind: "SavedQuery", + ownerId: query.Id.ToString(CultureInfo.InvariantCulture), + ownerName: query.Name); + } + } + catch + { + // Keep the host callback catalog usable even if saved query metadata is unavailable. + } + + try + { + IReadOnlyList procedures = await dbClient.GetProceduresAsync(includeDisabled: true); + foreach (ProcedureDefinition procedure in procedures) + { + AddSqlScalarFunctionReferences( + references, + procedure.BodySql, + surface: "procedures", + locationPrefix: "bodySql", + ownerKind: "Procedure", + ownerId: procedure.Name, + ownerName: procedure.Name); + } + } + catch + { + // Keep the host callback catalog usable even if procedure metadata is unavailable. + } + + try + { + IReadOnlyList triggers = await dbClient.GetTriggersAsync(); + foreach (ClientTriggerSchema trigger in triggers) + { + AddSqlScalarFunctionReferences( + references, + trigger.BodySql, + surface: "triggers", + locationPrefix: "bodySql", + ownerKind: "Trigger", + ownerId: trigger.TriggerName, + ownerName: trigger.TriggerName); + } + } + catch + { + // Keep the host callback catalog usable even if trigger metadata is unavailable. + } + + await AddPipelineReferencesAsync(references, dbClient); + } + + private static async Task AddPipelineReferencesAsync(List references, ICSharpDbClient dbClient) + { + try + { + IReadOnlyList tableNames = await dbClient.GetTableNamesAsync(); + if (!tableNames.Contains("_etl_pipelines", StringComparer.OrdinalIgnoreCase) + || !tableNames.Contains("_etl_pipeline_versions", StringComparer.OrdinalIgnoreCase)) + { + return; + } + + var pipelines = new CSharpDbPipelineCatalogClient(dbClient); + IReadOnlyList summaries = await pipelines.ListPipelinesAsync(limit: 500); + foreach (PipelineDefinitionSummary summary in summaries) + { + PipelinePackageDefinition? package = await pipelines.GetPipelineAsync(summary.Name); + if (package is null) + continue; + + DbAutomationMetadata automation = package.Automation ?? PipelineAutomationMetadata.Build(package); + AddReferences( + references, + automation, + ownerKind: "Pipeline", + ownerId: summary.Name, + ownerName: summary.Name); + } + } + catch + { + // Keep the host callback catalog usable even if pipeline metadata is unavailable. + } + } + + private static void AddSqlScalarFunctionReferences( + List references, + string? sql, + string surface, + string locationPrefix, + string ownerKind, + string ownerId, + string ownerName) + { + int index = 0; + foreach (DbAutomationScalarFunctionCall call in DbAutomationExpressionInspector.FindScalarFunctionCalls(sql, SqlFunctionIgnoreList)) + { + references.Add(new HostCallbackReference( + AutomationCallbackKind.ScalarFunction, + call.Name, + call.Arity, + surface, + $"{locationPrefix}.functions[{index}]", + ownerKind, + ownerId, + ownerName)); + index++; + } + } + private static void AddReferences( List references, DbAutomationMetadata? metadata, @@ -180,6 +361,19 @@ private static void AddReferences( ownerId, ownerName)); } + + foreach (DbAutomationValidationRuleReference validationRule in metadata.ValidationRules ?? []) + { + references.Add(new HostCallbackReference( + AutomationCallbackKind.ValidationRule, + validationRule.Name, + Arity: null, + validationRule.Surface, + validationRule.Location, + ownerKind, + ownerId, + ownerName)); + } } private static string GetEntryKey(AutomationCallbackKind kind, string name, int? arity) diff --git a/src/CSharpDB.Admin/Services/HostCallbackDiagnosticsHistoryService.cs b/src/CSharpDB.Admin/Services/HostCallbackDiagnosticsHistoryService.cs new file mode 100644 index 00000000..620d052a --- /dev/null +++ b/src/CSharpDB.Admin/Services/HostCallbackDiagnosticsHistoryService.cs @@ -0,0 +1,63 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Admin.Services; + +public sealed class HostCallbackDiagnosticsHistoryService : IObserver>, IDisposable +{ + private const int MaxEntries = 200; + private readonly object _gate = new(); + private readonly IDisposable _subscription; + private readonly List _entries = []; + + public HostCallbackDiagnosticsHistoryService() + { + _subscription = DbCallbackDiagnostics.Listener.Subscribe(this); + } + + public event Action? Changed; + + public IReadOnlyList Snapshot() + { + lock (_gate) + return _entries.ToArray(); + } + + public void Clear() + { + lock (_gate) + _entries.Clear(); + + Changed?.Invoke(); + } + + public void OnCompleted() + { + } + + public void OnError(Exception error) + { + } + + public void OnNext(KeyValuePair value) + { + if (value.Key != DbCallbackDiagnostics.InvocationEventName || + value.Value is not DbCallbackInvocationDiagnostic diagnostic) + { + return; + } + + lock (_gate) + { + _entries.Insert(0, diagnostic); + if (_entries.Count > MaxEntries) + _entries.RemoveRange(MaxEntries, _entries.Count - MaxEntries); + } + + Changed?.Invoke(); + } + + public void Dispose() + { + _subscription.Dispose(); + } +} diff --git a/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs b/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs index 0b8f5211..22eb01b2 100644 --- a/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs +++ b/src/CSharpDB.Admin/Services/HostCallbackReadinessService.cs @@ -69,6 +69,7 @@ private static DbAutomationMetadata BuildMetadata(IReadOnlyList(); var scalarFunctions = new List(); + var validationRules = new List(); foreach (HostCallbackCatalogEntry entry in entries) { @@ -90,13 +91,21 @@ private static DbAutomationMetadata BuildMetadata(IReadOnlyList definition.Kind switch diff --git a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs index dcddf857..3aea41a0 100644 --- a/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs +++ b/src/CSharpDB.Client/Pipelines/CSharpDbPipelineRunner.cs @@ -12,12 +12,14 @@ public sealed class CSharpDbPipelineRunner public CSharpDbPipelineRunner( ICSharpDbClient client, DbFunctionRegistry? functions = null, - DbCommandRegistry? commands = null) + DbCommandRegistry? commands = null, + DbExtensionPolicy? callbackPolicy = null) : this(new PipelineOrchestrator( - new CSharpDbPipelineComponentFactory(client, functions), + new CSharpDbPipelineComponentFactory(client, functions, callbackPolicy), new CSharpDbPipelineCheckpointStore(client), new CSharpDbPipelineRunLogger(client), - commands)) + commands, + callbackPolicy)) { } diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs index 5018d46d..f33230cd 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/DefaultPipelineComponentFactory.cs @@ -6,10 +6,12 @@ namespace CSharpDB.Pipelines.Runtime.BuiltIns; public sealed class DefaultPipelineComponentFactory : IPipelineComponentFactory { private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy _callbackPolicy; - public DefaultPipelineComponentFactory(DbFunctionRegistry? functions = null) + public DefaultPipelineComponentFactory(DbFunctionRegistry? functions = null, DbExtensionPolicy? callbackPolicy = null) { _functions = functions ?? DbFunctionRegistry.Empty; + _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; } public IPipelineSource CreateSource(PipelineSourceDefinition definition) => definition.Kind switch @@ -40,8 +42,8 @@ public IReadOnlyList CreateTransforms(IReadOnlyList new SelectPipelineTransform(definition), PipelineTransformKind.Rename => new RenamePipelineTransform(definition), PipelineTransformKind.Cast => new CastPipelineTransform(definition), - PipelineTransformKind.Filter => new FilterPipelineTransform(definition, _functions), - PipelineTransformKind.Derive => new DerivePipelineTransform(definition, _functions), + PipelineTransformKind.Filter => new FilterPipelineTransform(definition, _functions, _callbackPolicy), + PipelineTransformKind.Derive => new DerivePipelineTransform(definition, _functions, _callbackPolicy), PipelineTransformKind.Deduplicate => new DeduplicatePipelineTransform(definition), _ => throw new ArgumentOutOfRangeException(nameof(definition)), }; diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs index 6ed5a1d8..145ce55c 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/TransformSupport.cs @@ -53,7 +53,8 @@ internal static class TransformSupport public static bool EvaluateFilter( string expression, IReadOnlyDictionary row, - DbFunctionRegistry? functions = null) + DbFunctionRegistry? functions = null, + DbExtensionPolicy? callbackPolicy = null) { string[] operators = ["==", "!=", ">=", "<=", ">", "<"]; foreach (string op in operators) @@ -66,8 +67,8 @@ public static bool EvaluateFilter( string left = expression[..index].Trim(); string right = expression[(index + op.Length)..].Trim(); - object? leftValue = EvaluateFilterLeft(left, row, functions); - object? rightValue = ParseLiteral(right, row, functions); + object? leftValue = EvaluateFilterLeft(left, row, functions, callbackPolicy); + object? rightValue = ParseLiteral(right, row, functions, callbackPolicy); int comparison = Compare(leftValue, rightValue); return op switch @@ -88,7 +89,8 @@ public static bool EvaluateFilter( public static object? EvaluateDerivedExpression( string expression, IReadOnlyDictionary row, - DbFunctionRegistry? functions = null) + DbFunctionRegistry? functions = null, + DbExtensionPolicy? callbackPolicy = null) { string trimmed = expression.Trim(); if (row.TryGetValue(trimmed, out var columnValue)) @@ -96,29 +98,31 @@ public static bool EvaluateFilter( return columnValue; } - return ParseLiteral(trimmed, row, functions); + return ParseLiteral(trimmed, row, functions, callbackPolicy); } private static object? EvaluateValue( string token, IReadOnlyDictionary row, - DbFunctionRegistry? functions) + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { if (row.TryGetValue(token, out var columnValue)) return columnValue; - return ParseLiteral(token, row, functions); + return ParseLiteral(token, row, functions, callbackPolicy); } private static object? EvaluateFilterLeft( string token, IReadOnlyDictionary row, - DbFunctionRegistry? functions) + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { if (row.TryGetValue(token, out var columnValue)) return columnValue; - if (TryEvaluateFunctionCall(token, row, functions, out object? functionValue)) + if (TryEvaluateFunctionCall(token, row, functions, callbackPolicy, out object? functionValue)) return functionValue; return null; @@ -127,9 +131,10 @@ public static bool EvaluateFilter( private static object? ParseLiteral( string token, IReadOnlyDictionary row, - DbFunctionRegistry? functions) + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy) { - if (TryEvaluateFunctionCall(token, row, functions, out object? functionValue)) + if (TryEvaluateFunctionCall(token, row, functions, callbackPolicy, out object? functionValue)) return functionValue; if (token.StartsWith('\'') && token.EndsWith('\'') && token.Length >= 2) @@ -174,6 +179,7 @@ private static bool TryEvaluateFunctionCall( string expression, IReadOnlyDictionary row, DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy, out object? value) { value = null; @@ -190,11 +196,19 @@ private static bool TryEvaluateFunctionCall( DbFunctionRegistry registry = functions ?? DbFunctionRegistry.Empty; if (!registry.TryGetScalar(name, argumentTokens.Length, out var definition)) + { + IReadOnlyDictionary? metadata = CreatePipelineCallbackMetadata(name); + DbCallbackDiagnostics.WriteMissingScalarInvocation( + name, + argumentTokens.Length, + metadata, + $"Unknown pipeline scalar function '{name}'."); throw new InvalidOperationException($"Unknown scalar function '{name}'."); + } var arguments = new DbValue[argumentTokens.Length]; for (int i = 0; i < argumentTokens.Length; i++) - arguments[i] = ToDbValue(ParseLiteral(argumentTokens[i].Trim(), row, functions)); + arguments[i] = ToDbValue(ParseLiteral(argumentTokens[i].Trim(), row, functions, callbackPolicy)); if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) { @@ -204,7 +218,11 @@ private static bool TryEvaluateFunctionCall( try { - value = FromDbValue(definition.Invoke(arguments, CreatePipelineCallbackMetadata(name))); + IReadOnlyDictionary? metadata = CreatePipelineCallbackMetadata(name); + DbValue result = callbackPolicy is null + ? definition.Invoke(arguments, metadata) + : definition.Invoke(arguments, metadata, callbackPolicy, DbExtensionHostMode.Embedded); + value = FromDbValue(result); return true; } catch (Exception ex) @@ -334,11 +352,10 @@ private static int Compare(object? left, object? right) } private static IReadOnlyDictionary? CreatePipelineCallbackMetadata(string functionName) - => DbCallbackDiagnostics.IsInvocationEnabled - ? new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["surface"] = "Pipelines", - ["location"] = $"transforms.functions.{functionName}", - } - : null; + => new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "Pipelines", + ["location"] = $"transforms.functions.{functionName}", + ["correlationId"] = Guid.NewGuid().ToString("N"), + }; } diff --git a/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs b/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs index 7dea0108..5e7a87b3 100644 --- a/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs +++ b/src/CSharpDB.Pipelines/Runtime/BuiltIns/Transforms.cs @@ -106,12 +106,17 @@ public sealed class FilterPipelineTransform : IPipelineTransform { private readonly string _expression; private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy _callbackPolicy; - public FilterPipelineTransform(PipelineTransformDefinition definition, DbFunctionRegistry? functions = null) + public FilterPipelineTransform( + PipelineTransformDefinition definition, + DbFunctionRegistry? functions = null, + DbExtensionPolicy? callbackPolicy = null) { _expression = definition.FilterExpression ?? throw new InvalidOperationException("Filter transform requires an expression."); _functions = functions ?? DbFunctionRegistry.Empty; + _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; } public string Name => "filter"; @@ -119,7 +124,7 @@ public FilterPipelineTransform(PipelineTransformDefinition definition, DbFunctio public ValueTask TransformAsync(PipelineRowBatch batch, PipelineExecutionContext context, CancellationToken ct = default) { var rows = batch.Rows - .Where(row => TransformSupport.EvaluateFilter(_expression, row, _functions)) + .Where(row => TransformSupport.EvaluateFilter(_expression, row, _functions, _callbackPolicy)) .Select(row => new Dictionary(row, StringComparer.OrdinalIgnoreCase)) .ToArray(); @@ -131,12 +136,17 @@ public sealed class DerivePipelineTransform : IPipelineTransform { private readonly IReadOnlyList _columns; private readonly DbFunctionRegistry _functions; + private readonly DbExtensionPolicy _callbackPolicy; - public DerivePipelineTransform(PipelineTransformDefinition definition, DbFunctionRegistry? functions = null) + public DerivePipelineTransform( + PipelineTransformDefinition definition, + DbFunctionRegistry? functions = null, + DbExtensionPolicy? callbackPolicy = null) { _columns = definition.DerivedColumns ?? throw new InvalidOperationException("Derive transform requires derived columns."); _functions = functions ?? DbFunctionRegistry.Empty; + _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; } public string Name => "derive"; @@ -148,7 +158,7 @@ public ValueTask TransformAsync(PipelineRowBatch batch, Pipeli var output = new Dictionary(row, StringComparer.OrdinalIgnoreCase); foreach (var column in _columns) { - output[column.Name] = TransformSupport.EvaluateDerivedExpression(column.Expression, output, _functions); + output[column.Name] = TransformSupport.EvaluateDerivedExpression(column.Expression, output, _functions, _callbackPolicy); } return output; diff --git a/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs b/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs index 6deeb821..9c4b15aa 100644 --- a/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs +++ b/src/CSharpDB.Pipelines/Runtime/PipelineOrchestrator.cs @@ -11,17 +11,20 @@ public sealed class PipelineOrchestrator : IPipelineOrchestrator private readonly IPipelineCheckpointStore _checkpointStore; private readonly IPipelineRunLogger _runLogger; private readonly DbCommandRegistry _commands; + private readonly DbExtensionPolicy _callbackPolicy; public PipelineOrchestrator( IPipelineComponentFactory componentFactory, IPipelineCheckpointStore checkpointStore, IPipelineRunLogger runLogger, - DbCommandRegistry? commands = null) + DbCommandRegistry? commands = null, + DbExtensionPolicy? callbackPolicy = null) { _componentFactory = componentFactory; _checkpointStore = checkpointStore; _runLogger = runLogger; _commands = commands ?? DbCommandRegistry.Empty; + _callbackPolicy = callbackPolicy ?? DbExtensionPolicies.DefaultHostCallbackPolicy; } public async Task ExecuteAsync(PipelineRunRequest request, CancellationToken ct = default) @@ -332,7 +335,12 @@ private async Task DispatchHooksAsync( throw new InvalidOperationException($"Pipeline hook '{eventKind}' has an empty command name."); if (!_commands.TryGetCommand(hook.CommandName, out DbCommandDefinition definition)) + { + Dictionary missingMetadata = BuildHookMetadata(package, eventKind, context.RunId, context.Mode); + string message = $"Unknown pipeline command '{hook.CommandName}' for hook '{eventKind}'."; + DbCallbackDiagnostics.WriteMissingCommandInvocation(hook.CommandName, missingMetadata, message); throw new InvalidOperationException($"Unknown pipeline command '{hook.CommandName}' for hook '{eventKind}'."); + } Dictionary arguments = DbCommandArguments.FromObjectDictionary( BuildHookArguments(package, eventKind, context.RunId, context.Mode, metrics, batch, status, errorSummary), @@ -342,7 +350,12 @@ private async Task DispatchHooksAsync( DbCommandResult result; try { - result = await definition.InvokeAsync(arguments, metadata, ct); + result = await definition.InvokeAsync( + arguments, + metadata, + _callbackPolicy, + DbExtensionHostMode.Embedded, + ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { @@ -417,6 +430,11 @@ private static Dictionary BuildHookMetadata( ["runId"] = runId, ["mode"] = mode.ToString(), ["event"] = eventKind.ToString(), + ["location"] = $"hooks.{eventKind}", + ["ownerKind"] = "Pipeline", + ["ownerId"] = package.Name, + ["ownerName"] = package.Name, + ["correlationId"] = runId, }; private static string FormatErrorSummary(string step, string? component, PipelineRowBatch? batch, Exception exception) diff --git a/src/CSharpDB.Primitives/AutomationManifestValidation.cs b/src/CSharpDB.Primitives/AutomationManifestValidation.cs index 9bb41101..8eaa5b21 100644 --- a/src/CSharpDB.Primitives/AutomationManifestValidation.cs +++ b/src/CSharpDB.Primitives/AutomationManifestValidation.cs @@ -29,6 +29,7 @@ public enum AutomationCallbackKind Unknown, ScalarFunction, Command, + ValidationRule, } public static class AutomationManifestValidator @@ -40,16 +41,32 @@ public static AutomationValidationResult Validate( DbAutomationMetadata? metadata, DbFunctionRegistry functions, DbCommandRegistry commands) - => Validate(metadata, functions, commands, AutomationManifestValidationOptions.Default); + => Validate(metadata, functions, commands, DbValidationRuleRegistry.Empty, AutomationManifestValidationOptions.Default); + + public static AutomationValidationResult Validate( + DbAutomationMetadata? metadata, + DbFunctionRegistry functions, + DbCommandRegistry commands, + DbValidationRuleRegistry validationRules) + => Validate(metadata, functions, commands, validationRules, AutomationManifestValidationOptions.Default); public static AutomationValidationResult Validate( DbAutomationMetadata? metadata, DbFunctionRegistry functions, DbCommandRegistry commands, AutomationManifestValidationOptions? options) + => Validate(metadata, functions, commands, DbValidationRuleRegistry.Empty, options); + + public static AutomationValidationResult Validate( + DbAutomationMetadata? metadata, + DbFunctionRegistry functions, + DbCommandRegistry commands, + DbValidationRuleRegistry validationRules, + AutomationManifestValidationOptions? options) { ArgumentNullException.ThrowIfNull(functions); ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(validationRules); options ??= AutomationManifestValidationOptions.Default; var issues = new List(); @@ -72,8 +89,10 @@ public static AutomationValidationResult Validate( AddDuplicateCommandIssues(metadata.Commands, issues); AddDuplicateScalarFunctionIssues(metadata.ScalarFunctions, issues); + AddDuplicateValidationRuleIssues(metadata.ValidationRules, issues); ValidateCommands(metadata.Commands, commands, issues); ValidateScalarFunctions(metadata.ScalarFunctions, functions, issues); + ValidateValidationRules(metadata.ValidationRules, validationRules, issues); return CreateResult(issues); } @@ -152,6 +171,42 @@ private static void AddDuplicateScalarFunctionIssues( } } + private static void AddDuplicateValidationRuleIssues( + IReadOnlyList? references, + List issues) + { + if (references is null || references.Count == 0) + return; + + var occurrences = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DbAutomationValidationRuleReference reference in references) + { + string name = NormalizeName(reference.Name); + string surface = NormalizeSurface(reference.Surface); + string location = NormalizeLocation(reference.Location); + string key = CreateKey(name, surface, location); + + occurrences[key] = occurrences.TryGetValue(key, out DuplicateOccurrence? occurrence) + ? occurrence.Increment() + : new DuplicateOccurrence(name, surface, location, Count: 1, Arity: null); + } + + foreach (DuplicateOccurrence occurrence in occurrences.Values + .Where(static occurrence => occurrence.Count > 1) + .OrderBy(static occurrence => occurrence.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static occurrence => occurrence.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static occurrence => occurrence.Location, StringComparer.OrdinalIgnoreCase)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Warning, + AutomationCallbackKind.ValidationRule, + occurrence.Name, + occurrence.Surface, + occurrence.Location, + $"Validation rule '{occurrence.Name}' is referenced {occurrence.Count} times at {occurrence.Surface}:{occurrence.Location}. Remove duplicate metadata entries.")); + } + } + private static void ValidateCommands( IReadOnlyList? references, DbCommandRegistry commands, @@ -266,6 +321,48 @@ private static void ValidateScalarFunctions( } } + private static void ValidateValidationRules( + IReadOnlyList? references, + DbValidationRuleRegistry validationRules, + List issues) + { + if (references is null || references.Count == 0) + return; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (DbAutomationValidationRuleReference reference in references) + { + string name = NormalizeName(reference.Name); + string surface = NormalizeSurface(reference.Surface); + string location = NormalizeLocation(reference.Location); + if (!seen.Add(CreateKey(name, surface, location))) + continue; + + if (string.IsNullOrWhiteSpace(name)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.ValidationRule, + name, + surface, + location, + $"Validation rule reference at {surface}:{location} has no rule name.")); + continue; + } + + if (!validationRules.ContainsRuleName(name)) + { + issues.Add(new AutomationValidationIssue( + AutomationValidationSeverity.Error, + AutomationCallbackKind.ValidationRule, + name, + surface, + location, + $"Validation rule '{name}' is referenced by {surface} at {location}, but it is not registered in the host validation rule registry.")); + } + } + } + private static AutomationValidationResult CreateResult(IReadOnlyList issues) => new( !issues.Any(static issue => issue.Severity == AutomationValidationSeverity.Error), diff --git a/src/CSharpDB.Primitives/AutomationStubGeneration.cs b/src/CSharpDB.Primitives/AutomationStubGeneration.cs index 7c8e92f0..6c007304 100644 --- a/src/CSharpDB.Primitives/AutomationStubGeneration.cs +++ b/src/CSharpDB.Primitives/AutomationStubGeneration.cs @@ -21,6 +21,7 @@ public static string GenerateCSharp( CallbackGroup[] scalarFunctions = GetScalarFunctionGroups(metadata); CallbackGroup[] commands = GetCommandGroups(metadata); + CallbackGroup[] validationRules = GetValidationRuleGroups(metadata); var source = new StringBuilder(); source.AppendLine("using System;"); @@ -36,10 +37,11 @@ public static string GenerateCSharp( source.AppendLine("{"); source.Append(" public static void "); source.Append(options.MethodName); - source.AppendLine("(DbFunctionRegistryBuilder functions, DbCommandRegistryBuilder commands)"); + source.AppendLine("(DbFunctionRegistryBuilder functions, DbCommandRegistryBuilder commands, DbValidationRuleRegistryBuilder validationRules)"); source.AppendLine(" {"); source.AppendLine(" ArgumentNullException.ThrowIfNull(functions);"); source.AppendLine(" ArgumentNullException.ThrowIfNull(commands);"); + source.AppendLine(" ArgumentNullException.ThrowIfNull(validationRules);"); bool wroteRegistration = false; foreach (CallbackGroup function in scalarFunctions) @@ -56,6 +58,13 @@ public static string GenerateCSharp( wroteRegistration = true; } + foreach (CallbackGroup validationRule in validationRules) + { + source.AppendLine(); + AppendValidationRule(source, validationRule); + wroteRegistration = true; + } + if (!wroteRegistration) { source.AppendLine(); @@ -103,6 +112,22 @@ private static void AppendCommand(StringBuilder source, CallbackGroup command) source.AppendLine(" });"); } + private static void AppendValidationRule(StringBuilder source, CallbackGroup validationRule) + { + source.AppendLine(" validationRules.AddRule("); + source.Append(" "); + source.Append(ToStringLiteral(validationRule.Name)); + source.AppendLine(","); + source.AppendLine(" static async (context, ct) =>"); + source.AppendLine(" {"); + AppendReferenceComments(source, validationRule); + source.AppendLine(" await Task.CompletedTask;"); + source.Append(" throw new NotImplementedException("); + source.Append(ToStringLiteral($"Implement trusted validation rule '{validationRule.Name}'.")); + source.AppendLine(");"); + source.AppendLine(" });"); + } + private static void AppendReferenceComments(StringBuilder source, CallbackGroup callback) { source.AppendLine(" // References:"); @@ -150,6 +175,23 @@ private static CallbackGroup[] GetCommandGroups(DbAutomationMetadata metadata) .OrderBy(static group => group.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); + private static CallbackGroup[] GetValidationRuleGroups(DbAutomationMetadata metadata) + => (metadata.ValidationRules ?? []) + .Where(static reference => !string.IsNullOrWhiteSpace(reference.Name)) + .GroupBy( + static reference => reference.Name.Trim(), + StringComparer.OrdinalIgnoreCase) + .Select(static group => + { + DbAutomationValidationRuleReference first = group.First(); + return new CallbackGroup( + first.Name.Trim(), + Arity: null, + GetLocations(group.Select(static reference => new CallbackLocation(reference.Surface, reference.Location)))); + }) + .OrderBy(static group => group.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + private static CallbackLocation[] GetLocations(IEnumerable locations) => locations .Select(static location => new CallbackLocation( diff --git a/src/CSharpDB.Primitives/DbAutomationMetadata.cs b/src/CSharpDB.Primitives/DbAutomationMetadata.cs index 0c5326db..b53e4c99 100644 --- a/src/CSharpDB.Primitives/DbAutomationMetadata.cs +++ b/src/CSharpDB.Primitives/DbAutomationMetadata.cs @@ -3,12 +3,14 @@ namespace CSharpDB.Primitives; public sealed record DbAutomationMetadata( int MetadataVersion = DbAutomationMetadata.CurrentMetadataVersion, IReadOnlyList? Commands = null, - IReadOnlyList? ScalarFunctions = null) + IReadOnlyList? ScalarFunctions = null, + IReadOnlyList? ValidationRules = null) { public const int CurrentMetadataVersion = 1; public bool IsEmpty => (Commands is null || Commands.Count == 0) - && (ScalarFunctions is null || ScalarFunctions.Count == 0); + && (ScalarFunctions is null || ScalarFunctions.Count == 0) + && (ValidationRules is null || ValidationRules.Count == 0); } public sealed record DbAutomationCommandReference( @@ -22,12 +24,18 @@ public sealed record DbAutomationScalarFunctionReference( string Surface, string Location); +public sealed record DbAutomationValidationRuleReference( + string Name, + string Surface, + string Location); + public sealed record DbAutomationScalarFunctionCall(string Name, int Arity); public sealed class DbAutomationMetadataBuilder { private readonly List _commands = []; private readonly List _scalarFunctions = []; + private readonly List _validationRules = []; public DbAutomationMetadataBuilder AddCommand(string? name, string surface, string location) { @@ -52,11 +60,23 @@ public DbAutomationMetadataBuilder AddScalarFunction(string? name, int arity, st return this; } + public DbAutomationMetadataBuilder AddValidationRule(string? name, string surface, string location) + { + if (string.IsNullOrWhiteSpace(name)) + return this; + + ArgumentException.ThrowIfNullOrWhiteSpace(surface); + ArgumentException.ThrowIfNullOrWhiteSpace(location); + _validationRules.Add(new DbAutomationValidationRuleReference(name.Trim(), surface.Trim(), location.Trim())); + return this; + } + public DbAutomationMetadata Build() => new( DbAutomationMetadata.CurrentMetadataVersion, SortCommands(_commands), - SortScalarFunctions(_scalarFunctions)); + SortScalarFunctions(_scalarFunctions), + SortValidationRules(_validationRules)); private static IReadOnlyList SortCommands(IEnumerable commands) => commands @@ -80,6 +100,17 @@ private static IReadOnlyList SortScalarFunc .ThenBy(static function => function.Surface, StringComparer.OrdinalIgnoreCase) .ThenBy(static function => function.Location, StringComparer.OrdinalIgnoreCase) .ToArray(); + + private static IReadOnlyList SortValidationRules(IEnumerable rules) + => rules + .GroupBy( + static rule => $"{rule.Name}|{rule.Surface}|{rule.Location}", + StringComparer.OrdinalIgnoreCase) + .Select(static group => group.First()) + .OrderBy(static rule => rule.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static rule => rule.Surface, StringComparer.OrdinalIgnoreCase) + .ThenBy(static rule => rule.Location, StringComparer.OrdinalIgnoreCase) + .ToArray(); } public static class DbAutomationExpressionInspector diff --git a/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs b/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs index 42a32bc7..ac8554ed 100644 --- a/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs +++ b/src/CSharpDB.Primitives/DbCallbackDiagnostics.cs @@ -10,10 +10,19 @@ public sealed record DbCallbackInvocationDiagnostic( string? Surface, string? Location, string? EventName, + DateTimeOffset? StartedAtUtc, + string? CorrelationId, + string? OwnerKind, + string? OwnerId, + string? OwnerName, TimeSpan Elapsed, bool Succeeded, bool TimedOut, bool Canceled, + bool? PolicyAllowed, + string? PolicyDenialReason, + string? ErrorCode, + string? ExceptionType, string? ResultMessage, string? ExceptionMessage, IReadOnlyDictionary Metadata); @@ -34,6 +43,112 @@ internal static long GetTimestamp() internal static TimeSpan GetElapsedTime(long startingTimestamp) => Stopwatch.GetElapsedTime(startingTimestamp); + public static void WriteMissingScalarInvocation( + string name, + int arity, + IReadOnlyDictionary? metadata, + string message) + => WriteScalarInvocation( + name, + arity, + metadata, + elapsed: TimeSpan.Zero, + succeeded: false, + canceled: false, + exceptionMessage: message, + policyDecision: null, + errorCode: "MissingCallback", + exceptionType: null); + + public static void WriteMissingCommandInvocation( + string name, + IReadOnlyDictionary? metadata, + string message) + => WriteCommandInvocation( + name, + metadata, + elapsed: TimeSpan.Zero, + succeeded: false, + timedOut: false, + canceled: false, + resultMessage: null, + exceptionMessage: message, + policyDecision: null, + errorCode: "MissingCallback", + exceptionType: null); + + public static void WriteMissingValidationInvocation( + string name, + IReadOnlyDictionary? metadata, + string message) + => WriteValidationInvocation( + name, + metadata, + elapsed: TimeSpan.Zero, + succeeded: false, + timedOut: false, + canceled: false, + resultMessage: null, + exceptionMessage: message, + policyDecision: null, + errorCode: "MissingCallback", + exceptionType: null); + + public static void WritePolicyDeniedInvocation( + DbHostCallbackDescriptor callback, + IReadOnlyDictionary? metadata, + DbExtensionPolicyDecision decision) + { + ArgumentNullException.ThrowIfNull(callback); + ArgumentNullException.ThrowIfNull(decision); + + if (callback.Kind == AutomationCallbackKind.Command) + { + WriteCommandInvocation( + callback.Name, + metadata, + elapsed: TimeSpan.Zero, + succeeded: false, + timedOut: false, + canceled: false, + resultMessage: null, + exceptionMessage: decision.DenialReason, + decision, + errorCode: "PolicyDenied", + exceptionType: typeof(DbCallbackPolicyException).FullName); + return; + } + + if (callback.Kind == AutomationCallbackKind.ValidationRule) + { + WriteValidationInvocation( + callback.Name, + metadata, + elapsed: TimeSpan.Zero, + succeeded: false, + timedOut: false, + canceled: false, + resultMessage: null, + exceptionMessage: decision.DenialReason, + decision, + errorCode: "PolicyDenied", + exceptionType: typeof(DbCallbackPolicyException).FullName); + return; + } + + WriteScalarInvocation( + callback.Name, + callback.Arity ?? 0, + metadata, + elapsed: TimeSpan.Zero, + succeeded: false, + canceled: false, + exceptionMessage: decision.DenialReason, + decision, + errorCode: "PolicyDenied", + exceptionType: typeof(DbCallbackPolicyException).FullName); + } + [UnconditionalSuppressMessage( "Trimming", "IL2026", @@ -45,7 +160,10 @@ internal static void WriteScalarInvocation( TimeSpan elapsed, bool succeeded, bool canceled, - string? exceptionMessage) + string? exceptionMessage, + DbExtensionPolicyDecision? policyDecision = null, + string? errorCode = null, + string? exceptionType = null) { if (!IsInvocationEnabled) return; @@ -60,10 +178,19 @@ internal static void WriteScalarInvocation( ReadMetadata(metadataSnapshot, "surface"), BuildLocation(metadataSnapshot), ReadMetadata(metadataSnapshot, "event"), + ReadStartedAt(metadataSnapshot, elapsed), + ReadMetadata(metadataSnapshot, "correlationId") ?? ReadMetadata(metadataSnapshot, "correlation"), + ReadMetadata(metadataSnapshot, "ownerKind"), + ReadMetadata(metadataSnapshot, "ownerId"), + ReadMetadata(metadataSnapshot, "ownerName"), elapsed, succeeded, TimedOut: false, canceled, + policyDecision?.Allowed, + policyDecision?.DenialReason, + errorCode, + exceptionType, ResultMessage: null, exceptionMessage, metadataSnapshot)); @@ -81,7 +208,10 @@ internal static void WriteCommandInvocation( bool timedOut, bool canceled, string? resultMessage, - string? exceptionMessage) + string? exceptionMessage, + DbExtensionPolicyDecision? policyDecision = null, + string? errorCode = null, + string? exceptionType = null) { if (!IsInvocationEnabled) return; @@ -96,10 +226,67 @@ internal static void WriteCommandInvocation( ReadMetadata(metadataSnapshot, "surface"), BuildLocation(metadataSnapshot), ReadMetadata(metadataSnapshot, "event"), + ReadStartedAt(metadataSnapshot, elapsed), + ReadMetadata(metadataSnapshot, "correlationId") ?? ReadMetadata(metadataSnapshot, "correlation"), + ReadMetadata(metadataSnapshot, "ownerKind"), + ReadMetadata(metadataSnapshot, "ownerId"), + ReadMetadata(metadataSnapshot, "ownerName"), elapsed, succeeded, timedOut, canceled, + policyDecision?.Allowed, + policyDecision?.DenialReason, + errorCode, + exceptionType, + resultMessage, + exceptionMessage, + metadataSnapshot)); + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2026", + Justification = "Callback diagnostics are emitted only for subscribed hosts; the strongly typed event payload is part of the public diagnostics contract.")] + internal static void WriteValidationInvocation( + string name, + IReadOnlyDictionary? metadata, + TimeSpan elapsed, + bool succeeded, + bool timedOut, + bool canceled, + string? resultMessage, + string? exceptionMessage, + DbExtensionPolicyDecision? policyDecision = null, + string? errorCode = null, + string? exceptionType = null) + { + if (!IsInvocationEnabled) + return; + + IReadOnlyDictionary metadataSnapshot = CopyMetadata(metadata); + Listener.Write( + InvocationEventName, + new DbCallbackInvocationDiagnostic( + AutomationCallbackKind.ValidationRule, + name, + Arity: null, + ReadMetadata(metadataSnapshot, "surface"), + BuildLocation(metadataSnapshot), + ReadMetadata(metadataSnapshot, "event"), + ReadStartedAt(metadataSnapshot, elapsed), + ReadMetadata(metadataSnapshot, "correlationId") ?? ReadMetadata(metadataSnapshot, "correlation"), + ReadMetadata(metadataSnapshot, "ownerKind"), + ReadMetadata(metadataSnapshot, "ownerId"), + ReadMetadata(metadataSnapshot, "ownerName"), + elapsed, + succeeded, + timedOut, + canceled, + policyDecision?.Allowed, + policyDecision?.DenialReason, + errorCode, + exceptionType, resultMessage, exceptionMessage, metadataSnapshot)); @@ -138,6 +325,23 @@ private static IReadOnlyDictionary CopyMetadata( return null; } + private static DateTimeOffset ReadStartedAt( + IReadOnlyDictionary metadata, + TimeSpan elapsed) + { + if (TryReadMetadata(metadata, "startedAtUtc", out string? rawStartedAt) && + DateTimeOffset.TryParse( + rawStartedAt, + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, + out DateTimeOffset parsed)) + { + return parsed; + } + + return DateTimeOffset.UtcNow - elapsed; + } + private static string? ReadMetadata(IReadOnlyDictionary metadata, string key) => TryReadMetadata(metadata, key, out string? value) ? value : null; diff --git a/src/CSharpDB.Primitives/DbCommands.cs b/src/CSharpDB.Primitives/DbCommands.cs index 1265a44c..5438378b 100644 --- a/src/CSharpDB.Primitives/DbCommands.cs +++ b/src/CSharpDB.Primitives/DbCommands.cs @@ -68,23 +68,57 @@ public ValueTask InvokeAsync( return InvokeCoreAsync(context, ct); } + public ValueTask InvokeAsync( + IReadOnlyDictionary? arguments, + IReadOnlyDictionary? metadata, + DbExtensionPolicy policy, + DbExtensionHostMode hostMode = DbExtensionHostMode.Embedded, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(policy); + + var context = new DbCommandContext( + Name, + arguments ?? EmptyDbValueDictionary.Instance, + metadata ?? EmptyStringDictionary.Instance); + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + Descriptor, + policy, + hostMode); + + if (!decision.Allowed) + { + DbCallbackDiagnostics.WritePolicyDeniedInvocation(Descriptor, context.Metadata, decision); + throw new DbCallbackPolicyException(Descriptor, decision); + } + + if (DbCallbackDiagnostics.IsInvocationEnabled) + return InvokeWithDiagnosticsAsync(context, ct, decision.Timeout, decision); + + return InvokeCoreAsync(context, ct, decision.Timeout); + } + private ValueTask InvokeCoreAsync( DbCommandContext context, - CancellationToken ct) + CancellationToken ct, + TimeSpan? timeoutOverride = null) { - return Options.Timeout is { } timeout - ? InvokeWithTimeoutAsync(context, timeout, ct) + TimeSpan? timeout = timeoutOverride ?? Options.Timeout; + return timeout is { } value + ? InvokeWithTimeoutAsync(context, value, ct) : _invoke(context, ct); } private async ValueTask InvokeWithDiagnosticsAsync( DbCommandContext context, - CancellationToken ct) + CancellationToken ct, + TimeSpan? timeoutOverride = null, + DbExtensionPolicyDecision? policyDecision = null) { long started = DbCallbackDiagnostics.GetTimestamp(); try { - DbCommandResult result = await InvokeCoreAsync(context, ct).ConfigureAwait(false); + DbCommandResult result = await InvokeCoreAsync(context, ct, timeoutOverride).ConfigureAwait(false); DbCallbackDiagnostics.WriteCommandInvocation( Name, context.Metadata, @@ -93,7 +127,8 @@ private async ValueTask InvokeWithDiagnosticsAsync( timedOut: false, canceled: false, result.Message, - exceptionMessage: null); + exceptionMessage: null, + policyDecision: policyDecision); return result; } catch (TimeoutException ex) when (IsCommandTimeoutException(ex)) @@ -106,7 +141,10 @@ private async ValueTask InvokeWithDiagnosticsAsync( timedOut: true, canceled: false, resultMessage: null, - ex.Message); + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Timeout", + exceptionType: ex.GetType().FullName); throw; } catch (OperationCanceledException ex) @@ -119,7 +157,10 @@ private async ValueTask InvokeWithDiagnosticsAsync( timedOut: false, canceled: true, resultMessage: null, - ex.Message); + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Canceled", + exceptionType: ex.GetType().FullName); throw; } catch (Exception ex) @@ -132,7 +173,10 @@ private async ValueTask InvokeWithDiagnosticsAsync( timedOut: false, canceled: false, resultMessage: null, - ex.Message); + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Exception", + exceptionType: ex.GetType().FullName); throw; } } diff --git a/src/CSharpDB.Primitives/DbExtensions.cs b/src/CSharpDB.Primitives/DbExtensions.cs index 1ab1d810..fc69230b 100644 --- a/src/CSharpDB.Primitives/DbExtensions.cs +++ b/src/CSharpDB.Primitives/DbExtensions.cs @@ -105,6 +105,56 @@ public sealed record DbExtensionCapabilityDecision( string? Reason, string? PolicySource); +public static class DbExtensionPolicies +{ + public const string DefaultHostCallbackPolicySource = "CSharpDB default host callback policy"; + + public static DbExtensionPolicy DefaultHostCallbackPolicy { get; } = new( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ScalarFunctions, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Host-registered scalar functions are allowed by default.", + PolicySource: DefaultHostCallbackPolicySource), + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Host-registered commands are allowed by default.", + PolicySource: DefaultHostCallbackPolicySource), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Host-registered validation rules are allowed by default.", + PolicySource: DefaultHostCallbackPolicySource), + ], + DefaultTimeout: TimeSpan.FromSeconds(5), + RequireSignature: true, + AllowedHostModes: DbExtensionHostMode.Embedded); +} + +public sealed class DbCallbackPolicyException : InvalidOperationException +{ + public DbCallbackPolicyException( + DbHostCallbackDescriptor callback, + DbExtensionPolicyDecision decision) + : base(CreateMessage(callback, decision)) + { + Callback = callback; + Decision = decision; + } + + public DbHostCallbackDescriptor Callback { get; } + + public DbExtensionPolicyDecision Decision { get; } + + private static string CreateMessage( + DbHostCallbackDescriptor callback, + DbExtensionPolicyDecision decision) + => $"Host callback '{callback.Name}' was denied by policy: {decision.DenialReason ?? "No denial reason was provided."}"; +} + public sealed record DbExtensionInvocationRequest( string ExtensionId, DbExtensionExportKind Kind, @@ -158,7 +208,7 @@ public static DbExtensionPolicyDecision Evaluate( DbExtensionCapabilityDecision? deniedCapability = capabilityDecisions .FirstOrDefault(static decision => decision.Status != DbExtensionCapabilityGrantStatus.Granted); if (deniedCapability is not null) - denialReason = $"Capability '{deniedCapability.Name}' is not granted."; + denialReason = deniedCapability.Reason ?? $"Capability '{deniedCapability.Name}' is not granted."; } return new DbExtensionPolicyDecision( @@ -194,7 +244,7 @@ public static DbExtensionPolicyDecision Evaluate( DbExtensionCapabilityDecision? deniedCapability = capabilityDecisions .FirstOrDefault(static decision => decision.Status != DbExtensionCapabilityGrantStatus.Granted); if (deniedCapability is not null) - denialReason = $"Capability '{deniedCapability.Name}' is not granted."; + denialReason = deniedCapability.Reason ?? $"Capability '{deniedCapability.Name}' is not granted."; } TimeSpan timeout = callback.Timeout ?? policy.DefaultTimeout ?? DefaultExecutionTimeout; @@ -216,34 +266,130 @@ private static IReadOnlyList EvaluateCapabilities if (requests is null || requests.Count == 0) return []; - var grantByCapability = (grants ?? []) - .GroupBy(static grant => grant.Name) - .ToDictionary( - static group => group.Key, - static group => group.Last(), - EqualityComparer.Default); - var decisions = new DbExtensionCapabilityDecision[requests.Count]; for (int i = 0; i < requests.Count; i++) { DbExtensionCapabilityRequest request = requests[i]; - if (!grantByCapability.TryGetValue(request.Name, out DbExtensionCapabilityGrant? grant)) + DbExtensionCapabilityGrant[] candidateGrants = (grants ?? []) + .Where(grant => grant.Name == request.Name) + .ToArray(); + if (candidateGrants.Length == 0) { decisions[i] = new DbExtensionCapabilityDecision( request.Name, DbExtensionCapabilityGrantStatus.Denied, - "No matching capability grant.", + $"No grant exists for capability '{request.Name}'.", PolicySource: null); continue; } + DbExtensionCapabilityGrant? deniedGrant = candidateGrants + .Where(grant => grant.Status == DbExtensionCapabilityGrantStatus.Denied) + .FirstOrDefault(grant => GrantMatchesRequest(grant, request)); + if (deniedGrant is not null) + { + decisions[i] = new DbExtensionCapabilityDecision( + request.Name, + DbExtensionCapabilityGrantStatus.Denied, + deniedGrant.Reason ?? $"Capability '{request.Name}' is denied for {FormatRequestScope(request)}.", + deniedGrant.PolicySource); + continue; + } + + DbExtensionCapabilityGrant? grantedGrant = candidateGrants + .Where(grant => grant.Status == DbExtensionCapabilityGrantStatus.Granted) + .FirstOrDefault(grant => GrantMatchesRequest(grant, request)); + if (grantedGrant is not null) + { + decisions[i] = new DbExtensionCapabilityDecision( + request.Name, + DbExtensionCapabilityGrantStatus.Granted, + grantedGrant.Reason, + grantedGrant.PolicySource); + continue; + } + decisions[i] = new DbExtensionCapabilityDecision( request.Name, - grant.Status, - grant.Reason, - grant.PolicySource); + DbExtensionCapabilityGrantStatus.Denied, + $"No grant for capability '{request.Name}' matches requested {FormatRequestScope(request)}.", + PolicySource: null); } return decisions; } + + private static bool GrantMatchesRequest( + DbExtensionCapabilityGrant grant, + DbExtensionCapabilityRequest request) + => MatchesStringScope(grant.Exports, request.Exports) + && MatchesStringScope(grant.Tables, request.Tables) + && MatchesDictionaryScope(grant.Scope, request.Scope); + + private static bool MatchesStringScope( + IReadOnlyList? grantValues, + IReadOnlyList? requestValues) + { + if (grantValues is null || grantValues.Count == 0) + return true; + + if (requestValues is null || requestValues.Count == 0) + return false; + + var allowed = new HashSet( + grantValues.Where(static value => !string.IsNullOrWhiteSpace(value)), + StringComparer.OrdinalIgnoreCase); + if (allowed.Contains("*")) + return true; + + return requestValues + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .All(allowed.Contains); + } + + private static bool MatchesDictionaryScope( + IReadOnlyDictionary? grantScope, + IReadOnlyDictionary? requestScope) + { + if (grantScope is null || grantScope.Count == 0) + return true; + + if (requestScope is null || requestScope.Count == 0) + return false; + + foreach ((string key, string expectedValue) in grantScope) + { + if (string.IsNullOrWhiteSpace(key)) + continue; + + if (!requestScope.TryGetValue(key, out string? actualValue)) + return false; + + if (!string.Equals(expectedValue, "*", StringComparison.Ordinal) && + !string.Equals(expectedValue, actualValue, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + } + + private static string FormatRequestScope(DbExtensionCapabilityRequest request) + { + string exports = FormatScopeList(request.Exports); + string tables = FormatScopeList(request.Tables); + string scope = request.Scope is { Count: > 0 } + ? string.Join(", ", request.Scope + .OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase) + .Select(static item => $"{item.Key}={item.Value}")) + : "*"; + + return $"exports [{exports}], tables [{tables}], scope [{scope}]"; + } + + private static string FormatScopeList(IReadOnlyList? values) + => values is { Count: > 0 } + ? string.Join(", ", values) + : "*"; } diff --git a/src/CSharpDB.Primitives/DbFunctions.cs b/src/CSharpDB.Primitives/DbFunctions.cs index a5b5966f..b6e85e4c 100644 --- a/src/CSharpDB.Primitives/DbFunctions.cs +++ b/src/CSharpDB.Primitives/DbFunctions.cs @@ -62,6 +62,33 @@ public DbValue Invoke(ReadOnlySpan arguments) public DbValue Invoke( ReadOnlySpan arguments, IReadOnlyDictionary? metadata) + => InvokeCore(arguments, metadata, policyDecision: null); + + public DbValue Invoke( + ReadOnlySpan arguments, + IReadOnlyDictionary? metadata, + DbExtensionPolicy policy, + DbExtensionHostMode hostMode = DbExtensionHostMode.Embedded) + { + ArgumentNullException.ThrowIfNull(policy); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + Descriptor, + policy, + hostMode); + if (!decision.Allowed) + { + DbCallbackDiagnostics.WritePolicyDeniedInvocation(Descriptor, metadata, decision); + throw new DbCallbackPolicyException(Descriptor, decision); + } + + return InvokeCore(arguments, metadata, decision); + } + + private DbValue InvokeCore( + ReadOnlySpan arguments, + IReadOnlyDictionary? metadata, + DbExtensionPolicyDecision? policyDecision) { DbScalarFunctionContext context = DbScalarFunctionContext.Create(Name, metadata); if (!DbCallbackDiagnostics.IsInvocationEnabled) @@ -78,7 +105,8 @@ public DbValue Invoke( DbCallbackDiagnostics.GetElapsedTime(started), succeeded: true, canceled: false, - exceptionMessage: null); + exceptionMessage: null, + policyDecision: policyDecision); return result; } catch (OperationCanceledException ex) @@ -90,7 +118,10 @@ public DbValue Invoke( DbCallbackDiagnostics.GetElapsedTime(started), succeeded: false, canceled: true, - ex.Message); + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Canceled", + exceptionType: ex.GetType().FullName); throw; } catch (Exception ex) @@ -102,7 +133,10 @@ public DbValue Invoke( DbCallbackDiagnostics.GetElapsedTime(started), succeeded: false, canceled: false, - ex.Message); + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Exception", + exceptionType: ex.GetType().FullName); throw; } } diff --git a/src/CSharpDB.Primitives/DbHostCallbacks.cs b/src/CSharpDB.Primitives/DbHostCallbacks.cs index 9ea916be..8cfbf94d 100644 --- a/src/CSharpDB.Primitives/DbHostCallbacks.cs +++ b/src/CSharpDB.Primitives/DbHostCallbacks.cs @@ -45,6 +45,19 @@ public static DbHostCallbackDescriptor CreateCommand( IsLongRunning: options.IsLongRunning, Metadata: options.Metadata); + public static DbHostCallbackDescriptor CreateValidationRule( + string name, + DbValidationRuleOptions options) + => new( + AutomationCallbackKind.ValidationRule, + name, + DbExtensionRuntimeKind.HostCallback, + CreateCapabilities(DbExtensionCapability.ValidationRules, name, options.AdditionalCapabilities), + Description: options.Description, + Timeout: options.Timeout, + IsLongRunning: options.IsLongRunning, + Metadata: options.Metadata); + private static IReadOnlyList CreateCapabilities( DbExtensionCapability baseCapability, string exportName, diff --git a/src/CSharpDB.Primitives/DbValidationRules.cs b/src/CSharpDB.Primitives/DbValidationRules.cs new file mode 100644 index 00000000..c11f0820 --- /dev/null +++ b/src/CSharpDB.Primitives/DbValidationRules.cs @@ -0,0 +1,411 @@ +namespace CSharpDB.Primitives; + +public enum DbValidationRuleScope +{ + Field = 0, + Form = 1, +} + +public delegate ValueTask DbValidationRuleDelegate( + DbValidationRuleContext context, + CancellationToken ct); + +public sealed record DbValidationFailure( + string? FieldName, + string Message, + string? RuleId = null); + +public sealed record DbValidationRuleResult( + bool Succeeded, + IReadOnlyList? Failures = null, + string? Message = null) +{ + public static DbValidationRuleResult Success(string? message = null) + => new(true, Failures: [], message); + + public static DbValidationRuleResult Failure(string message, string? fieldName = null, string? ruleId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + return new(false, [new DbValidationFailure(fieldName, message, ruleId)], message); + } + + public static DbValidationRuleResult Failure(IEnumerable failures, string? message = null) + { + ArgumentNullException.ThrowIfNull(failures); + DbValidationFailure[] failureList = failures + .Where(static failure => !string.IsNullOrWhiteSpace(failure.Message)) + .ToArray(); + + return new(false, failureList, message); + } +} + +public sealed record DbValidationRuleContext( + string RuleName, + DbValidationRuleScope Scope, + IReadOnlyDictionary Record, + IReadOnlyDictionary Parameters, + IReadOnlyDictionary Metadata) +{ + public string? FormId { get; init; } + public string? FormName { get; init; } + public string? TableName { get; init; } + public string? ControlId { get; init; } + public string? FieldName { get; init; } + public DbValue Value { get; init; } = DbValue.Null; + public string? FallbackMessage { get; init; } + + public static DbValidationRuleContext Create( + string ruleName, + DbValidationRuleScope scope, + IReadOnlyDictionary? record = null, + IReadOnlyDictionary? parameters = null, + IReadOnlyDictionary? metadata = null) + => new( + ruleName, + scope, + record ?? EmptyDbValueDictionary.Instance, + parameters ?? EmptyDbValueDictionary.Instance, + metadata ?? EmptyStringDictionary.Instance); + + private static class EmptyDbValueDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + private static class EmptyStringDictionary + { + public static readonly IReadOnlyDictionary Instance = + new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} + +public sealed record DbValidationRuleOptions( + string? Description = null, + TimeSpan? Timeout = null, + bool IsLongRunning = false, + IReadOnlyList? AdditionalCapabilities = null, + IReadOnlyDictionary? Metadata = null); + +public sealed class DbValidationRuleDefinition +{ + private const string ValidationTimeoutDataKey = "CSharpDB.ValidationRuleTimedOut"; + private readonly DbValidationRuleDelegate _invoke; + + internal DbValidationRuleDefinition( + string name, + DbValidationRuleOptions options, + DbValidationRuleDelegate invoke) + { + Name = name; + Options = options; + Descriptor = DbHostCallbackDescriptorFactory.CreateValidationRule(name, options); + _invoke = invoke; + } + + public string Name { get; } + + public DbValidationRuleOptions Options { get; } + + public DbHostCallbackDescriptor Descriptor { get; } + + public ValueTask InvokeAsync( + DbValidationRuleContext context, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (DbCallbackDiagnostics.IsInvocationEnabled) + return InvokeWithDiagnosticsAsync(context, ct); + + return InvokeCoreAsync(context, ct); + } + + public ValueTask InvokeAsync( + DbValidationRuleContext context, + DbExtensionPolicy policy, + DbExtensionHostMode hostMode = DbExtensionHostMode.Embedded, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(policy); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + Descriptor, + policy, + hostMode); + + if (!decision.Allowed) + { + DbCallbackDiagnostics.WritePolicyDeniedInvocation(Descriptor, context.Metadata, decision); + throw new DbCallbackPolicyException(Descriptor, decision); + } + + if (DbCallbackDiagnostics.IsInvocationEnabled) + return InvokeWithDiagnosticsAsync(context, ct, decision.Timeout, decision); + + return InvokeCoreAsync(context, ct, decision.Timeout); + } + + private ValueTask InvokeCoreAsync( + DbValidationRuleContext context, + CancellationToken ct, + TimeSpan? timeoutOverride = null) + { + TimeSpan? timeout = timeoutOverride ?? Options.Timeout; + return timeout is { } value + ? InvokeWithTimeoutAsync(context, value, ct) + : _invoke(context, ct); + } + + private async ValueTask InvokeWithDiagnosticsAsync( + DbValidationRuleContext context, + CancellationToken ct, + TimeSpan? timeoutOverride = null, + DbExtensionPolicyDecision? policyDecision = null) + { + long started = DbCallbackDiagnostics.GetTimestamp(); + try + { + DbValidationRuleResult result = await InvokeCoreAsync(context, ct, timeoutOverride).ConfigureAwait(false); + DbCallbackDiagnostics.WriteValidationInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: result.Succeeded, + timedOut: false, + canceled: false, + result.Message, + exceptionMessage: null, + policyDecision: policyDecision); + return result; + } + catch (TimeoutException ex) when (IsValidationTimeoutException(ex)) + { + DbCallbackDiagnostics.WriteValidationInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + timedOut: true, + canceled: false, + resultMessage: null, + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Timeout", + exceptionType: ex.GetType().FullName); + throw; + } + catch (OperationCanceledException ex) + { + DbCallbackDiagnostics.WriteValidationInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + timedOut: false, + canceled: true, + resultMessage: null, + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Canceled", + exceptionType: ex.GetType().FullName); + throw; + } + catch (Exception ex) + { + DbCallbackDiagnostics.WriteValidationInvocation( + Name, + context.Metadata, + DbCallbackDiagnostics.GetElapsedTime(started), + succeeded: false, + timedOut: false, + canceled: false, + resultMessage: null, + exceptionMessage: ex.Message, + policyDecision: policyDecision, + errorCode: "Exception", + exceptionType: ex.GetType().FullName); + throw; + } + } + + private async ValueTask InvokeWithTimeoutAsync( + DbValidationRuleContext context, + TimeSpan timeout, + CancellationToken ct) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token); + + try + { + ValueTask invocation = _invoke(context, linkedCts.Token); + if (invocation.IsCompletedSuccessfully) + return invocation.Result; + + return await invocation.AsTask().WaitAsync(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) + { + throw CreateTimeoutException(timeout, ex); + } + } + + private TimeoutException CreateTimeoutException(TimeSpan timeout, Exception inner) + { + var exception = new TimeoutException($"Validation rule '{Name}' timed out after {FormatTimeout(timeout)}.", inner); + exception.Data[ValidationTimeoutDataKey] = true; + return exception; + } + + private static bool IsValidationTimeoutException(TimeoutException exception) + => exception.Data[ValidationTimeoutDataKey] is true; + + private static string FormatTimeout(TimeSpan timeout) + => timeout.TotalMilliseconds < 1000 + ? $"{timeout.TotalMilliseconds:0.###}ms" + : $"{timeout.TotalSeconds:0.###}s"; +} + +public sealed class DbValidationRuleRegistry +{ + private readonly Dictionary _rules; + private readonly DbValidationRuleDefinition[] _ruleList; + private readonly DbHostCallbackDescriptor[] _callbackList; + + public static DbValidationRuleRegistry Empty { get; } = new(); + + private DbValidationRuleRegistry() + { + _rules = new Dictionary(StringComparer.OrdinalIgnoreCase); + _ruleList = []; + _callbackList = []; + } + + internal DbValidationRuleRegistry(Dictionary rules) + { + _rules = rules; + _ruleList = rules.Values + .OrderBy(static definition => definition.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + _callbackList = _ruleList + .Select(static definition => definition.Descriptor) + .ToArray(); + } + + public IReadOnlyCollection Rules => _ruleList; + + public IReadOnlyCollection Callbacks => _callbackList; + + public static DbValidationRuleRegistry Create(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var builder = new DbValidationRuleRegistryBuilder(); + configure(builder); + return builder.Build(); + } + + public bool TryGetRule(string name, out DbValidationRuleDefinition definition) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + if (_rules.TryGetValue(name, out definition!)) + return true; + + definition = null!; + return false; + } + + public bool ContainsRuleName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return _rules.ContainsKey(name); + } +} + +public sealed class DbValidationRuleRegistryBuilder +{ + private readonly Dictionary _rules = + new(StringComparer.OrdinalIgnoreCase); + + public DbValidationRuleRegistryBuilder AddRule( + string name, + DbValidationRuleOptions? options, + DbValidationRuleDelegate invoke) + { + string normalizedName = ValidateRuleName(name); + ValidateRuleOptions(options); + ArgumentNullException.ThrowIfNull(invoke); + + if (_rules.ContainsKey(normalizedName)) + throw new ArgumentException($"Validation rule '{name}' is already registered.", nameof(name)); + + _rules.Add( + normalizedName, + new DbValidationRuleDefinition( + normalizedName, + options ?? new DbValidationRuleOptions(), + invoke)); + return this; + } + + public DbValidationRuleRegistryBuilder AddRule( + string name, + DbValidationRuleDelegate invoke) + => AddRule(name, options: null, invoke); + + public DbValidationRuleRegistryBuilder AddRule( + string name, + DbValidationRuleOptions? options, + Func invoke) + { + ArgumentNullException.ThrowIfNull(invoke); + return AddRule( + name, + options, + (context, _) => ValueTask.FromResult(invoke(context))); + } + + public DbValidationRuleRegistryBuilder AddRule( + string name, + Func invoke) + => AddRule(name, options: null, invoke); + + public DbValidationRuleRegistry Build() + { + if (_rules.Count == 0) + return DbValidationRuleRegistry.Empty; + + return new DbValidationRuleRegistry(new Dictionary(_rules, StringComparer.OrdinalIgnoreCase)); + } + + private static string ValidateRuleName(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + string trimmed = name.Trim(); + if (!IsIdentifierStart(trimmed[0])) + throw new ArgumentException($"Validation rule name '{name}' is not a valid identifier.", nameof(name)); + + for (int i = 1; i < trimmed.Length; i++) + { + char ch = trimmed[i]; + if (!char.IsLetterOrDigit(ch) && ch != '_') + throw new ArgumentException($"Validation rule name '{name}' is not a valid identifier.", nameof(name)); + } + + return trimmed; + } + + private static void ValidateRuleOptions(DbValidationRuleOptions? options) + { + if (options?.Timeout is { } timeout && timeout <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(options), timeout, "Validation rule timeout must be greater than zero."); + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; +} diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs index ed39a108..7e5ba2a3 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs @@ -1,10 +1,16 @@ +using System.Text.Json; using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Reports.Contracts; using CSharpDB.Admin.Reports.Models; using CSharpDB.Admin.Services; +using CSharpDB.Client; using CSharpDB.Primitives; using Microsoft.Extensions.DependencyInjection; +using FormPropertyBag = CSharpDB.Admin.Forms.Models.PropertyBag; +using FormRect = CSharpDB.Admin.Forms.Models.Rect; +using ReportPropertyBag = CSharpDB.Admin.Reports.Models.PropertyBag; +using ReportRect = CSharpDB.Admin.Reports.Models.Rect; namespace CSharpDB.Admin.Forms.Tests.Admin; @@ -108,6 +114,32 @@ public void GetCallbacks_WhenRegistriesAreMissing_ReturnsEmptyList() Assert.Empty(catalog.GetCallbacks()); } + [Fact] + public void GetCallbacks_IncludesRegisteredValidationRules() + { + DbValidationRuleRegistry validationRules = DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "CreditLimit", + new DbValidationRuleOptions(Description: "Checks customer credit limit."), + static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success()))); + + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(validationRules) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + DbHostCallbackDescriptor callback = Assert.Single(catalog.GetCallbacks()); + + Assert.Equal(AutomationCallbackKind.ValidationRule, callback.Kind); + Assert.Equal("CreditLimit", callback.Name); + Assert.Contains(callback.Capabilities, capability => + capability.Name == DbExtensionCapability.ValidationRules + && capability.Exports is not null + && capability.Exports.Contains("CreditLimit")); + } + [Fact] public async Task GetEntriesAsync_ReturnsRegisteredAndReferencedCallbacks() { @@ -115,10 +147,13 @@ public async Task GetEntriesAsync_ReturnsRegisteredAndReferencedCallbacks() builder.AddScalar("Slugify", 1, (_, _) => DbValue.Null)); DbCommandRegistry commands = DbCommandRegistry.Create(builder => builder.AddCommand("EchoAutomationEvent", _ => DbCommandResult.Success())); + DbValidationRuleRegistry validationRules = DbValidationRuleRegistry.Create(builder => + builder.AddRule("CreditLimit", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success()))); using ServiceProvider provider = new ServiceCollection() .AddSingleton(functions) .AddSingleton(commands) + .AddSingleton(validationRules) .AddSingleton(new StubFormRepository( [ CreateForm("orders-form", "Orders") with @@ -132,6 +167,11 @@ public async Task GetEntriesAsync_ReturnsRegisteredAndReferencedCallbacks() ScalarFunctions: [ new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + ], + ValidationRules: + [ + new DbAutomationValidationRuleReference("CreditLimit", "admin.forms", "controls.credit.validationRules.CreditLimit"), + new DbAutomationValidationRuleReference("MissingValidationRule", "admin.forms", "form.validationRules.MissingValidationRule"), ]), }, ])) @@ -177,6 +217,17 @@ public async Task GetEntriesAsync_ReturnsRegisteredAndReferencedCallbacks() Assert.True(registeredCommand.IsRegistered); Assert.False(registeredCommand.IsMissingRegistration); Assert.Single(registeredCommand.References); + + HostCallbackCatalogEntry registeredValidationRule = Assert.Single(entries, entry => entry.Name == "CreditLimit"); + Assert.Equal(AutomationCallbackKind.ValidationRule, registeredValidationRule.Kind); + Assert.True(registeredValidationRule.IsRegistered); + Assert.False(registeredValidationRule.IsMissingRegistration); + Assert.Equal("controls.credit.validationRules.CreditLimit", Assert.Single(registeredValidationRule.References).Location); + + HostCallbackCatalogEntry missingValidationRule = Assert.Single(entries, entry => entry.Name == "MissingValidationRule"); + Assert.Equal(AutomationCallbackKind.ValidationRule, missingValidationRule.Kind); + Assert.True(missingValidationRule.IsMissingRegistration); + Assert.Equal("form.validationRules.MissingValidationRule", Assert.Single(missingValidationRule.References).Location); } [Fact] @@ -209,6 +260,145 @@ public async Task GetEntriesAsync_DeduplicatesRepeatedReferences() Assert.Single(entry.References); } + [Fact] + public async Task GetEntriesAsync_RebuildsFormAndReportReferencesWhenAutomationMetadataIsMissing() + { + FormDefinition form = CreateForm("orders-form", "Orders") with + { + Controls = + [ + new ControlDefinition( + "slug", + "computed", + new FormRect(0, 0, 120, 24), + null, + new FormPropertyBag(new Dictionary + { + ["formula"] = "Slugify(Name)", + }), + null), + new ControlDefinition( + "send", + "commandButton", + new FormRect(0, 32, 120, 24), + null, + new FormPropertyBag(new Dictionary + { + ["commandName"] = "SendReceipt", + }), + null), + ], + }; + + ReportDefinition report = CreateReport("orders-report", "Orders") with + { + Bands = + [ + new ReportBandDefinition( + "detail", + ReportBandKind.Detail, + 28, + GroupId: null, + Controls: + [ + new ReportControlDefinition( + "risk", + ReportControlType.CalculatedText, + "detail", + new ReportRect(0, 0, 120, 20), + null, + "RiskScore(Total, Region)", + null, + ReportPropertyBag.Empty), + ]), + ], + }; + + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(DbFunctionRegistry.Empty) + .AddSingleton(DbCommandRegistry.Empty) + .AddSingleton(new StubFormRepository([form])) + .AddSingleton(new StubReportRepository([report])) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + IReadOnlyList entries = await catalog.GetEntriesAsync(); + + HostCallbackCatalogEntry slugify = Assert.Single(entries, entry => entry.Name == "Slugify"); + Assert.Equal(AutomationCallbackKind.ScalarFunction, slugify.Kind); + Assert.Equal("Form", Assert.Single(slugify.References).OwnerKind); + + HostCallbackCatalogEntry sendReceipt = Assert.Single(entries, entry => entry.Name == "SendReceipt"); + Assert.Equal(AutomationCallbackKind.Command, sendReceipt.Kind); + Assert.Equal("controls.send.commandButton.click", Assert.Single(sendReceipt.References).Location); + + HostCallbackCatalogEntry riskScore = Assert.Single(entries, entry => entry.Name == "RiskScore"); + Assert.Equal(2, riskScore.Arity); + Assert.Equal("Report", Assert.Single(riskScore.References).OwnerKind); + } + + [Fact] + public async Task GetEntriesAsync_DiscoversSavedQueryProcedureAndTriggerScalarReferences() + { + using ServiceProvider provider = new ServiceCollection() + .AddSingleton(DbFunctionRegistry.Empty) + .AddSingleton(DbCommandRegistry.Empty) + .AddSingleton(new StubDbClient( + savedQueries: + [ + new CSharpDB.Client.Models.SavedQueryDefinition + { + Id = 12, + Name = "Customer Score Query", + SqlText = "SELECT NormalizeName(Name), COUNT(*) FROM Customers GROUP BY NormalizeName(Name);", + }, + ], + procedures: + [ + new CSharpDB.Client.Models.ProcedureDefinition + { + Name = "RefreshCustomerRisk", + BodySql = "UPDATE Customers SET Risk = RiskScore(Total, Region);", + }, + ], + triggers: + [ + new CSharpDB.Client.Models.TriggerSchema + { + TriggerName = "Customers_Audit", + TableName = "Customers", + Timing = CSharpDB.Client.Models.TriggerTiming.After, + Event = CSharpDB.Client.Models.TriggerEvent.Update, + BodySql = "INSERT INTO Audit(Value) VALUES(AuditScore(new.Risk));", + }, + ])) + .AddScoped() + .BuildServiceProvider(); + + HostCallbackCatalogService catalog = provider.GetRequiredService(); + + IReadOnlyList entries = await catalog.GetEntriesAsync(); + + HostCallbackCatalogEntry normalizeName = Assert.Single(entries, entry => entry.Name == "NormalizeName"); + Assert.Equal(1, normalizeName.Arity); + HostCallbackReference savedQueryReference = Assert.Single(normalizeName.References); + Assert.Equal("SavedQuery", savedQueryReference.OwnerKind); + Assert.Equal("12", savedQueryReference.OwnerId); + Assert.Equal("savedQueries", savedQueryReference.Surface); + + HostCallbackCatalogEntry riskScore = Assert.Single(entries, entry => entry.Name == "RiskScore"); + Assert.Equal(2, riskScore.Arity); + Assert.Equal("Procedure", Assert.Single(riskScore.References).OwnerKind); + + HostCallbackCatalogEntry auditScore = Assert.Single(entries, entry => entry.Name == "AuditScore"); + Assert.Equal(1, auditScore.Arity); + Assert.Equal("Trigger", Assert.Single(auditScore.References).OwnerKind); + + Assert.DoesNotContain(entries, entry => entry.Name == "COUNT"); + } + private static FormDefinition CreateForm(string formId, string tableName) => new( formId, @@ -262,4 +452,94 @@ public StubReportRepository(IReadOnlyList reports) public Task> ListAsync(ReportSourceKind? sourceKind = null, string? sourceName = null) => Task.FromResult(_reports); public Task DeleteAsync(string reportId) => throw new NotSupportedException(); } + + private sealed class StubDbClient : ICSharpDbClient + { + private readonly IReadOnlyList _savedQueries; + private readonly IReadOnlyList _procedures; + private readonly IReadOnlyList _triggers; + private readonly IReadOnlyList _tableNames; + + public StubDbClient( + IReadOnlyList? savedQueries = null, + IReadOnlyList? procedures = null, + IReadOnlyList? triggers = null, + IReadOnlyList? tableNames = null) + { + _savedQueries = savedQueries ?? []; + _procedures = procedures ?? []; + _triggers = triggers ?? []; + _tableNames = tableNames ?? []; + } + + public string DataSource => "stub"; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public Task GetInfoAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetTableNamesAsync(CancellationToken ct = default) => Task.FromResult(_tableNames); + public Task GetTableSchemaAsync(string tableName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetRowCountAsync(string tableName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task BrowseTableAsync(string tableName, int page = 1, int pageSize = 50, CancellationToken ct = default) => throw new NotSupportedException(); + public Task?> GetRowByPkAsync(string tableName, string pkColumn, object pkValue, CancellationToken ct = default) => throw new NotSupportedException(); + public Task InsertRowAsync(string tableName, Dictionary values, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateRowAsync(string tableName, string pkColumn, object pkValue, Dictionary values, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteRowAsync(string tableName, string pkColumn, object pkValue, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DropTableAsync(string tableName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task RenameTableAsync(string tableName, string newTableName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task AddColumnAsync(string tableName, string columnName, CSharpDB.Client.Models.DbType type, bool notNull, CancellationToken ct = default) => throw new NotSupportedException(); + public Task AddColumnAsync(string tableName, string columnName, CSharpDB.Client.Models.DbType type, bool notNull, string? collation, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DropColumnAsync(string tableName, string columnName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task RenameColumnAsync(string tableName, string oldColumnName, string newColumnName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetIndexesAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task CreateIndexAsync(string indexName, string tableName, string columnName, bool isUnique, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CreateIndexAsync(string indexName, string tableName, string columnName, bool isUnique, string? collation, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateIndexAsync(string existingIndexName, string newIndexName, string tableName, string columnName, bool isUnique, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateIndexAsync(string existingIndexName, string newIndexName, string tableName, string columnName, bool isUnique, string? collation, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DropIndexAsync(string indexName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetViewNamesAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetViewsAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetViewAsync(string viewName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetViewSqlAsync(string viewName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task BrowseViewAsync(string viewName, int page = 1, int pageSize = 50, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CreateViewAsync(string viewName, string selectSql, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateViewAsync(string existingViewName, string newViewName, string selectSql, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DropViewAsync(string viewName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetTriggersAsync(CancellationToken ct = default) => Task.FromResult(_triggers); + public Task CreateTriggerAsync(string triggerName, string tableName, CSharpDB.Client.Models.TriggerTiming timing, CSharpDB.Client.Models.TriggerEvent triggerEvent, string bodySql, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateTriggerAsync(string existingTriggerName, string newTriggerName, string tableName, CSharpDB.Client.Models.TriggerTiming timing, CSharpDB.Client.Models.TriggerEvent triggerEvent, string bodySql, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DropTriggerAsync(string triggerName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetSavedQueriesAsync(CancellationToken ct = default) => Task.FromResult(_savedQueries); + public Task GetSavedQueryAsync(string name, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpsertSavedQueryAsync(string name, string sqlText, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteSavedQueryAsync(string name, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetProceduresAsync(bool includeDisabled = true, CancellationToken ct = default) => Task.FromResult(_procedures); + public Task GetProcedureAsync(string name, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CreateProcedureAsync(CSharpDB.Client.Models.ProcedureDefinition definition, CancellationToken ct = default) => throw new NotSupportedException(); + public Task UpdateProcedureAsync(string existingName, CSharpDB.Client.Models.ProcedureDefinition definition, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteProcedureAsync(string name, CancellationToken ct = default) => throw new NotSupportedException(); + public Task ExecuteProcedureAsync(string name, IReadOnlyDictionary args, CancellationToken ct = default) => throw new NotSupportedException(); + public Task ExecuteSqlAsync(string sql, CancellationToken ct = default) => throw new NotSupportedException(); + public Task BeginTransactionAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task ExecuteInTransactionAsync(string transactionId, string sql, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CommitTransactionAsync(string transactionId, CancellationToken ct = default) => throw new NotSupportedException(); + public Task RollbackTransactionAsync(string transactionId, CancellationToken ct = default) => throw new NotSupportedException(); + public Task> GetCollectionNamesAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetCollectionCountAsync(string collectionName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task BrowseCollectionAsync(string collectionName, int page = 1, int pageSize = 50, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetDocumentAsync(string collectionName, string key, CancellationToken ct = default) => throw new NotSupportedException(); + public Task PutDocumentAsync(string collectionName, string key, JsonElement document, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DeleteDocumentAsync(string collectionName, string key, CancellationToken ct = default) => throw new NotSupportedException(); + public Task DropCollectionAsync(string collectionName, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CheckpointAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task BackupAsync(CSharpDB.Client.Models.BackupRequest request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task RestoreAsync(CSharpDB.Client.Models.RestoreRequest request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task MigrateForeignKeysAsync(CSharpDB.Client.Models.ForeignKeyMigrationRequest request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetMaintenanceReportAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task ReindexAsync(CSharpDB.Client.Models.ReindexRequest request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task VacuumAsync(CancellationToken ct = default) => throw new NotSupportedException(); + public Task InspectStorageAsync(string? databasePath = null, bool includePages = false, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CheckWalAsync(string? databasePath = null, CancellationToken ct = default) => throw new NotSupportedException(); + public Task InspectPageAsync(uint pageId, bool includeHex = false, string? databasePath = null, CancellationToken ct = default) => throw new NotSupportedException(); + public Task CheckIndexesAsync(string? databasePath = null, string? indexName = null, int? sampleSize = null, CancellationToken ct = default) => throw new NotSupportedException(); + } } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs index 052fbd3d..37ed1b15 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs @@ -45,7 +45,7 @@ public void Evaluate_DefaultPolicyDeniesUnapprovedAdditionalCapabilities() DbExtensionPolicyDecision decision = service.Evaluate(callback); Assert.False(decision.Allowed); - Assert.Equal("Capability 'Network' is not granted.", decision.DenialReason); + Assert.Equal("No grant exists for capability 'Network'.", decision.DenialReason); Assert.Contains(decision.Capabilities, capability => capability.Name == DbExtensionCapability.Commands && capability.Status == DbExtensionCapabilityGrantStatus.Granted); diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs index a84a7640..6aba0c11 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackReadinessServiceTests.cs @@ -111,6 +111,10 @@ public async Task GenerateMissingStubSourceAsync_ProducesCSharpForMissingReferen [ new DbAutomationScalarFunctionReference("RegisteredFunction", 1, "admin.forms", "controls.slug.formula"), new DbAutomationScalarFunctionReference("MissingFunction", 2, "admin.forms", "controls.total.formula"), + ], + ValidationRules: + [ + new DbAutomationValidationRuleReference("MissingValidationRule", "admin.forms", "form.validationRules.MissingValidationRule"), ]), }, ], @@ -125,6 +129,8 @@ public async Task GenerateMissingStubSourceAsync_ProducesCSharpForMissingReferen Assert.Contains("\"AuditOrder\"", source); Assert.Contains("functions.AddScalar", source); Assert.Contains("\"MissingFunction\"", source); + Assert.Contains("validationRules.AddRule", source); + Assert.Contains("\"MissingValidationRule\"", source); Assert.Contains("Form Orders Form (orders-form): form.events.OnLoad", source); Assert.DoesNotContain("\"RegisteredFunction\"", source); } diff --git a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs index 3dbc7858..0cec50e5 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Pages/DataEntryTests.cs @@ -789,6 +789,8 @@ private sealed class PassThroughValidationService : IValidationInferenceService { public IReadOnlyList InferRules(FormFieldDefinition field) => []; public IReadOnlyList Evaluate(FormDefinition form, IDictionary record) => []; + public Task> EvaluateAsync(FormDefinition form, IDictionary record, CancellationToken ct = default) + => Task.FromResult>([]); } private sealed class StubJsRuntime : IJSRuntime diff --git a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs index 14c5f81b..33df8b45 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Serialization/JsonRoundtripTests.cs @@ -317,6 +317,22 @@ public void FormAutomationMetadata_NormalizeForExport_RoundTrips() ], Name: "ScoreActions")), ]), + new ControlDefinition( + "credit", + "number", + new Rect(0, 80, 120, 32), + new BindingDefinition("CreditLimit", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "ValidateCreditLimit", + "Credit limit is invalid.", + new Dictionary()), + ], + DisableRuleIds: [])), ], EventBindings: [ @@ -329,6 +345,13 @@ public void FormAutomationMetadata_NormalizeForExport_RoundTrips() new DbActionStep(DbActionKind.RunCommand, CommandName: "ReusableOrderAudit"), ], Name: "ReusableOrderActions"), + ], + ValidationRules: + [ + new ValidationRule( + "ValidateOrderTotals", + "Order totals are invalid.", + new Dictionary()), ]); FormDefinition normalized = FormAutomationMetadata.NormalizeForExport(form); @@ -345,6 +368,8 @@ public void FormAutomationMetadata_NormalizeForExport_RoundTrips() DbAutomationScalarFunctionReference function = Assert.Single(deserialized.Automation.ScalarFunctions!); Assert.Equal("BoostScore", function.Name); Assert.Equal(1, function.Arity); + Assert.Contains(deserialized.Automation.ValidationRules!, rule => rule.Name == "ValidateCreditLimit"); + Assert.Contains(deserialized.Automation.ValidationRules!, rule => rule.Name == "ValidateOrderTotals"); Assert.Contains("\"automation\"", json); } @@ -579,6 +604,53 @@ public void ValidationRule_RoundTrips() Assert.Equal(100L, deserialized.Parameters["max"]); } + [Fact] + public void FormAndControlValidationRules_RoundTripWithoutMigration() + { + var form = new FormDefinition( + "f-validation", + "Validation Form", + "Orders", + 1, + "orders:v1", + new LayoutDefinition("absolute", 8, true, []), + [ + new ControlDefinition( + "credit", + "number", + new Rect(0, 0, 160, 32), + new BindingDefinition("CreditLimit", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "ValidateCreditLimit", + "Credit limit is invalid.", + new Dictionary { ["minimum"] = 0L }), + ], + DisableRuleIds: [])), + ], + ValidationRules: + [ + new ValidationRule( + "ValidateOrderTotals", + "Order totals are invalid.", + new Dictionary { ["allowZero"] = false }), + ]); + + string json = JsonSerializer.Serialize(form, Options); + FormDefinition deserialized = JsonSerializer.Deserialize(json, Options)!; + + ValidationRule formRule = Assert.Single(deserialized.ValidationRules!); + Assert.Equal("ValidateOrderTotals", formRule.RuleId); + Assert.Equal(false, formRule.Parameters["allowZero"]); + ValidationRule controlRule = Assert.Single(deserialized.Controls[0].ValidationOverride!.AddRules); + Assert.Equal("ValidateCreditLimit", controlRule.RuleId); + Assert.Equal(0L, controlRule.Parameters["minimum"]); + } + [Fact] public void ControlDefinition_ComputedType_RoundTrips() { diff --git a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultValidationInferenceServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultValidationInferenceServiceTests.cs index 8c61aa00..7f595a01 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultValidationInferenceServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Services/DefaultValidationInferenceServiceTests.cs @@ -1,6 +1,7 @@ using CSharpDB.Admin.Forms.Contracts; using CSharpDB.Admin.Forms.Models; using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Tests.Services; @@ -151,6 +152,217 @@ public void Evaluate_WithDisabledRequiredRule_SuppressesValidationError() Assert.Empty(errors); } + [Fact] + public async Task EvaluateAsync_FieldCallbackFailure_ReturnsFieldValidationError() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "BlockName", + static (context, _) => + { + Assert.Equal("form-1", context.FormId); + Assert.Equal("Test Form", context.FormName); + Assert.Equal("Customers", context.TableName); + Assert.Equal("c1", context.ControlId); + Assert.Equal("Name", context.FieldName); + Assert.Equal("blocked", context.Parameters["mode"].AsText); + Assert.True(context.Record.ContainsKey("Name")); + + return ValueTask.FromResult( + context.Value.Type == DbType.Text && context.Value.AsText == "Blocked" + ? DbValidationRuleResult.Failure("Name is blocked.", context.FieldName, context.RuleName) + : DbValidationRuleResult.Success()); + })); + var service = new DefaultValidationInferenceService(registry, DbExtensionPolicies.DefaultHostCallbackPolicy); + FormDefinition form = CreateForm(new ControlDefinition( + "c1", + "text", + new Rect(0, 0, 100, 30), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "BlockName", + "Name failed validation.", + new Dictionary { ["mode"] = "blocked" }), + ], + DisableRuleIds: []))); + + IReadOnlyList errors = await service.EvaluateAsync( + form, + new Dictionary { ["Name"] = "Blocked" }, + TestContext.Current.CancellationToken); + + ValidationError error = Assert.Single(errors); + Assert.Equal("Name", error.FieldName); + Assert.Equal("BlockName", error.RuleId); + Assert.Equal("Name is blocked.", error.Message); + } + + [Fact] + public async Task EvaluateAsync_FormCallbackCanReturnFieldAndGlobalFailures() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "DateRange", + static (context, _) => + { + Assert.Equal(DbValidationRuleScope.Form, context.Scope); + Assert.Null(context.ControlId); + Assert.Null(context.FieldName); + + string start = context.Record["StartDate"].AsText; + string end = context.Record["EndDate"].AsText; + return ValueTask.FromResult(string.CompareOrdinal(start, end) <= 0 + ? DbValidationRuleResult.Success() + : DbValidationRuleResult.Failure( + [ + new DbValidationFailure("EndDate", "End date must be after start date.", "DateRange"), + new DbValidationFailure(null, "Fix the date range before saving.", "DateRange"), + ])); + })); + var service = new DefaultValidationInferenceService(registry, DbExtensionPolicies.DefaultHostCallbackPolicy); + FormDefinition form = CreateForm( + new ControlDefinition("start", "text", new Rect(0, 0, 100, 30), new BindingDefinition("StartDate", "TwoWay"), PropertyBag.Empty, null), + new ControlDefinition("end", "text", new Rect(0, 40, 100, 30), new BindingDefinition("EndDate", "TwoWay"), PropertyBag.Empty, null)) with + { + ValidationRules = + [ + new ValidationRule("DateRange", "Date range is invalid.", new Dictionary()), + ], + }; + + IReadOnlyList errors = await service.EvaluateAsync( + form, + new Dictionary + { + ["StartDate"] = "2026-05-02", + ["EndDate"] = "2026-05-01", + }, + TestContext.Current.CancellationToken); + + Assert.Equal(2, errors.Count); + Assert.Contains(errors, error => error.FieldName == "EndDate" && error.Message == "End date must be after start date."); + Assert.Contains(errors, error => error.FieldName == string.Empty && error.Message == "Fix the date range before saving."); + } + + [Fact] + public async Task EvaluateAsync_MissingCallbackBlocksSave() + { + var service = new DefaultValidationInferenceService(DbValidationRuleRegistry.Empty, DbExtensionPolicies.DefaultHostCallbackPolicy); + FormDefinition form = CreateForm(new ControlDefinition( + "c1", + "text", + new Rect(0, 0, 100, 30), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride(false, [new ValidationRule("MissingRule", "Fallback", new Dictionary())], []))); + + IReadOnlyList errors = await service.EvaluateAsync( + form, + new Dictionary { ["Name"] = "Alice" }, + TestContext.Current.CancellationToken); + + ValidationError error = Assert.Single(errors); + Assert.Equal("Name", error.FieldName); + Assert.Equal("MissingRule", error.RuleId); + Assert.Contains("not registered", error.Message); + } + + [Fact] + public async Task EvaluateAsync_DeniedCallbackBlocksSave() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule("DenyMe", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success()))); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Denied, + Reason: "Validation rules are disabled."), + ]); + var service = new DefaultValidationInferenceService(registry, policy); + FormDefinition form = CreateForm(new ControlDefinition( + "c1", + "text", + new Rect(0, 0, 100, 30), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride(false, [new ValidationRule("DenyMe", "Fallback", new Dictionary())], []))); + + IReadOnlyList errors = await service.EvaluateAsync( + form, + new Dictionary { ["Name"] = "Alice" }, + TestContext.Current.CancellationToken); + + ValidationError error = Assert.Single(errors); + Assert.Equal("Name", error.FieldName); + Assert.Contains("denied by policy", error.Message); + Assert.Contains("Validation rules are disabled.", error.Message); + } + + [Fact] + public async Task EvaluateAsync_ThrownCallbackBlocksSave() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "Throws", + static (_, _) => throw new InvalidOperationException("callback broke"))); + var service = new DefaultValidationInferenceService(registry, DbExtensionPolicies.DefaultHostCallbackPolicy); + FormDefinition form = CreateForm(new ControlDefinition( + "c1", + "text", + new Rect(0, 0, 100, 30), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride(false, [new ValidationRule("Throws", "Fallback", new Dictionary())], []))); + + IReadOnlyList errors = await service.EvaluateAsync( + form, + new Dictionary { ["Name"] = "Alice" }, + TestContext.Current.CancellationToken); + + ValidationError error = Assert.Single(errors); + Assert.Equal("Name", error.FieldName); + Assert.Contains("callback broke", error.Message); + } + + [Fact] + public async Task EvaluateAsync_TimedOutCallbackBlocksSave() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "Slow", + new DbValidationRuleOptions(Timeout: TimeSpan.FromMilliseconds(10)), + static async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(5), ct); + return DbValidationRuleResult.Success(); + })); + var service = new DefaultValidationInferenceService(registry, DbExtensionPolicies.DefaultHostCallbackPolicy); + FormDefinition form = CreateForm(new ControlDefinition( + "c1", + "text", + new Rect(0, 0, 100, 30), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride(false, [new ValidationRule("Slow", "Fallback", new Dictionary())], []))); + + IReadOnlyList errors = await service.EvaluateAsync( + form, + new Dictionary { ["Name"] = "Alice" }, + TestContext.Current.CancellationToken); + + ValidationError error = Assert.Single(errors); + Assert.Equal("Name", error.FieldName); + Assert.Contains("timed out", error.Message); + } + private static FormDefinition CreateForm(params ControlDefinition[] controls) => new( "form-1", diff --git a/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs b/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs index 837bd924..c6cb186b 100644 --- a/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs +++ b/tests/CSharpDB.Tests/AutomationManifestValidatorTests.cs @@ -15,11 +15,16 @@ public void Validate_ReturnsSuccessWhenMetadataMatchesRegistries() ScalarFunctions: [ new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + ], + ValidationRules: + [ + new DbAutomationValidationRuleReference("CreditLimit", "admin.forms", "controls.credit.validationRules.CreditLimit"), ]); DbFunctionRegistry functions = CreateFunctions(("Slugify", 1)); DbCommandRegistry commands = CreateCommands("AuditOrder"); + DbValidationRuleRegistry validationRules = CreateValidationRules("CreditLimit"); - AutomationValidationResult result = AutomationManifestValidator.Validate(metadata, functions, commands); + AutomationValidationResult result = AutomationManifestValidator.Validate(metadata, functions, commands, validationRules); Assert.True(result.Succeeded); Assert.Empty(result.Issues); @@ -36,15 +41,20 @@ public void Validate_ReportsMissingCommandsAndScalarFunctions() ScalarFunctions: [ new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), + ], + ValidationRules: + [ + new DbAutomationValidationRuleReference("CreditLimit", "admin.forms", "form.validationRules.CreditLimit"), ]); AutomationValidationResult result = AutomationManifestValidator.Validate( metadata, DbFunctionRegistry.Empty, - DbCommandRegistry.Empty); + DbCommandRegistry.Empty, + DbValidationRuleRegistry.Empty); Assert.False(result.Succeeded); - Assert.Equal(2, result.Issues.Count); + Assert.Equal(3, result.Issues.Count); AutomationValidationIssue commandIssue = Assert.Single( result.Issues, @@ -63,6 +73,14 @@ public void Validate_ReportsMissingCommandsAndScalarFunctions() Assert.Equal(1, functionIssue.ExpectedArity); Assert.Equal("controls.slug.formula", functionIssue.Location); Assert.Contains("not registered", functionIssue.Message); + + AutomationValidationIssue validationIssue = Assert.Single( + result.Issues, + issue => issue.CallbackKind == AutomationCallbackKind.ValidationRule); + Assert.Equal(AutomationValidationSeverity.Error, validationIssue.Severity); + Assert.Equal("CreditLimit", validationIssue.Name); + Assert.Equal("form.validationRules.CreditLimit", validationIssue.Location); + Assert.Contains("not registered", validationIssue.Message); } [Fact] @@ -103,17 +121,24 @@ public void Validate_DuplicateReferencesProduceWarningsWithoutFailing() [ new DbAutomationScalarFunctionReference("Slugify", 1, "admin.forms", "controls.slug.formula"), new DbAutomationScalarFunctionReference("slugify", 1, "admin.forms", "controls.slug.formula"), + ], + ValidationRules: + [ + new DbAutomationValidationRuleReference("CreditLimit", "admin.forms", "form.validationRules.CreditLimit"), + new DbAutomationValidationRuleReference("creditlimit", "admin.forms", "form.validationRules.CreditLimit"), ]); DbFunctionRegistry functions = CreateFunctions(("Slugify", 1)); DbCommandRegistry commands = CreateCommands("AuditOrder"); + DbValidationRuleRegistry validationRules = CreateValidationRules("CreditLimit"); - AutomationValidationResult result = AutomationManifestValidator.Validate(metadata, functions, commands); + AutomationValidationResult result = AutomationManifestValidator.Validate(metadata, functions, commands, validationRules); Assert.True(result.Succeeded); - Assert.Equal(2, result.Issues.Count); + Assert.Equal(3, result.Issues.Count); Assert.All(result.Issues, issue => Assert.Equal(AutomationValidationSeverity.Warning, issue.Severity)); Assert.Contains(result.Issues, issue => issue.CallbackKind == AutomationCallbackKind.Command); Assert.Contains(result.Issues, issue => issue.CallbackKind == AutomationCallbackKind.ScalarFunction); + Assert.Contains(result.Issues, issue => issue.CallbackKind == AutomationCallbackKind.ValidationRule); } [Fact] @@ -152,4 +177,11 @@ private static DbCommandRegistry CreateCommands(params string[] commands) foreach (string command in commands) builder.AddCommand(command, static _ => DbCommandResult.Success()); }); + + private static DbValidationRuleRegistry CreateValidationRules(params string[] rules) + => DbValidationRuleRegistry.Create(builder => + { + foreach (string rule in rules) + builder.AddRule(rule, static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success())); + }); } diff --git a/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs b/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs index cfdc06c3..0dad6062 100644 --- a/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs +++ b/tests/CSharpDB.Tests/AutomationStubGeneratorTests.cs @@ -19,6 +19,10 @@ public void GenerateCSharp_ProducesDeterministicRegistrationStubs() [ new DbAutomationScalarFunctionReference("NormalizeName", 2, "pipelines", "transforms[0].filterExpression"), new DbAutomationScalarFunctionReference("normalizeName", 2, "admin.forms", "controls.name.formula"), + ], + ValidationRules: + [ + new DbAutomationValidationRuleReference("ValidateCreditLimit", "admin.forms", "controls.credit.validationRules.ValidateCreditLimit"), ]); string source = AutomationStubGenerator.GenerateCSharp( @@ -36,10 +40,11 @@ namespace MyApp.CSharpDbAutomation; public static class CSharpDbAutomationRegistration { - public static void Register(DbFunctionRegistryBuilder functions, DbCommandRegistryBuilder commands) + public static void Register(DbFunctionRegistryBuilder functions, DbCommandRegistryBuilder commands, DbValidationRuleRegistryBuilder validationRules) { ArgumentNullException.ThrowIfNull(functions); ArgumentNullException.ThrowIfNull(commands); + ArgumentNullException.ThrowIfNull(validationRules); functions.AddScalar( "NormalizeName", @@ -60,12 +65,22 @@ static async (context, ct) => // References: // - admin.forms: form.events.AfterUpdate // - admin.forms: form.events.BeforeInsert - await Task.CompletedTask; - throw new NotImplementedException("Implement trusted command 'AuditOrder'."); - }); - } + await Task.CompletedTask; + throw new NotImplementedException("Implement trusted command 'AuditOrder'."); + }); + + validationRules.AddRule( + "ValidateCreditLimit", + static async (context, ct) => + { + // References: + // - admin.forms: controls.credit.validationRules.ValidateCreditLimit + await Task.CompletedTask; + throw new NotImplementedException("Implement trusted validation rule 'ValidateCreditLimit'."); + }); } - """; + } + """; Assert.Equal(Normalize(expected), Normalize(source)); } @@ -135,5 +150,11 @@ private static MetadataReference[] GetMetadataReferences() } private static string Normalize(string source) - => source.ReplaceLineEndings("\n").TrimEnd('\n'); + => string.Join( + "\n", + source + .ReplaceLineEndings("\n") + .Trim() + .Split('\n') + .Select(static line => line.Trim())); } diff --git a/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs b/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs index 3bc63d8d..42019717 100644 --- a/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs +++ b/tests/CSharpDB.Tests/CallbackDiagnosticsTests.cs @@ -127,6 +127,167 @@ static async (_, ct) => Assert.Contains("timed out", diagnostic.ExceptionMessage); } + [Fact] + public async Task CommandPolicyDenied_EmitsDiagnosticAndDoesNotInvokeCallback() + { + bool invoked = false; + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbCommandRegistry registry = DbCommandRegistry.Create(commands => + commands.AddCommand( + "DiagNetwork", + new DbCommandOptions( + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Network), + ]), + _ => + { + invoked = true; + return DbCommandResult.Success(); + })); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + ]); + + Assert.True(registry.TryGetCommand("DiagNetwork", out DbCommandDefinition definition)); + DbCallbackPolicyException ex = await Assert.ThrowsAsync(async () => + await definition.InvokeAsync( + new Dictionary(), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "AdminForms", + ["location"] = "controls.notify.events.Click", + ["correlationId"] = "corr-1", + ["ownerKind"] = "Form", + ["ownerId"] = "orders-form", + ["ownerName"] = "Orders", + }, + policy, + DbExtensionHostMode.Embedded, + TestContext.Current.CancellationToken)); + + Assert.False(invoked); + Assert.Contains("No grant exists for capability 'Network'.", ex.Message); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "DiagNetwork"); + Assert.False(diagnostic.Succeeded); + Assert.Equal(false, diagnostic.PolicyAllowed); + Assert.Equal("No grant exists for capability 'Network'.", diagnostic.PolicyDenialReason); + Assert.Equal("PolicyDenied", diagnostic.ErrorCode); + Assert.Equal(typeof(DbCallbackPolicyException).FullName, diagnostic.ExceptionType); + Assert.Equal("corr-1", diagnostic.CorrelationId); + Assert.Equal("Form", diagnostic.OwnerKind); + Assert.Equal("orders-form", diagnostic.OwnerId); + Assert.Equal("controls.notify.events.Click", diagnostic.Location); + Assert.NotNull(diagnostic.StartedAtUtc); + } + + [Fact] + public async Task ValidationRuleInvocation_EmitsDiagnosticWithPolicyDecision() + { + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(rules => + rules.AddRule( + "CreditLimit", + static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Failure("Credit limit exceeded.", "CreditLimit")))); + + Assert.True(registry.TryGetRule("CreditLimit", out DbValidationRuleDefinition definition)); + DbValidationRuleResult result = await definition.InvokeAsync( + DbValidationRuleContext.Create( + "CreditLimit", + DbValidationRuleScope.Field, + metadata: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "admin.forms", + ["location"] = "controls.credit.validationRules.CreditLimit", + ["correlationId"] = "validation-corr", + ["ownerKind"] = "Form", + ["ownerId"] = "orders-form", + ["ownerName"] = "Orders", + }), + DbExtensionPolicies.DefaultHostCallbackPolicy, + DbExtensionHostMode.Embedded, + TestContext.Current.CancellationToken); + + Assert.False(result.Succeeded); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "CreditLimit"); + Assert.Equal(AutomationCallbackKind.ValidationRule, diagnostic.CallbackKind); + Assert.Null(diagnostic.Arity); + Assert.False(diagnostic.Succeeded); + Assert.Equal(true, diagnostic.PolicyAllowed); + Assert.Equal("admin.forms", diagnostic.Surface); + Assert.Equal("controls.credit.validationRules.CreditLimit", diagnostic.Location); + Assert.Equal("validation-corr", diagnostic.CorrelationId); + Assert.Equal("Form", diagnostic.OwnerKind); + Assert.Equal("orders-form", diagnostic.OwnerId); + Assert.NotNull(diagnostic.StartedAtUtc); + } + + [Fact] + public void ScalarPolicyDenied_EmitsDiagnosticAndDoesNotInvokeCallback() + { + bool invoked = false; + List diagnostics = []; + using IDisposable subscription = DbCallbackDiagnostics.Listener.Subscribe(new CallbackObserver(diagnostics)); + DbFunctionRegistry registry = DbFunctionRegistry.Create(functions => + functions.AddScalar( + "DiagSecret", + 0, + new DbScalarFunctionOptions( + AdditionalCapabilities: + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.EnvironmentVariables), + ]), + (_, _) => + { + invoked = true; + return DbValue.FromText("secret"); + })); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ScalarFunctions, + DbExtensionCapabilityGrantStatus.Granted), + ]); + + Assert.True(registry.TryGetScalar("DiagSecret", 0, out DbScalarFunctionDefinition definition)); + DbCallbackPolicyException ex = Assert.Throws(() => + definition.Invoke( + [], + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface"] = "Pipelines", + ["location"] = "transforms.functions.DiagSecret", + }, + policy, + DbExtensionHostMode.Embedded)); + + Assert.False(invoked); + Assert.Contains("No grant exists for capability 'EnvironmentVariables'.", ex.Message); + DbCallbackInvocationDiagnostic diagnostic = Assert.Single( + diagnostics, + diagnostic => diagnostic.Name == "DiagSecret"); + Assert.False(diagnostic.Succeeded); + Assert.Equal(false, diagnostic.PolicyAllowed); + Assert.Equal("No grant exists for capability 'EnvironmentVariables'.", diagnostic.PolicyDenialReason); + Assert.Equal("PolicyDenied", diagnostic.ErrorCode); + Assert.Equal(typeof(DbCallbackPolicyException).FullName, diagnostic.ExceptionType); + Assert.Equal("Pipelines", diagnostic.Surface); + Assert.Equal("transforms.functions.DiagSecret", diagnostic.Location); + } + private sealed class CallbackObserver(List diagnostics) : IObserver> { diff --git a/tests/CSharpDB.Tests/DbValidationRuleRegistryTests.cs b/tests/CSharpDB.Tests/DbValidationRuleRegistryTests.cs new file mode 100644 index 00000000..533c7f48 --- /dev/null +++ b/tests/CSharpDB.Tests/DbValidationRuleRegistryTests.cs @@ -0,0 +1,104 @@ +using CSharpDB.Primitives; + +namespace CSharpDB.Tests; + +public sealed class DbValidationRuleRegistryTests +{ + [Fact] + public void AddRule_RegistersDescriptorWithValidationCapability() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "CreditLimit", + new DbValidationRuleOptions( + Description: "Rejects orders over the customer credit limit.", + Timeout: TimeSpan.FromSeconds(2)), + static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success()))); + + Assert.True(registry.TryGetRule("creditlimit", out DbValidationRuleDefinition definition)); + Assert.Equal("CreditLimit", definition.Name); + Assert.Equal(AutomationCallbackKind.ValidationRule, definition.Descriptor.Kind); + Assert.Equal(TimeSpan.FromSeconds(2), definition.Descriptor.Timeout); + Assert.Contains(definition.Descriptor.Capabilities, capability => + capability.Name == DbExtensionCapability.ValidationRules + && capability.Exports is not null + && capability.Exports.Contains("CreditLimit")); + Assert.Same(definition.Descriptor, Assert.Single(registry.Callbacks)); + } + + [Fact] + public void AddRule_RejectsDuplicateNames() + { + ArgumentException ex = Assert.Throws(() => + DbValidationRuleRegistry.Create(builder => + { + builder.AddRule("CreditLimit", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success())); + builder.AddRule("creditlimit", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success())); + })); + + Assert.Contains("already registered", ex.Message); + } + + [Fact] + public void AddRule_RejectsInvalidNamesAndTimeouts() + { + Assert.Throws(() => + DbValidationRuleRegistry.Create(builder => + builder.AddRule("not valid", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success())))); + + Assert.Throws(() => + DbValidationRuleRegistry.Create(builder => + builder.AddRule( + "CreditLimit", + new DbValidationRuleOptions(Timeout: TimeSpan.Zero), + static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success())))); + } + + [Fact] + public void Policy_DefaultHostCallbackPolicyAllowsValidationRules() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule("CreditLimit", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success()))); + DbValidationRuleDefinition definition = Assert.Single(registry.Rules); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + definition.Descriptor, + DbExtensionPolicies.DefaultHostCallbackPolicy, + DbExtensionHostMode.Embedded); + + Assert.True(decision.Allowed, decision.DenialReason); + Assert.Contains(decision.Capabilities, capability => + capability.Name == DbExtensionCapability.ValidationRules + && capability.Status == DbExtensionCapabilityGrantStatus.Granted); + } + + [Fact] + public void Policy_ScopedExportDenyBlocksOnlyMatchingRule() + { + DbValidationRuleRegistry registry = DbValidationRuleRegistry.Create(builder => + builder.AddRule("CreditLimit", static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success()))); + DbValidationRuleDefinition definition = Assert.Single(registry.Rules); + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Granted, + Exports: ["*"]), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Denied, + Reason: "Credit limit validation is disabled.", + Exports: ["CreditLimit"]), + ]); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + definition.Descriptor, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Credit limit validation is disabled.", decision.DenialReason); + } +} diff --git a/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs b/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs index 34ca27d0..598f6f7a 100644 --- a/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs +++ b/tests/CSharpDB.Tests/ExtensionSandbox/DbExtensionPolicyTests.cs @@ -151,7 +151,7 @@ public void Evaluate_DeniesHostCallbackDescriptorWhenCapabilityIsNotGranted() DbExtensionHostMode.Embedded); Assert.False(decision.Allowed); - Assert.Equal("Capability 'Network' is not granted.", decision.DenialReason); + Assert.Equal("No grant exists for capability 'Network'.", decision.DenialReason); } [Fact] @@ -214,7 +214,158 @@ public void Evaluate_DeniesExtensionWhenRequestedCapabilityIsMissing() DbExtensionHostMode.Embedded); Assert.False(decision.Allowed); - Assert.Equal("Capability 'ReadDatabase' is not granted.", decision.DenialReason); + Assert.Equal("No grant exists for capability 'ReadDatabase'.", decision.DenialReason); + } + + [Fact] + public void Evaluate_AllowsExtensionWhenScopedGrantMatchesRequestedTable() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature") with + { + Capabilities = + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Commands), + new DbExtensionCapabilityRequest(DbExtensionCapability.ReadDatabase, Tables: ["Orders"]), + ], + }; + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted, + Tables: ["Orders"]), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.True(decision.Allowed, decision.DenialReason); + Assert.All(decision.Capabilities, capability => + Assert.Equal(DbExtensionCapabilityGrantStatus.Granted, capability.Status)); + } + + [Fact] + public void Evaluate_DeniesExtensionWhenScopedGrantDoesNotMatchRequestedTable() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature") with + { + Capabilities = + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Commands), + new DbExtensionCapabilityRequest(DbExtensionCapability.ReadDatabase, Tables: ["Orders"]), + ], + }; + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted, + Tables: ["Customers"]), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal( + "No grant for capability 'ReadDatabase' matches requested exports [*], tables [Orders], scope [*].", + decision.DenialReason); + } + + [Fact] + public void Evaluate_DenyGrantWinsOverMatchingAllowGrant() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature") with + { + Capabilities = + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Commands), + new DbExtensionCapabilityRequest(DbExtensionCapability.ReadDatabase, Tables: ["Orders"]), + ], + }; + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted, + Tables: ["Orders"]), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Denied, + Reason: "Orders reads are blocked for this host.", + Tables: ["Orders"]), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.False(decision.Allowed); + Assert.Equal("Orders reads are blocked for this host.", decision.DenialReason); + DbExtensionCapabilityDecision readDecision = Assert.Single( + decision.Capabilities, + capability => capability.Name == DbExtensionCapability.ReadDatabase); + Assert.Equal(DbExtensionCapabilityGrantStatus.Denied, readDecision.Status); + } + + [Fact] + public void Evaluate_ScopedDenyDoesNotBlockDifferentScope() + { + DbExtensionManifest manifest = CreateCommandManifest(signature: "trusted-signature") with + { + Capabilities = + [ + new DbExtensionCapabilityRequest(DbExtensionCapability.Commands), + new DbExtensionCapabilityRequest(DbExtensionCapability.ReadDatabase, Tables: ["Orders"]), + ], + }; + var policy = new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.Commands, + DbExtensionCapabilityGrantStatus.Granted), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Denied, + Tables: ["Payroll"]), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ReadDatabase, + DbExtensionCapabilityGrantStatus.Granted, + Tables: ["Orders"]), + ], + RequireSignature: true); + + DbExtensionPolicyDecision decision = DbExtensionPolicyEvaluator.Evaluate( + manifest, + policy, + DbExtensionHostMode.Embedded); + + Assert.True(decision.Allowed, decision.DenialReason); } [Fact] From 3f865955561c7742fda9c482a44a8762410194b7 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Fri, 1 May 2026 22:22:39 -0700 Subject: [PATCH 30/39] feat: Add trusted validation rules and enhance documentation for Admin Forms --- README.md | 4 +- docs/admin-forms-access-parity/README.md | 5 + docs/trusted-csharp-functions/README.md | 17 ++ .../validation-rules.md | 233 ++++++++++++++++++ samples/README.md | 1 + samples/trusted-csharp-host/Program.cs | 125 +++++++++- samples/trusted-csharp-host/README.md | 32 ++- .../Components/Tabs/CallbacksTab.razor | 11 +- .../Services/AdminHostCallbacks.cs | 5 + .../Services/HostCallbackCatalogService.cs | 20 ++ .../Admin/HostCallbackCatalogServiceTests.cs | 11 +- .../Admin/HostCallbackPolicyServiceTests.cs | 10 + 12 files changed, 462 insertions(+), 12 deletions(-) create mode 100644 docs/trusted-csharp-functions/validation-rules.md diff --git a/README.md b/README.md index 359d98ad..c3e157f3 100644 --- a/README.md +++ b/README.md @@ -210,8 +210,8 @@ The native library exports 20 C functions. See the [Native Library Reference](ht | [Architecture Guide](https://csharpdb.com/architecture.html) | Engine design deep dive | | [Tools & Ecosystem](https://csharpdb.com/docs/ecosystem.html) | APIs, hosts, designers, and integrations | | [EF Core Provider](https://csharpdb.com/docs/entity-framework-core.html) | Embedded EF Core 10 provider guide | -| [Trusted C# Scalar Functions](docs/trusted-csharp-functions/README.md) | Register in-process C# functions for SQL, forms, reports, and pipelines | -| [Trusted C# Host Sample](samples/trusted-csharp-host/README.md) | VS Code-ready C# host project for trusted functions, commands, and form actions | +| [Trusted C# Callbacks](docs/trusted-csharp-functions/README.md) | Register in-process C# functions, commands, and validation rules for SQL, forms, reports, and pipelines | +| [Trusted C# Host Sample](samples/trusted-csharp-host/README.md) | VS Code-ready C# host project for trusted functions, commands, validation rules, and form actions | | [Admin UI Guide](https://csharpdb.com/docs/admin-ui.html) | Querying, schema, pipelines, forms, reports, and storage | | [CSharpDB.Client](src/CSharpDB.Client/README.md) | Unified client API and transports | | [Pipelines](https://csharpdb.com/docs/pipelines.html) | ETL package model and visual designer | diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index d3434b2c..2ae943cf 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -140,3 +140,8 @@ Those foundations should be added before expanding the control palette too far. Custom form controls can now be registered without changing saved form JSON. See [Form Control Extensibility](form-control-extensibility.md) for the registry API, component contexts, generic property schema, and the sample rating control. + +Host-owned validation callbacks can be registered for field-level and form-level +save checks. See +[Trusted Validation Rules](../trusted-csharp-functions/validation-rules.md) for +registration, designer metadata, policy, diagnostics, and sample code. diff --git a/docs/trusted-csharp-functions/README.md b/docs/trusted-csharp-functions/README.md index d58ca4de..d7df8bb6 100644 --- a/docs/trusted-csharp-functions/README.md +++ b/docs/trusted-csharp-functions/README.md @@ -8,6 +8,9 @@ For an end-to-end app-builder walkthrough that combines Admin Forms, collections macro actions, reports, trusted callbacks, and callback readiness, see the [Fulfillment Ops Admin Automation tutorial](../tutorials/fulfillment-ops-admin-automation.md). +For Admin Forms save-time business validation, see +[Trusted Validation Rules](validation-rules.md). + --- ## Trusted Commands @@ -62,6 +65,20 @@ visibility. It does not sandbox the command. --- +## Trusted Validation Rules + +Admin Forms can also call host-registered validation rules before save. Rules +are registered in the host app with `AddCSharpDbAdminFormValidationRules(...)` +and referenced from form or control metadata by name. Missing, denied, timed +out, throwing, or failed validation rules block save and appear in the callback +diagnostics path. + +See [Trusted Validation Rules](validation-rules.md) for registration examples, +field-level and form-level metadata, policy grants, diagnostics behavior, and +the runnable sample. + +--- + ## What You Can Register V1 supports synchronous scalar functions: diff --git a/docs/trusted-csharp-functions/validation-rules.md b/docs/trusted-csharp-functions/validation-rules.md new file mode 100644 index 00000000..ce461b52 --- /dev/null +++ b/docs/trusted-csharp-functions/validation-rules.md @@ -0,0 +1,233 @@ +# Trusted Validation Rules + +Admin Forms can run host-registered validation callbacks before a record is +saved. The form stores only the validation rule name, fallback message, and JSON +parameters. The C# callback body is compiled into the host application and +registered during startup. + +Validation rules are trusted in-process callbacks. They are intended for +business checks that do not belong in generic field metadata, such as +cross-field validation, tenant-specific policies, or checks against host-owned +services. + +## Register Rules + +Register rules with `AddCSharpDbAdminFormValidationRules(...)`: + +```csharp +using CSharpDB.Admin.Forms.Services; +using CSharpDB.Primitives; + +builder.Services.AddCSharpDbAdminFormValidationRules(rules => +{ + rules.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "Rejects placeholder customer names.", + Timeout: TimeSpan.FromSeconds(2)), + static (context, ct) => + { + string text = context.Value.IsNull ? string.Empty : context.Value.AsText; + string blockedText = context.Parameters.TryGetValue("blockedText", out DbValue configured) + && !configured.IsNull + ? configured.AsText + : "test"; + + DbValidationRuleResult result = text.Contains(blockedText, StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Failure( + context.FallbackMessage ?? "Customer name is not allowed.", + context.FieldName, + context.RuleName) + : DbValidationRuleResult.Success(); + return ValueTask.FromResult(result); + }); +}); +``` + +Rule names are case-insensitive identifiers. Duplicate names fail during +registration so the host fails fast at startup. + +## Field-Level Rules + +Field-level rules are attached to a bound control through +`ValidationOverride.AddRules`. The runtime context includes the current field +value, field name, control id, full record, parameters, and metadata. + +```csharp +new ControlDefinition( + "customer-name", + "text", + new Rect(24, 72, 240, 32), + new BindingDefinition("Name", "TwoWay"), + PropertyBag.Empty, + new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "CustomerNamePolicy", + "Use the real customer name, not a placeholder.", + new Dictionary + { + ["blockedText"] = "test", + }), + ], + DisableRuleIds: [])); +``` + +The fallback message is used when the callback returns a failure without a more +specific message. Parameters are converted to `DbValue` and are available through +`context.Parameters`. + +## Form-Level Rules + +Form-level rules live on `FormDefinition.ValidationRules`. Use them for +cross-field checks and global save policies. A form-level callback can return +field-specific failures or global failures. A failure with `FieldName = null` or +an empty field name is shown as a form-level error. + +```csharp +builder.Services.AddCSharpDbAdminFormValidationRules(rules => +{ + rules.AddRule( + "CustomerReadyForInsert", + static context => + { + string status = context.Record.TryGetValue("Status", out DbValue value) + && !value.IsNull + ? value.AsText + : string.Empty; + + return string.Equals(status, "Ready", StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Success() + : DbValidationRuleResult.Failure( + [ + new DbValidationFailure( + "Status", + "Customer status must be Ready before save.", + context.RuleName), + ], + "Customer record is not ready."); + }); +}); +``` + +Attach the rule to the form: + +```csharp +var form = existingForm with +{ + ValidationRules = + [ + new ValidationRule( + "CustomerReadyForInsert", + "Customer status must be Ready before save.", + new Dictionary()), + ], +}; +``` + +## Runtime Context + +Every rule receives `DbValidationRuleContext`: + +| Property | Meaning | +| --- | --- | +| `RuleName` | The registered callback name. | +| `Scope` | `Field` or `Form`. | +| `Record` | Full current record as `IReadOnlyDictionary`. | +| `Parameters` | JSON parameters from form metadata as `DbValue`s. | +| `Metadata` | Surface, owner, location, correlation id, and form details. | +| `FormId`, `FormName`, `TableName` | Current form source metadata. | +| `ControlId`, `FieldName`, `Value` | Field-level context; null/default for form-level rules. | +| `FallbackMessage` | Designer-provided fallback message. | + +Validation callbacks are asynchronous: + +```csharp +public delegate ValueTask DbValidationRuleDelegate( + DbValidationRuleContext context, + CancellationToken ct); +``` + +Pass the cancellation token to host I/O. If a rule uses host services, capture +thread-safe services in the registration closure or register the rule from a +host-owned composition root. + +## Policy + +Validation rules request the `DbExtensionCapability.ValidationRules` +capability. `DbExtensionPolicies.DefaultHostCallbackPolicy` grants validation +rules by default. If the host uses a custom policy, it must grant that +capability: + +```csharp +builder.Services.AddSingleton(new DbExtensionPolicy( + AllowExtensions: true, + Grants: + [ + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Granted, + Exports: ["CustomerNamePolicy", "CustomerReadyForInsert"]), + ], + DefaultTimeout: TimeSpan.FromSeconds(5), + RequireSignature: true, + AllowedHostModes: DbExtensionHostMode.Embedded)); +``` + +Scoped grants can use `Exports`, `Tables`, and `Scope`. Deny grants take +precedence over allows when both match a callback request. + +## Failure Behavior + +Validation rules fail closed. Save is blocked when a rule is: + +- not registered +- denied by policy +- timed out +- canceled by the validation runtime +- throwing an exception +- returning a failed result + +The Admin callbacks tab shows registered validation rules, saved references, +policy decisions, and diagnostics history. Missing references mean saved form +metadata names a rule that the current host has not registered. + +## Generated Stubs + +Forms export automation metadata for validation rule references. The callback +catalog can generate starter registrations: + +```csharp +public static void Register( + DbFunctionRegistryBuilder functions, + DbCommandRegistryBuilder commands, + DbValidationRuleRegistryBuilder validationRules) +{ + validationRules.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "TODO: describe validation rule."), + static async (context, ct) => + { + await ValueTask.CompletedTask; + return DbValidationRuleResult.Success(); + }); +} +``` + +The generated code is a handoff artifact. Keep the rule implementation in the +host project, not in form JSON or database metadata. + +## Runnable Sample + +The trusted host sample registers scalar functions, commands, and validation +rules: + +```powershell +dotnet run --project samples\trusted-csharp-host\TrustedCSharpHostSample.csproj +``` + +The sample exports form automation metadata, validates that the referenced +callbacks are registered, prints generated stubs, and runs a validation demo. diff --git a/samples/README.md b/samples/README.md index 8d38f2d6..38cba5fe 100644 --- a/samples/README.md +++ b/samples/README.md @@ -19,6 +19,7 @@ The SQL dataset samples use a conventional layout with `schema.sql` for setup, ` | `collection-indexing/` | Runnable `Collection` indexing walkthrough | `.csproj`, `Program.cs`, `README.md` | | `generated-collections/` | Runnable source-generated collection fast-path walkthrough | `.csproj`, `Program.cs`, `README.md` | | `efcore-provider/` | Runnable EF Core 10 embedded-provider sample | `.csproj`, `Program.cs`, `README.md` | +| `trusted-csharp-host/` | Runnable trusted callback host sample | scalar functions, commands, validation rules, form automation metadata, `.csproj`, `Program.cs`, `README.md` | ## Tutorials diff --git a/samples/trusted-csharp-host/Program.cs b/samples/trusted-csharp-host/Program.cs index 57f309f9..a9876d6d 100644 --- a/samples/trusted-csharp-host/Program.cs +++ b/samples/trusted-csharp-host/Program.cs @@ -42,19 +42,71 @@ }); }); +DbValidationRuleRegistry validationRules = DbValidationRuleRegistry.Create(builder => +{ + builder.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "Rejects placeholder customer names.", + Timeout: TimeSpan.FromSeconds(2)), + static (context, _) => + { + string text = context.Value.IsNull ? string.Empty : context.Value.AsText; + string blockedText = context.Parameters.TryGetValue("blockedText", out DbValue configured) + && !configured.IsNull + ? configured.AsText + : "test"; + + DbValidationRuleResult result = text.Contains(blockedText, StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Failure( + context.FallbackMessage ?? "Customer name is not allowed.", + context.FieldName, + context.RuleName) + : DbValidationRuleResult.Success(); + return ValueTask.FromResult(result); + }); + + builder.AddRule( + "CustomerReadyForInsert", + new DbValidationRuleOptions( + Description: "Requires the customer workflow status before save."), + static context => + { + string requiredStatus = context.Parameters.TryGetValue("requiredStatus", out DbValue configured) + && !configured.IsNull + ? configured.AsText + : "Ready"; + string status = context.Record.TryGetValue("Status", out DbValue value) && !value.IsNull + ? value.AsText + : string.Empty; + + return string.Equals(status, requiredStatus, StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Success() + : DbValidationRuleResult.Failure( + [ + new DbValidationFailure( + "Status", + context.FallbackMessage ?? $"Status must be {requiredStatus}.", + context.RuleName), + ], + "Customer record is not ready."); + }); +}); + FormDefinition form = FormAutomationMetadata.NormalizeForExport(CreateCustomerEntryForm()); DbAutomationMetadata automation = form.Automation ?? throw new InvalidOperationException("The sample form should export automation metadata."); PrintAutomationMetadata(automation); -ValidateAutomationMetadata(automation, functions, commands); +ValidateAutomationMetadata(automation, functions, commands, validationRules); PrintGeneratedStub(automation); await RunSqlScalarFunctionDemoAsync(functions); await RunAdminFormsAutomationDemoAsync(form, commands, auditLog); +await RunAdminFormsValidationDemoAsync(form, validationRules); Console.WriteLine(); -Console.WriteLine("Set breakpoints inside Slugify or AuditCustomerChange, then run this sample from VS Code."); +Console.WriteLine("Set breakpoints inside Slugify, AuditCustomerChange, or a validation rule, then run this sample from VS Code."); static async Task RunSqlScalarFunctionDemoAsync(DbFunctionRegistry functions) { @@ -111,6 +163,26 @@ static async Task RunAdminFormsAutomationDemoAsync( Console.WriteLine($" Audit: {auditEntry}"); } +static async Task RunAdminFormsValidationDemoAsync( + FormDefinition form, + DbValidationRuleRegistry validationRules) +{ + var record = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Id"] = 43L, + ["Name"] = "Test Account", + ["Status"] = "Draft", + }; + + var validation = new DefaultValidationInferenceService(validationRules, DbExtensionPolicies.DefaultHostCallbackPolicy); + IReadOnlyList errors = await validation.EvaluateAsync(form, record); + + Console.WriteLine(); + Console.WriteLine("Admin Forms validation result:"); + foreach (ValidationError error in errors) + Console.WriteLine($" {error.FieldName}: {error.RuleId} - {error.Message}"); +} + static void PrintAutomationMetadata(DbAutomationMetadata automation) { Console.WriteLine("Exported automation metadata:"); @@ -122,17 +194,22 @@ static void PrintAutomationMetadata(DbAutomationMetadata automation) foreach (DbAutomationCommandReference command in automation.Commands ?? []) Console.WriteLine($" command {command.Name} from {command.Surface}:{command.Location}"); + + foreach (DbAutomationValidationRuleReference rule in automation.ValidationRules ?? []) + Console.WriteLine($" validation {rule.Name} from {rule.Surface}:{rule.Location}"); } static void ValidateAutomationMetadata( DbAutomationMetadata automation, DbFunctionRegistry functions, - DbCommandRegistry commands) + DbCommandRegistry commands, + DbValidationRuleRegistry validationRules) { AutomationValidationResult result = AutomationManifestValidator.Validate( automation, functions, commands, + validationRules, new AutomationManifestValidationOptions(RequireMetadata: true)); Console.WriteLine(); @@ -180,6 +257,38 @@ static FormDefinition CreateCustomerEntryForm() ["formula"] = "=Slugify(Name)", }), ValidationOverride: null), + new ControlDefinition( + "customer-name", + "text", + new Rect(24, 72, 240, 32), + Binding: new BindingDefinition("Name", "TwoWay"), + Props: new PropertyBag(new Dictionary + { + ["label"] = "Name", + }), + ValidationOverride: new ValidationOverride( + DisableInferredRules: false, + AddRules: + [ + new ValidationRule( + "CustomerNamePolicy", + "Use the real customer name, not a placeholder.", + new Dictionary + { + ["blockedText"] = "test", + }), + ], + DisableRuleIds: [])), + new ControlDefinition( + "customer-status", + "text", + new Rect(24, 120, 160, 32), + Binding: new BindingDefinition("Status", "TwoWay"), + Props: new PropertyBag(new Dictionary + { + ["label"] = "Status", + }), + ValidationOverride: null), ], EventBindings: [ @@ -211,6 +320,16 @@ static FormDefinition CreateCustomerEntryForm() CommandName: "AuditCustomerChange"), ], Name: "ReusableCustomerAudit"), + ], + ValidationRules: + [ + new ValidationRule( + "CustomerReadyForInsert", + "Customer status must be Ready before save.", + new Dictionary + { + ["requiredStatus"] = "Ready", + }), ]); static string Slugify(string text) diff --git a/samples/trusted-csharp-host/README.md b/samples/trusted-csharp-host/README.md index efa13529..90d7a21b 100644 --- a/samples/trusted-csharp-host/README.md +++ b/samples/trusted-csharp-host/README.md @@ -9,11 +9,14 @@ It demonstrates: - registering a trusted scalar function with `DbFunctionRegistry` - calling that function from SQL - registering a trusted command with `DbCommandRegistry` +- registering trusted Admin Forms validation rules with + `DbValidationRuleRegistry` - exporting Admin Forms automation metadata - validating automation metadata against registered callbacks - generating starter C# registration stubs from automation metadata - running an Admin Forms action sequence that sets a field - invoking a reusable named Admin Forms action sequence that calls the command +- running field-level and form-level validation callbacks - inspecting an Access-style macro form manifest with open form, filter, run SQL, and conditional UI rule actions - inspecting callback arguments and metadata in console output @@ -30,7 +33,8 @@ only. 4. Watch the sample print exported automation metadata. 5. Watch validation confirm that referenced callbacks are registered. 6. Inspect the generated starter C# registration stub. -7. Put breakpoints in `Slugify` or the `AuditCustomerChange` command callback. +7. Put breakpoints in `Slugify`, `AuditCustomerChange`, or one of the + validation rule callbacks. ## Developer Handoff Story @@ -69,6 +73,28 @@ builder.AddCommand( return DbCommandResult.Success( $"Customer {customerId} changed to {status}."); +}); +``` + +Validation rules use the same host-owned pattern: + +```csharp +builder.AddRule( + "CustomerNamePolicy", + new DbValidationRuleOptions( + Description: "Rejects placeholder customer names.", + Timeout: TimeSpan.FromSeconds(2)), + static (context, ct) => + { + string text = context.Value.IsNull ? string.Empty : context.Value.AsText; + + DbValidationRuleResult result = text.Contains("test", StringComparison.OrdinalIgnoreCase) + ? DbValidationRuleResult.Failure( + context.FallbackMessage ?? "Customer name is not allowed.", + context.FieldName, + context.RuleName) + : DbValidationRuleResult.Success(); + return ValueTask.FromResult(result); }); ``` @@ -90,11 +116,15 @@ Expected output includes: - generated starter registration code - slug values from SQL - an audit entry from the reusable form action sequence +- validation errors from a field rule and a form rule The audit entry prints callback metadata such as the form event and reusable action sequence name, along with callback arguments passed from the form record and action sequence. +The validation result prints the failing field, rule id, and message. The same +rules block save in Admin Forms when referenced by saved form metadata. + ## Files - `Program.cs` contains the host registration code, metadata validation, stub diff --git a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor index e57587a9..b62652de 100644 --- a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor @@ -44,7 +44,7 @@ @@ -233,6 +233,7 @@ {

This callback is referenced by saved database metadata, but it is not registered in the current Admin host. + Remove it by editing the referenced object below, or register the callback in host code.

@@ -243,7 +244,7 @@ - + @@ -548,7 +549,7 @@ }; private static string FormatRuntime(HostCallbackCatalogEntry entry) - => entry.Descriptor?.Runtime.ToString() ?? "Missing"; + => entry.Descriptor?.Runtime.ToString() ?? "Not registered"; private static string FormatArity(int? arity) => arity?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-"; @@ -616,7 +617,7 @@ private string GetEntryStatus(HostCallbackCatalogEntry entry) { if (entry.IsMissingRegistration) - return "Missing"; + return "Not registered"; return entry.Descriptor is { } descriptor ? GetPolicyStatus(CallbackPolicy.Evaluate(descriptor)) @@ -640,7 +641,7 @@ return _readiness.Ready ? $"Ready · {_readiness.RegisteredCount} registered" - : $"{_readiness.MissingCount} missing · {_readiness.ReferencedCount} referenced"; + : $"{_readiness.MissingCount} unregistered · {_readiness.ReferencedCount} referenced"; } private string GetReadinessBadgeClass() diff --git a/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs b/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs index 1f2119cb..09a16789 100644 --- a/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs +++ b/src/CSharpDB.Admin/Services/AdminHostCallbacks.cs @@ -59,6 +59,11 @@ public static DbExtensionPolicy CreatePolicy() DbExtensionCapabilityGrantStatus.Granted, Reason: "Admin host registered trusted commands.", PolicySource: PolicySource), + new DbExtensionCapabilityGrant( + DbExtensionCapability.ValidationRules, + DbExtensionCapabilityGrantStatus.Granted, + Reason: "Admin host registered validation rules.", + PolicySource: PolicySource), ], DefaultTimeout: TimeSpan.FromSeconds(5), RequireSignature: true, diff --git a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs index 0484c1b2..e5ad4b0c 100644 --- a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs +++ b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs @@ -42,14 +42,24 @@ public sealed class HostCallbackCatalogService private static readonly string[] SqlFunctionIgnoreList = [ "ABS", + "AND", + "AS", "AVG", + "BETWEEN", "CAST", "CHECK", "COALESCE", "COUNT", "DATE", "DATETIME", + "DEFAULT", + "EXISTS", + "FILTER", + "FOREIGN", + "FROM", + "GROUP", "IFNULL", + "IN", "JULIANDAY", "KEY", "LENGTH", @@ -57,12 +67,19 @@ public sealed class HostCallbackCatalogService "LTRIM", "MAX", "MIN", + "NOT", "NULLIF", + "ON", + "OR", + "OVER", + "PRIMARY", "PRINTF", "RAISE", "RANDOM", + "REFERENCES", "ROUND", "RTRIM", + "SELECT", "STRFTIME", "SUBSTR", "SUBSTRING", @@ -70,7 +87,10 @@ public sealed class HostCallbackCatalogService "TIME", "TRIM", "TYPEOF", + "UNIQUE", "UPPER", + "VALUES", + "WHERE", ]; private readonly IServiceProvider _services; diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs index 7e5ba2a3..46b3bb5c 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs @@ -352,7 +352,13 @@ public async Task GetEntriesAsync_DiscoversSavedQueryProcedureAndTriggerScalarRe { Id = 12, Name = "Customer Score Query", - SqlText = "SELECT NormalizeName(Name), COUNT(*) FROM Customers GROUP BY NormalizeName(Name);", + SqlText = """ + SELECT NormalizeName(Name), COUNT(*) + FROM Customers + WHERE Status IN ('Open', 'Ready') + AND EXISTS (SELECT 1 FROM Regions WHERE Regions.Id = Customers.RegionId) + GROUP BY NormalizeName(Name); + """, }, ], procedures: @@ -397,6 +403,9 @@ public async Task GetEntriesAsync_DiscoversSavedQueryProcedureAndTriggerScalarRe Assert.Equal("Trigger", Assert.Single(auditScore.References).OwnerKind); Assert.DoesNotContain(entries, entry => entry.Name == "COUNT"); + Assert.DoesNotContain(entries, entry => entry.Name == "EXISTS"); + Assert.DoesNotContain(entries, entry => entry.Name == "IN"); + Assert.DoesNotContain(entries, entry => entry.Name == "VALUES"); } private static FormDefinition CreateForm(string formId, string tableName) diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs index 37ed1b15..363f0253 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackPolicyServiceTests.cs @@ -13,6 +13,7 @@ public void Evaluate_DefaultPolicyAllowsAdminHostCallbacks() [ .. AdminHostCallbacks.CreateFunctionRegistry().Callbacks, .. AdminHostCallbacks.CreateCommandRegistry().Callbacks, + .. CreateValidationRuleRegistry().Callbacks, ]; Assert.NotEmpty(callbacks); @@ -70,4 +71,13 @@ public void Evaluate_UsesCallbackTimeoutBeforeDefaultPolicyTimeout() Assert.True(decision.Allowed); Assert.Equal(TimeSpan.FromSeconds(17), decision.Timeout); } + + private static DbValidationRuleRegistry CreateValidationRuleRegistry() + => DbValidationRuleRegistry.Create(builder => + { + builder.AddRule( + "AdminHostValidationRule", + new DbValidationRuleOptions(Description: "Test validation rule for Admin host policy."), + static (_, _) => ValueTask.FromResult(DbValidationRuleResult.Success())); + }); } From a3fef5caed26c43b7a36cc7d8a28163548dd9ae4 Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Fri, 1 May 2026 22:34:39 -0700 Subject: [PATCH 31/39] feat: Enhance SQL handling in HostCallbackCatalogService and update tests for new SQL patterns --- .../Services/HostCallbackCatalogService.cs | 202 +++++++++++++++++- .../Admin/HostCallbackCatalogServiceTests.cs | 16 +- 2 files changed, 215 insertions(+), 3 deletions(-) diff --git a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs index e5ad4b0c..5e3c2997 100644 --- a/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs +++ b/src/CSharpDB.Admin/Services/HostCallbackCatalogService.cs @@ -60,6 +60,7 @@ public sealed class HostCallbackCatalogService "GROUP", "IFNULL", "IN", + "INTO", "JULIANDAY", "KEY", "LENGTH", @@ -330,8 +331,9 @@ private static void AddSqlScalarFunctionReferences( string ownerId, string ownerName) { + string? inspectedSql = MaskSqlTableColumnListTargets(sql); int index = 0; - foreach (DbAutomationScalarFunctionCall call in DbAutomationExpressionInspector.FindScalarFunctionCalls(sql, SqlFunctionIgnoreList)) + foreach (DbAutomationScalarFunctionCall call in DbAutomationExpressionInspector.FindScalarFunctionCalls(inspectedSql, SqlFunctionIgnoreList)) { references.Add(new HostCallbackReference( AutomationCallbackKind.ScalarFunction, @@ -346,6 +348,204 @@ private static void AddSqlScalarFunctionReferences( } } + private static string? MaskSqlTableColumnListTargets(string? sql) + { + if (string.IsNullOrWhiteSpace(sql)) + return sql; + + ReadOnlySpan input = sql.AsSpan(); + char[] output = sql.ToCharArray(); + for (int i = 0; i < input.Length; i++) + { + char current = input[i]; + if (current is '\'' or '"') + { + i = SkipQuoted(input, i, current); + continue; + } + + if (current == '[') + { + i = SkipBracketed(input, i); + continue; + } + + if (!IsIdentifierStart(current)) + continue; + + int start = i; + i++; + while (i < input.Length && IsIdentifierPart(input[i])) + i++; + + int end = i; + int cursor = SkipWhitespaceForward(input, end); + if (cursor < input.Length + && input[cursor] == '(' + && IsSqlTableColumnListTarget(input, start)) + { + for (int j = start; j < end; j++) + output[j] = ' '; + } + + i = end - 1; + } + + return new string(output); + } + + private static bool IsSqlTableColumnListTarget(ReadOnlySpan input, int identifierStart) + { + int qualifiedStart = GetQualifiedIdentifierStart(input, identifierStart); + string? previous = GetPreviousIdentifier(input, qualifiedStart); + if (previous is null) + return false; + + if (previous.Equals("INTO", StringComparison.OrdinalIgnoreCase) + || previous.Equals("TABLE", StringComparison.OrdinalIgnoreCase) + || previous.Equals("REFERENCES", StringComparison.OrdinalIgnoreCase) + || previous.Equals("WITH", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (previous.Equals("EXISTS", StringComparison.OrdinalIgnoreCase)) + return HasEarlierStatementKeyword(input, qualifiedStart, "CREATE", "TABLE"); + + if (previous.Equals("ON", StringComparison.OrdinalIgnoreCase)) + return HasEarlierStatementKeyword(input, qualifiedStart, "CREATE", "INDEX"); + + return false; + } + + private static int GetQualifiedIdentifierStart(ReadOnlySpan input, int identifierStart) + { + int result = identifierStart; + while (true) + { + int dot = SkipWhitespaceBackward(input, result - 1); + if (dot < 0 || input[dot] != '.') + return result; + + int previousEnd = SkipWhitespaceBackward(input, dot - 1); + if (previousEnd < 0 || !IsIdentifierPart(input[previousEnd])) + return result; + + int previousStart = previousEnd; + while (previousStart > 0 && IsIdentifierPart(input[previousStart - 1])) + previousStart--; + + result = previousStart; + } + } + + private static bool HasEarlierStatementKeyword(ReadOnlySpan input, int beforeIndex, params string[] required) + { + var found = new HashSet(StringComparer.OrdinalIgnoreCase); + int cursor = beforeIndex - 1; + while (cursor >= 0) + { + char current = input[cursor]; + if (current == ';') + break; + + if (!IsIdentifierPart(current)) + { + cursor--; + continue; + } + + int end = cursor + 1; + while (cursor >= 0 && IsIdentifierPart(input[cursor])) + cursor--; + + string keyword = input[(cursor + 1)..end].ToString(); + foreach (string value in required) + { + if (keyword.Equals(value, StringComparison.OrdinalIgnoreCase)) + found.Add(value); + } + + if (found.Count == required.Length) + return true; + } + + return false; + } + + private static string? GetPreviousIdentifier(ReadOnlySpan input, int beforeIndex) + { + int end = SkipWhitespaceBackward(input, beforeIndex - 1); + if (end < 0) + return null; + + if (input[end] == ']') + { + int start = end - 1; + while (start >= 0 && input[start] != '[') + start--; + + return start >= 0 + ? input[(start + 1)..end].ToString() + : null; + } + + if (!IsIdentifierPart(input[end])) + return null; + + int identifierStart = end; + while (identifierStart > 0 && IsIdentifierPart(input[identifierStart - 1])) + identifierStart--; + + return input[identifierStart..(end + 1)].ToString(); + } + + private static int SkipWhitespaceForward(ReadOnlySpan input, int start) + { + int cursor = start; + while (cursor < input.Length && char.IsWhiteSpace(input[cursor])) + cursor++; + + return cursor; + } + + private static int SkipWhitespaceBackward(ReadOnlySpan input, int start) + { + int cursor = start; + while (cursor >= 0 && char.IsWhiteSpace(input[cursor])) + cursor--; + + return cursor; + } + + private static int SkipQuoted(ReadOnlySpan input, int start, char quote) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] == quote) + return i; + } + + return input.Length - 1; + } + + private static int SkipBracketed(ReadOnlySpan input, int start) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] == ']') + return i; + } + + return input.Length - 1; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + + private static bool IsIdentifierPart(char value) + => char.IsLetterOrDigit(value) || value == '_'; + private static void AddReferences( List references, DbAutomationMetadata? metadata, diff --git a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs index 46b3bb5c..6adc1356 100644 --- a/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs +++ b/tests/CSharpDB.Admin.Forms.Tests/Admin/HostCallbackCatalogServiceTests.cs @@ -366,7 +366,12 @@ AND EXISTS (SELECT 1 FROM Regions WHERE Regions.Id = Customers.RegionId) new CSharpDB.Client.Models.ProcedureDefinition { Name = "RefreshCustomerRisk", - BodySql = "UPDATE Customers SET Risk = RiskScore(Total, Region);", + BodySql = """ + CREATE INDEX idx_ops_events_entity_date ON ops_events (entity_type, event_date); + INSERT INTO ops_events (entity_type, entity_id, event_type) + VALUES ('customer', @customerId, 'risk-refreshed'); + UPDATE Customers SET Risk = RiskScore(Total, Region); + """, }, ], triggers: @@ -377,7 +382,11 @@ AND EXISTS (SELECT 1 FROM Regions WHERE Regions.Id = Customers.RegionId) TableName = "Customers", Timing = CSharpDB.Client.Models.TriggerTiming.After, Event = CSharpDB.Client.Models.TriggerEvent.Update, - BodySql = "INSERT INTO Audit(Value) VALUES(AuditScore(new.Risk));", + BodySql = """ + INSERT INTO Audit(Value) VALUES(AuditScore(new.Risk)); + INSERT INTO ops_events (entity_type, entity_id, event_type) + VALUES ('customer', new.Id, 'updated'); + """, }, ])) .AddScoped() @@ -405,6 +414,9 @@ AND EXISTS (SELECT 1 FROM Regions WHERE Regions.Id = Customers.RegionId) Assert.DoesNotContain(entries, entry => entry.Name == "COUNT"); Assert.DoesNotContain(entries, entry => entry.Name == "EXISTS"); Assert.DoesNotContain(entries, entry => entry.Name == "IN"); + Assert.DoesNotContain(entries, entry => entry.Name == "INTO"); + Assert.DoesNotContain(entries, entry => entry.Name == "Audit"); + Assert.DoesNotContain(entries, entry => entry.Name == "ops_events"); Assert.DoesNotContain(entries, entry => entry.Name == "VALUES"); } From 962ece1fd6c28d354ad19ee32ff5caadb817ddcd Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Sat, 2 May 2026 08:51:08 -0700 Subject: [PATCH 32/39] Remove the mockup html files --- www/admin-ui-mockups/command-palette.html | 304 ------- www/admin-ui-mockups/dashboard.html | 504 ----------- www/admin-ui-mockups/data-tab.html | 495 ----------- www/admin-ui-mockups/forms-mobile.html | 760 ---------------- www/admin-ui-mockups/heavy-tab.html | 409 --------- www/admin-ui-mockups/index.html | 539 ------------ www/admin-ui-mockups/query-tab.html | 602 ------------- www/admin-ui-mockups/reports-designer.html | 973 --------------------- www/admin-ui-mockups/reports-mobile.html | 817 ----------------- www/admin-ui-mockups/sidebar.html | 474 ---------- www/admin-ui-mockups/styles.css | 355 -------- 11 files changed, 6232 deletions(-) delete mode 100644 www/admin-ui-mockups/command-palette.html delete mode 100644 www/admin-ui-mockups/dashboard.html delete mode 100644 www/admin-ui-mockups/data-tab.html delete mode 100644 www/admin-ui-mockups/forms-mobile.html delete mode 100644 www/admin-ui-mockups/heavy-tab.html delete mode 100644 www/admin-ui-mockups/index.html delete mode 100644 www/admin-ui-mockups/query-tab.html delete mode 100644 www/admin-ui-mockups/reports-designer.html delete mode 100644 www/admin-ui-mockups/reports-mobile.html delete mode 100644 www/admin-ui-mockups/sidebar.html delete mode 100644 www/admin-ui-mockups/styles.css diff --git a/www/admin-ui-mockups/command-palette.html b/www/admin-ui-mockups/command-palette.html deleted file mode 100644 index b7a51d0b..00000000 --- a/www/admin-ui-mockups/command-palette.html +++ /dev/null @@ -1,304 +0,0 @@ - - - - -Command Palette — CSharpDB Studio Mockup - - - - - -
- - -
-
- - CSharpDB Studio - — relational.db -
- - -
- Connected -
- -
- -
-
-
Dashboard
-
Customers
-
-
-
- -
- Connected -
-
- - -
- - -
- - -
- All - Actions - Tables · 4 - Views · 1 - Forms · 2 - Reports · 1 - Procedures · 1 -
- -
- -
Tables
-
-
-
Customers 1,247 rows
-
- Open - -
-
-
-
-
CustomerAddresses 2,109 rows
-
Open
-
-
-
-
CustomerNotes 514 rows
-
Open
-
- -
Forms
-
-
-
Customer Form → Customers
-
Form
-
- -
Reports
-
-
-
Customer Sales Report → vw_customer_sales
-
Report
-
- -
Procedures
-
-
-
recalc_customer_balances
-
Proc
-
- -
Actions
-
-
-
Run: SELECT * FROM Customers LIMIT 50
-
- Action - Ctrl ↵ -
-
-
-
-
New Form for Customers
-
Action
-
-
-
-
Drop table Customersrequires confirm
-
Danger
-
- -
- -
- Navigate - Open - Ctrl ↵ Open in new tab - Tab Filter by type - Esc Dismiss -
- 9 results in 3 ms -
-
- - -
-
Reality check vs. the live screen
-
    -
  • ALREADY EXISTS — sidebar text-filter input that narrows the visible tree (case-insensitive substring match), keyboard shortcuts Ctrl+N (new query) / Ctrl+B (toggle sidebar) / Ctrl+Enter (run query) / Ctrl+W (close tab) / Ctrl+Shift+L (theme), right-click context menus on every object kind with "Open / Design / Select Top 50 / Drop" actions, modal confirm for danger ops, autocomplete inside the SQL editor.
  • -
  • DOES NOT EXIST TODAY — there is no Ctrl+K palette or any global "search anything" entry point. Users must locate items via the sidebar tree or open them from a tab.
  • -
  • NEW: Ctrl+K palette — fuzzy search across tables, views, forms, reports, procedures, and saved queries in one input.
  • -
  • NEW: Action items inline — "Run: SELECT TOP 50 FROM X", "New Form for X", "Drop X". Surfaces what's currently buried in right-click menus.
  • -
  • NEW: Type filter chips with keyboard Tab cycling.
  • -
  • NEW: Title-bar launcher ("Search anything…") for discoverability.
  • -
  • Danger actions still route through the existing Modal.ConfirmAsync with isDanger:true — reuses what's there.
  • -
-
- - diff --git a/www/admin-ui-mockups/dashboard.html b/www/admin-ui-mockups/dashboard.html deleted file mode 100644 index f909ab36..00000000 --- a/www/admin-ui-mockups/dashboard.html +++ /dev/null @@ -1,504 +0,0 @@ - - - - -Dashboard — CSharpDB Studio Mockup - - - - - -
- - -
-
- - CSharpDB Studio - — relational.db -
- - -
- - - -
-
- Connected - - - -
- - -
- - - - -
-
-
Dashboard
-
Customers
-
Query 1
- -
- -
-
-
-
relational.db
-

Dashboard

-
-
- - -
-
- - -
-
-
Tables
-
24
-
+2 this week
- -
-
-
Views
-
6
-
no change
- -
-
-
Procedures
-
11
-
+1 today
- -
-
-
Indexes
-
38
-
across 14 tables
- -
-
-
Storage
-
2.4 GB
-
-
2.4 GB used3.5 GB cap
-
-
- -
- -
-
-
-

Top tables by row count

- View all 24 → -
-
-
Customers
-
1,247
-
- - - - -
-
-
-
-
Orders
-
8,912
-
- - - - -
-
-
-
-
OrderItems
-
24,310
-
- - - - -
-
-
-
-
Invoices
-
5,103
-
- - - - -
-
-
-
-
Products
-
412
-
- - - - -
-
-
-
- -
-
-

Recent activity

- Open log → -
-
-
-
-
Ran query on Customers
-
SELECT * FROM Customers WHERE status = 'active' LIMIT 50
-
-
2m ago
-
-
-
-
-
Edited form Customer Form
-
Added field "loyaltyTier"
-
-
14m ago
-
-
-
-
-
Created procedure recalc_invoices
-
11 statements · validated
-
-
1h ago
-
-
-
-
-
Pipeline nightly_etl finished with warnings
-
3 of 1,204 rows skipped (type mismatch)
-
-
3h ago
-
-
-
- - -
-
-
-

Pinned

- -
-
-
Customers
-
Orders
-
Customer Form
-
Sales Report
-
"Active customers"
-
recalc_invoices
-
-
- -
-

Quick actions

-
- - - - - - -
-
- -
-
-

Health

- Run full check → -
-
-
Last integrity check2h ago · OK
-
Open writer transactions1
-
Storage pressure68%
-
Background jobs2 running
-
WAL fsync modeHybridIncrementalDurable
-
-
-
-
-
-
-
- - -
- Connected - relational.db - 24 tables · 6 views · 11 procs - 2 background jobs -
- 2.4 GB / 3.5 GB - 3 notifications -
-
- - -
-
Reality check vs. the live screen
-
    -
  • ALREADY EXISTS — current Welcome tab shows app icon, app name, four shortcut hints (Ctrl+N / Ctrl+B / Ctrl+Enter / Ctrl+Shift+L), and a grid of table cards with a "Load row counts" button. Status bar already reports table/view/procedure counts. Title bar already has Open Database / New Query / New Table / New Form / New Procedure / New Pipeline / Storage buttons plus theme/sidebar/help toggles. ANALYZE and storage stats already accessible via the Storage tab and Query tab "Stats" shortcuts.
  • -
  • NEW: Stat cards row with deltas and a storage-pressure bar.
  • -
  • NEW: Top tables panel with sparklines for row-count growth — today you'd run an ad-hoc query.
  • -
  • NEW: Recent activity feed — there's no audit/activity surface today.
  • -
  • NEW: Pinned objects grid — pinning doesn't exist anywhere yet.
  • -
  • RESTYLE: Quick action tiles — same actions as the title-bar buttons, in a more discoverable card form.
  • -
  • NEW: Health panel — surfaces integrity, open writers, storage pressure, background jobs, WAL mode in one place. Today these are scattered across StorageTab's 11 stacked sections.
  • -
  • NEW: Title-bar database switcher dropdown + search-style palette launcher — today there's just an "Open Database" button that opens a path prompt (no recent-databases list).
  • -
  • NEW: Status-bar storage pressure / background-job ticker / notification counter — current status bar shows connection state, db path, and counts.
  • -
-
- - diff --git a/www/admin-ui-mockups/data-tab.html b/www/admin-ui-mockups/data-tab.html deleted file mode 100644 index b1c1199f..00000000 --- a/www/admin-ui-mockups/data-tab.html +++ /dev/null @@ -1,495 +0,0 @@ - - - - -Data Tab — CSharpDB Studio Mockup - - - - - -
- -
-
- - CSharpDB Studio - — relational.db -
- - -
- Connected - - -
- -
- - -
-
-
Dashboard
-
Customers
-
Query 1
- -
- - -
-
- tables - / - Customers -
- -
- - - - -
- -
- - -
- -
- - - -
- -
- - -
- -
- Rows 1–25 of 1,247 - - -
-
- - -
- Filters: - - status - = - 'active' - - - - created_at - - 2026-01-01 - - - - name - LIKE - '%Smith%' - - - - - Showing 42 of 1,247 rows - -
- - -
-
RegistrationMissing
RegistrationNot registered
Kind@GetKindLabel(selected.Kind)
Arity@FormatArity(selected.Arity)
References@selected.References.Count
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
id nameemailstatuscreated_atorder_countnotes
1Alice Smithalice@example.comactive2026-01-12 09:1412NULL
2Bob Leebob@example.comactive2026-01-14 11:023VIP — handle with care
3Carol Smithcarol@example.compending2026-02-03 16:480NULL
4David Smithdsmith@example.comactive2026-02-19 08:227Net 30 terms
5Eve Smitherseve@example.cominactive2026-03-01 14:1022Migrated from legacy
6Frank Smithyfrank@example.comactive2026-03-12 10:551NULL
7Grace Smith-Jonesgrace@example.comactive2026-03-22 09:175Refer-a-friend
8Henry Smithfieldhank@example.compending2026-04-02 12:380NULL
- - -
- 2 selected - Apply an action to the selected rows: -
- - - - -
-
-
-
-
- -
- Connected - relational.db - Customers · 1,247 rows - 3 filters · 42 visible -
- Last refresh 2s ago - 2.4 GB / 3.5 GB -
-
- - -
-
Reality check vs. the live screen
-
    -
  • ALREADY EXISTS — Data/Schema view switcher, Add Row, Delete (with selection count), Save, Discard, Refresh, Drop Table buttons in toolbar. Per-column filter row with Contains/StartsWith/EndsWith/Exact mode selector. Sortable headers with ↑↓ indicators. Type badges (INTEGER/TEXT/REAL/BLOB). PK badges. Pagination bar with page-size dropdown and first/prev/next/last. Inline cell editing on double-click. Right-click context menu for row actions. NULL and BLOB cell rendering.
  • -
  • NEW: Breadcrumb header ("tables / Customers") — today the object name is right-aligned in the toolbar info area.
  • -
  • NEW: Filter summary chip strip — a recap of all active filters as removable pills. The per-column filter row stays; this adds an overview that's currently missing.
  • -
  • NEW: Schema sub-tabs — split the Schema view's three stacked sections (Columns, Indexes, Triggers) into sub-tabs so users don't scroll past the first section.
  • -
  • NEW: Export / Import buttons — no export today.
  • -
  • NEW: Bulk action bar at bottom when rows are selected — Bulk edit, Copy as INSERT, Export, Delete. Delete exists today via toolbar/context menu, but Bulk edit and Copy as INSERT are new.
  • -
  • RESTYLE — type icons in column headers (replacing or supplementing the existing text badges). Status pills for enum-ish columns. Right-aligned numbers, distinct date coloring. Visual polish on top of features that already work.
  • -
  • NEW: Status-bar pills for table-specific facts (row count, active filter count, last refresh).
  • -
-
- - diff --git a/www/admin-ui-mockups/forms-mobile.html b/www/admin-ui-mockups/forms-mobile.html deleted file mode 100644 index 88a82b95..00000000 --- a/www/admin-ui-mockups/forms-mobile.html +++ /dev/null @@ -1,760 +0,0 @@ - - - - -Elastic Forms — CSharpDB Studio Mockup - - - - - -
- -
CSharpDB.Admin.Forms · UI proposal
-

Make forms mobile-friendly with an "Elastic" layout mode

-

- Today every form rendered by FormRenderer.razor uses absolute pixel coordinates - — controls have a fixed X / Y / W / H from Rect, and on a phone the - form simply scrolls horizontally. The LayoutDefinition model already carries a - Breakpoints list and ControlDefinition already has - RendererHints, so the schema is responsive-ready. This proposal adds a designer - switch and a runtime that uses them. -

- - -

1 · Designer · new "Layout mode" switch

- -
-
-
-
- Layout mode -
- - -
-
-
- Preview -
- - - -
-
-
- 12-col grid · 8 px gutter -
-
-
- 2 controls overflow at Mobile -
-
- -
-
-
-
Customer · CST-00142
-
vw_customer_form · tablet preview · 720 × auto
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - -
-
-
tablet · 768 px breakpoint
-
-
-
- - -
-

Layout settings

- -
- Mode - Elastic · 12-col grid -
-
- Auto-stack on mobile -
-
-
- Touch targets - ≥ 44 px on mobile -
- -

Breakpoints

- -
- Desktop - ≥ 1024 px · 12 cols -
-
- Tablet - ≥ 640 px · 8 cols -
-
- Mobile - < 640 px · stacked -
- -

Auto-elastic

-
- For legacy forms -
-
-

- When a form has no breakpoint overrides, the renderer infers a 12-col grid from the - pixel layout (X/W → column span, Y → row order) so existing forms get a sensible - mobile view without redesign. -

-
-
- - -

2 · Same form at three breakpoints

-

- One form definition, three viewport sizes. Designer can switch between them with the toolbar - Preview segmented control; users see the right one based on their screen. -

- -
- -
-
-
Customer · CST-00142
-
desktop · 12-col
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
desktop · ≥ 1024 px
-
- - -
-
-
Customer · CST-00142
-
tablet · 8-col
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
tablet · ≥ 640 px
-
- - -
-
-
-
Customer · CST-00142
-
mobile · stacked
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
mobile · < 640 px
-
-
- - -

3 · Today vs. Elastic on a 380 px phone

- -
- -
-
-
-
-
Customer · CST-00142
-
- TODAY · pixel-positioned · 800 px wide -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
Horizontal scroll · cramped · no save button visible
-
- - -
-
-
-
Customer · CST-00142
-
- ELASTIC · auto-stacked · 380 px -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
-
Single-column · 44 px touch targets · primary action visible
-
-
- - -

4 · Implementation notes (no code changed)

- -
-
    -
  • - - Schema is already responsive-ready. - LayoutDefinition has Breakpoints and - ControlDefinition has RendererHints. Per-breakpoint - geometry can ride in RendererHints["breakpoint:mobile"] = { x, y, w, h, hidden } - with no model migration. DesignerState.IsVisibleAtBreakpoint and - GetEffectiveRect already handle this design-side. -
  • -
  • - - Add a LayoutMode field to LayoutDefinition - — values: FixedPixel (today's behavior) and Elastic. - Existing forms default to FixedPixel, so nothing changes for them. -
  • -
  • - - Runtime FormRenderer.razor picks the layout using - a CSS container query (or a one-time window.matchMedia probe) and emits - either: -
    position: absolute; left/top/width/height (FixedPixel, today), -
    — CSS Grid with grid-column: span N per control (Elastic). -
  • -
  • - - Auto-elastic for legacy forms — when a form is FixedPixel but the - viewport is below the tablet breakpoint, the renderer can synthesize a column span - from the original Rect.Width / canvasWidth × 12 and order by - Rect.Y. Doesn't require redesigning every form. -
  • -
  • - - Designer changes — toolbar gains a Layout-mode segmented control and a - Preview-device segmented control. The existing 8 px snap-to-grid stays. Property - inspector grows a "Span at desktop / tablet / mobile" row per control. The "2 - controls overflow at Mobile" pill is a lint warning that links to the offenders. -
  • -
  • - - Runtime niceties — touch targets ≥ 44 px on mobile, primary action sticks to - the bottom of the viewport, the toolbar ([DataEntry.razor toolbar](../../src/CSharpDB.Admin.Forms/Pages/DataEntry.razor)) becomes a flex-wrap row that - collapses Print/Edit-Form behind a "⋯" overflow on small screens. Print stylesheet - ignores breakpoints and renders the desktop layout as today. -
  • -
  • - - Out of scope for this proposal — touch-drag in the designer (designer can - stay desktop-only), mobile-specific control variants (e.g. native date picker), and - an offline data-entry mode. -
  • -
-
- -

- Back to all mockups. -

-
- - diff --git a/www/admin-ui-mockups/heavy-tab.html b/www/admin-ui-mockups/heavy-tab.html deleted file mode 100644 index 7b246ed9..00000000 --- a/www/admin-ui-mockups/heavy-tab.html +++ /dev/null @@ -1,409 +0,0 @@ - - - - -Heavy tab → vertical sub-nav (Storage example) — CSharpDB Studio Mockup - - - - - -
- -
-
- - CSharpDB Studio - — relational.db -
- - -
- Connected -
- -
- - -
-
-
Dashboard
-
Storage
- -
- -
- - - - -
-
Storage / Overview
-

Summary

-

An at-a-glance look at the database file. Pick a category from the rail on the left to drill into details.

- -
- - - -
- -
-
-

File

healthy
-
-
-
-
2.4 GB
-
Database file
-
-
-
68%
-
of 3.5 GB cap
-
-
-
- - - - - -
Page size4 KB
Physical pages614,400
Freelist pages12
WAL file128 MB
-
-
- -
-

Health

3 warnings
-
- - - - - -
Index checksall OK
Integrity issues3 warnings
Last integrity check2h ago
Open writer txns1
-
-
- -
-

Recent maintenance

last 7 days
-
- - - - - -
Last backup16h ago · customers.backup.db
Last vacuum4d ago · 312 MB reclaimed
Last reindex2d ago · 38 indexes
Last FK migration
-
-
- -
-

Page type histogram

-
- - - - - -
btree-leaf412,099
btree-internal8,213
overflow194,076
freelist12
-
-
-
- -
-
-

Top integrity issues

- View all 3 → -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SeverityCodeMessagePage
WARNWAL_TRAILING_BYTESWAL file has 24 trailing bytes after the last commit frame.
WARNFREELIST_GAPFreelist contains a gap; consider running vacuum.512
WARNINDEX_FREE_SPACE_HIGHIndex ix_customers_email has >50% free space across leaf pages.3,212
-
-
-
-
-
- -
- Connected - relational.db - 3 storage warnings -
- 2.4 GB / 3.5 GB -
-
- - -
-
The pattern: split heavy tabs into vertical sub-nav
-
    -
  • ALREADY EXISTS in StorageTab — Database header, WAL, Space usage, Fragmentation, Maintenance (Reindex / Vacuum), Backup & Restore, FK migration, Page-type histogram, Index checks, Integrity issues, Page drill-down. Each is a fully-featured section.
  • -
  • PROBLEM — all eleven sections are vertically stacked in one long scroll. Users have to scroll past the file header every time they want to reach maintenance, and there's no overview of where to start.
  • -
  • NEW: Vertical sub-nav rail on the left of the tab. Categories: Overview, Inspect, Health, Maintenance. Each item is one click away — no scrolling.
  • -
  • NEW: Summary screen as the default view — surfaces the most useful facts (file size with cap, health pills, recent maintenance, top warnings) so a user landing on Storage gets value without picking a section.
  • -
  • NEW: Status badges in the rail (e.g. "WARN 3" on Integrity issues) so users see what needs attention without opening every section.
  • -
  • SAME PATTERN APPLIES TO:
  • -
  • Pipeline tab — currently stacks Designer, Run Result, Stored Pipelines, Recent Runs vertically. Rail items: Designer / Run / Stored / History / Rejects.
  • -
  • Procedure tab — currently stacks Definition, Parameters, Execution. Rail items: Definition / Parameters / Run / Results.
  • -
  • Schema view of Data tab — currently stacks Columns, Alter Table, Indexes, Triggers. Could become sub-tabs as proposed in data-tab.html.
  • -
  • Implementation note: the rail items are routes within a single tab; switching them does not open a new top-level tab. State (e.g. an unsaved JSON edit in Pipeline) is preserved across rail switches.
  • -
-
- - diff --git a/www/admin-ui-mockups/index.html b/www/admin-ui-mockups/index.html deleted file mode 100644 index 3dcc088f..00000000 --- a/www/admin-ui-mockups/index.html +++ /dev/null @@ -1,539 +0,0 @@ - - - - -CSharpDB Admin — UI Improvement Mockups - - - - - -
-

CSharpDB Admin · UI Improvement Mockups

-

Static HTML previews of proposed enhancements to the Blazor Server admin - (src/CSharpDB.Admin). Nothing here is wired to the live app — these are visual - proposals for discussion, now reconciled against the actual current screens.

-

Open each file directly in a browser to see the mocked screen with annotations.

- -
-
NEW Capability that doesn't exist today
-
RESTYLE Existing feature, reorganized or visually refreshed
-
EXISTS Already in the live admin (don't propose as new)
-
GAP Missing today, this mockup adds it
-
- -

The big ideas at a glance

-
-
    -
  • Replace the sparse Welcome tab with a real database dashboard: stat cards, top tables with sparklines, recent activity feed, pinned objects, quick actions, and a consolidated health panel.
  • -
  • Add a command palette (Ctrl+K) — there is no global search today; users must navigate the sidebar tree or right-click for actions.
  • -
  • Sidebar gains Pinned + Recent + drill-down under each table. The existing search/filter and right-click context menus stay; pin star and per-group "+" become discoverable.
  • -
  • Data tab refresh is mostly visual — most "features" I first proposed (per-column filters, sortable headers, type badges, pagination, inline edit, row selection, save/discard) already exist. The genuine adds are: breadcrumb header, filter summary chips, schema sub-tabs, export/import, bulk-edit / copy-as-INSERT, status-bar table facts.
  • -
  • Query tab gains result sub-tabs (Results / Plan / Messages / Stats), an Explain Plan button, a Cancel button, and a query-history rail. Run / Format / Save / Designer mode / autocomplete / completions popup all already exist.
  • -
  • Heavy tabs (Storage / Pipeline / Procedure) currently stack 4–11 sections in one long scroll. Replace with a vertical sub-nav rail and a Summary screen so users land on something useful.
  • -
  • Forms reflow on mobile via a new Elastic layout mode in CSharpDB.Admin.Forms — today every control is absolute-positioned in pixels, so phone users get horizontal scroll. Schema already has Breakpoints and RendererHints; the runtime just doesn't use them.
  • -
  • Reports get a Reader view in CSharpDB.Admin.Reports — paper layout stays untouched (it's supposed to look like paper, and Print/PDF need it), but a derived Reader view turns bands into sections and repeated detail rows into cards. Phone defaults to Reader.
  • -
  • Report designer surfaces the Reader view via a Paper / Reader / Split toolbar toggle, a live phone-sized preview pane, a Reader-hint subsection in the Selection inspector, an "Auto-derive all hints" action, and a Reader-lint panel — so the runtime view never drifts from designer intent.
  • -
  • Title bar gains a database switcher (recent databases dropdown) and a search-style command-palette launcher. Today there's just an "Open Database" button that opens a path prompt.
  • -
-
- -

Mockups

-
- - -
-
- DASHBOARD · relational.db - ┌─ Tables ──┬─ Views ──┬─ Procs ──┐ - │ 24 │ 6 │ 11 │ - └───────────┴──────────┴──────────┘ - Top tables ▮▮▮▮▮▮▮▮ Customers - ▮▮▮▮▮▮ Orders - ▮▮▮▮ Invoices -
- -
-
-

Database Dashboard

-

Replaces the sparse Welcome tab. Stats, top tables with sparklines, recent activity, pinned objects, quick actions, health panel. Mostly net-new content.

-
- -
- - -
-
- ⌘ Search anything - ──────────────────────────── - ▶ Open table: Customers - Run: Select Top 50 - New Form for Customers -
- -
-
-

Command Palette (Ctrl+K)

-

One keystroke to fuzzy-find tables, forms, reports, procedures, or actions. There is no global search in the app today — this is purely additive.

-
- -
- - -
-
- ★ PINNED - ▦ Customers ▦ Orders - ⏱ RECENT - ▦ Invoices ◫ Customer Form - ▾ TABLES 24 + - ▦ Customers - ▦ Orders -
- -
-
-

Improved Object Explorer

-

Pinned + Recent sections, type filter chips, drill-down under tables (columns / indexes / triggers as nested rows). The base tree, search, and right-click menus already exist.

-
- -
- - -
-
- tables / Customers · 1,247 rows - [Data] Schema Indexes Triggers - ▼ status = active ▼ created > 2026-01 - id │ name │ email │ status - ───┼───────────┼───────────┼─────── - 1│ A. Smith │ as@e.com │ active - 2│ B. Lee │ bl@e.com │ active -
- -
-
-

Data Tab refresh

-

Most filter / sort / pagination / type-badge / inline-edit features already exist. Real adds: breadcrumb, filter chip summary, schema sub-tabs, export, bulk action bar.

-
- -
- - -
-
- ▶ Run · Format · Explain · SQL - SELECT c.id, c.name, COUNT(o.id) - FROM Customers c … - ───────────────────────────── - [Results · 1247] Plan Messages - id │ name │ orders - 1 │ Alice Smith │ 12 -
- -
-
-

Query Tab refresh

-

Result sub-tabs (Results / Plan / Messages / Stats), Explain & Cancel buttons, query-history rail. Run / Format / Save / Designer / autocomplete already exist.

-
- -
- - -
-
-
- DESKTOP - [ first ][ last ][ email ] - [ status ][ tier ][ created ] - [ notes ──────────────────── ] -
-
- 📱 - [first ] - [last ] - [email ] - [status] - [ Save ] -
-
- -
-
-

Elastic Forms (mobile-friendly)

-

Forms render with absolute pixel coords today and don't reflow on phones. Add an Elastic layout mode + auto-stack so the same form works on desktop, tablet, and mobile — schema is already responsive-ready.

-
- -
- - -
-
-
- PAPER · Letter - ID │ Cust │ Orders │ Total - ━━━━━━━━━━━━━━━━━━━━━━ - 1 │ A.S. │ 12 │ 4,950 - 4 │ D.S. │ 7 │ 2,723 -
-
- 📱 Reader - ▾ Tier·Gold - ▦ Alice S. - 12 · $4,950 - ▦ David S. -
-
- -
-
-

Mobile-friendly Reports (Reader view)

-

Reports are paper-shaped and shouldn't reflow. Add a derived Reader view: bands → sections, repeated rows → cards, group footers → summary lines. Phone defaults to Reader; Print & PDF stay on Paper.

-
- -
- - -
-
-
- Tools - - T -
-
- Detail band - {id} {name} {total} - [title][field] -
-
- 📱 Reader - ▦ Alice - $4,950 -
-
- -
-
-

Report Designer · Reader-view support

-

Paper / Reader / Split toggle in the toolbar, live phone preview alongside the paper canvas, Reader-hint subsection in the Selection inspector, and a one-click "Auto-derive all hints" + lint panel.

-
- -
- - -
-
- ▾ Storage - ► Summary - Database header - WAL - Space usage - Integrity issues ⚠ 3 - Vacuum / Reindex - Backup & Restore -
- -
-
-

Heavy tabs → vertical sub-nav

-

Storage, Pipeline, and Procedure tabs each stack 4–11 long sections. Add a left rail with a Summary screen so users land on something useful and can jump without scrolling.

-
- -
- -
- -

Reality check: what's actually in the live admin today

-

After reading the actual tab components, the picture is more mature than my first pass assumed. Here's the delta per area.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AreaAlready exists in the live adminGenuinely missing / proposed here
Welcome / first-runApp icon, name, four shortcut hints, table-card grid with optional row counts.NEW Stat cards, top tables with sparklines, recent activity, pinned grid, quick actions, health panel.
Object ExplorerTree of Tables / Forms / Reports / System Catalog / Views / Indexes / Triggers / Procedures with counts; text filter; right-click menus on every kind; collapse-all; resize handle; active-tab highlighting. - NEW Pinned & Recent sections, type filter chips, drill-down under each table (columns / indexes / triggers nested), match highlighting in filter results.
- RESTYLE per-item star + ⋯, per-group "+" on hover. -
Data tabData ↔ Schema view switcher, Add Row / Delete (with count) / Save / Discard / Refresh, per-column filter row with Contains/StartsWith/EndsWith/Exact, sort indicators, type badges, PK badges, pagination, inline edit, row selection, NULL/BLOB rendering, modified-cell styling, right-click context menu. - NEW Breadcrumb header, filter summary chips, schema sub-tabs, export/import, bulk-edit + copy-as-INSERT, status-bar table facts.
- RESTYLE column type icons (vs text badges), enum status pills, value-type cell coloring. -
Query tabRun / Analyze / Clear / Format / Save buttons, saved-query dropdown, Stats shortcuts, SQL ↔ Designer mode toggle, resizable editor with persisted height, line numbers, syntax highlighting, autocomplete popup with arrow-key nav, Ctrl+Enter, EXEC procedure parsing with named args.NEW Result sub-tabs (Results / Plan / Messages / Stats), Explain Plan button, Cancel button, query-history rail per-tab and global.
Storage tab11 stacked sections: DB header, WAL, Space usage, Fragmentation, Maintenance (Reindex / Vacuum), Backup / Restore, FK migration, Page-type histogram, Index checks, Integrity issues, Page drill-down. Refresh, hex dump toggle.NEW Vertical sub-nav rail, default Summary screen, status badges in nav, recent-maintenance recap.
Pipeline tabVisual designer + JSON mode, Validate / Dry Run / Run / Save / Reset, stored pipelines list with revisions, recent runs with metrics, run inspector with rejects, resume.NEW Sub-nav rail to navigate Designer / Run Result / Stored / History / Rejects without scrolling. (Same pattern as Storage.)
Procedure tabDefinition (name / desc / enabled / body SQL), parameters table (name/type/required/default/desc), execution panel with args JSON, per-statement results.NEW Sub-nav rail or sub-tabs for Definition / Parameters / Run / Results so the body editor isn't competing with the args panel.
Forms (Admin.Forms)Visual form designer with grid + snap, toolbox, layers panel, property inspector, child tabs, validation overrides, default form generation from a table schema, undo/redo, navigation, print, child datagrids. Schema model already has LayoutDefinition.Breakpoints and ControlDefinition.RendererHints; DesignerState already exposes IsVisibleAtBreakpoint and GetEffectiveRect. - NEW LayoutMode = Elastic on LayoutDefinition; runtime FormRenderer that emits CSS Grid spans (instead of position: absolute in pixels) and picks a breakpoint based on viewport.
- NEW Designer toolbar gains Layout-mode + Device-preview segmented controls; property inspector gains per-breakpoint span / hidden flags; "controls overflow at this breakpoint" lint pill.
- NEW Auto-elastic fallback for legacy forms — synthesize a 12-col span from the existing Rect.Width / canvasWidth so old forms get a usable mobile view without a redesign. -
Reports (Admin.Reports)Visual report designer with bands (ReportHeader / PageHeader / GroupHeader / Detail / GroupFooter / PageFooter / ReportFooter); Label / BoundText / CalculatedText / Line / Box controls; pixel-positioned within bands; preview pagination at fixed Letter 816 × 1056 px page; overflow-x: auto on the page surface; print stylesheet that hides toolbar; Export PDF; default report generation from a source schema; RendererHints on ReportDefinition and a PropertyBag on each control already available for extension. - NEW Reader view — derived from the same ReportPreviewResult; bands become collapsible sections, repeated Detail rows become cards, GroupFooter becomes a summary row. Auto-pick on phones; user toggle persists per report.
- NEW Per-control reader hints in Props["readerHint"] (role: title / subtitle / field / hidden, custom label, showOnPhone). No schema migration.
- NEW Paper-view zoom controls (Fit-width / 100% / 150% / 200%) and a sticky page pager so Paper is still tolerable on a phone when the user explicitly wants it.
- EXISTS Print and Export PDF continue to use Paper unchanged. -
Reports designer3-column layout: Toolbox (Pointer / Label / BoundText / Calculated / Line / Box) + Groups + Sorts on the left; per-band paper canvas with absolute pixel positions and pointer drag-resize in the center; Page Settings (Paper / Orientation / 4 margins) + Selection inspector (X/Y/W/H, type-specific props, Bring/Send/Delete) on the right. Pointer-driven; no touch affordances. - NEW Toolbar Paper / Reader / Split view toggle + Reader-device toggle (Tablet / Phone). Split mode shows the live Reader projection beside the paper canvas.
- NEW Selection inspector grows a Reader-view subsection (Role: title / subtitle / field / hidden, custom label, "show on phone", quick-action buttons). Stored in Props["readerHint"] — no schema migration.
- NEW Tiny role badges on canvas controls show what the Reader pickup will be.
- NEW "Auto-derive all hints" action seeds hints from inference rules; Reader-lint panel surfaces issues with one-click fixes.
- DELIBERATE NON-GOAL The designer chrome itself stays desktop-only — the 3-column pointer-driven UI is right for building paper reports; only the Reader preview goes mobile-sized. -
Title / status barsLogo + db name, Open Database (path prompt), New Query / Table / Form / Procedure / Pipeline / Storage buttons, theme toggle, sidebar toggle, keyboard-shortcuts modal. Status bar shows connection, db path, table/view/proc counts.NEW Database switcher dropdown with recent databases, command-palette launcher, status-bar storage pressure / background-job ticker / notification counter.
Modals / toastsConfirm modal with danger styling, prompt modal, toast container with success / warning / error, context menu with separators and danger items.NEW Side drawers for non-blocking edits (e.g. column properties); rolling notification log accessible from the status bar.
- -

Smaller wins worth doing

-
-
    -
  • Tab strip: dirty-dot indicator (already mocked), middle-click to close, overflow dropdown when there are too many tabs.
  • -
  • Density toggle (Compact / Comfortable) so the same screens work on a 13" laptop and a 27" monitor.
  • -
  • Empty states with calls-to-action — every empty list should suggest the next step ("No saved queries yet — press Ctrl+S in any query tab").
  • -
  • Inline help: a "?" button per tab that opens a side-panel cheat-sheet for the active tab, instead of a single global keyboard-shortcut modal.
  • -
  • Toast → inbox: keep a rolling notification log accessible from the status bar so users can re-read what happened.
  • -
-
-
- - diff --git a/www/admin-ui-mockups/query-tab.html b/www/admin-ui-mockups/query-tab.html deleted file mode 100644 index c1b19b4d..00000000 --- a/www/admin-ui-mockups/query-tab.html +++ /dev/null @@ -1,602 +0,0 @@ - - - - -Query Tab — CSharpDB Studio Mockup - - - - - -
- -
-
- - CSharpDB Studio - — relational.db -
- - -
- Connected -
- -
- - -
-
-
Dashboard
-
Customers
-
Active customers
- -
- -
- -
- - - - - - - - -
- - -
-
- 1,247 rows · 18.4 ms · page 1 -
-
- - -
- Saved - - - - - - Stats - - -
- - -
-
- -
-
- 1234567 -
-
-- Pull active customers with recent orders
-SELECT  c.id,
-        c.name,
-        c.email,
-        COUNT(o.id) AS order_count
-FROM Customers AS c
-LEFT JOIN Orders AS o ON o.customer_id = c.i
- -
-
idint · pk
-
image_urltext
-
is_activeint (bool)
-
imported_atdatetime
-
-
- -
- - -
-
- - - - -
- Rows 1–25 of 1,247 - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - -
idnameemailorder_countlast_order_at
1Alice Smithalice@example.com122026-04-22 09:14
2Bob Leebob@example.com32026-04-12 11:02
3Carol Smithcarol@example.com92026-04-19 16:48
4David Smithdsmith@example.com72026-04-19 08:22
5Eve Smitherseve@example.com222026-04-21 14:10
6Frank Smithyfrank@example.com12026-03-12 10:55
7Grace Smith-Jonesgrace@example.com52026-04-22 09:17
8Henry Smithfieldhank@example.com0
-
-
-
- - -
-
- History - -
-
- This tab - All - Errors -
-
-
-
✓ 18 ms · 1,247 rowsjust now
-
SELECT c.id, c.name, c.email, COUNT(o.id)…
-
- Restore - Save - Copy -
-
-
-
✓ 4 ms · 1 row2m
-
SELECT COUNT(*) FROM Customers
-
-
-
✗ syntax error5m
-
SELECT * FROMM Orders
-
-
-
✓ 32 ms · 8,912 rows11m
-
SELECT * FROM Orders WHERE created_at > '2026-01-01'
-
-
-
✓ 9 ms · 6 rows34m
-
SELECT * FROM sys.table_stats
-
-
-
✓ 145 ms · 24,310 rows1h
-
SELECT * FROM OrderItems WHERE qty > 5
-
-
-
-
-
-
-
- -
- Connected - relational.db - Active customers · dirty - Last run 18 ms -
- 87 queries today -
-
- - -
-
Reality check vs. the live screen
-
    -
  • ALREADY EXISTS — Run / Analyze / Clear / Format buttons, SQL ↔ Designer mode toggle (a Visual Designer panel exists today!), saved-query name input + Save + Load dropdown + Refresh, Stats shortcuts (Table Stats / Column Stats), per-tab SQL persistence, resizable editor splitter with persisted height, line-numbered editor with syntax highlighting, autocomplete popup with arrow-key nav (Ctrl-Space to trigger explicitly), Ctrl+Enter to run, EXEC procedure parsing with named args, paged results that over-fetch one row to avoid blocking COUNT(*).
  • -
  • NEW: Result sub-tabs — Results / Plan / Messages / Stats. Today the result panel only shows rows. Plan would expose the existing query plan; Messages would collect non-query notices; Stats would show the existing elapsed/rows breakdown plus per-statement timings.
  • -
  • NEW: Explain Plan button in toolbar — runs the query in plan-only mode and switches to the Plan sub-tab.
  • -
  • NEW: Cancel button for in-flight queries.
  • -
  • NEW: History rail on the right — per-tab and global recent-queries list with status (✓/✗), elapsed, and row count. Hover reveals Restore / Save / Copy. Today there's no query history.
  • -
  • NEW: Result toolbar in the result tab strip — Export, Copy as JSON, Pop out results.
  • -
  • RESTYLE — toolbar visually groups primary action (Run) + cancel + secondary tools + view mode + result summary, instead of the current single flat row.
  • -
-
- - diff --git a/www/admin-ui-mockups/reports-designer.html b/www/admin-ui-mockups/reports-designer.html deleted file mode 100644 index 3a53caad..00000000 --- a/www/admin-ui-mockups/reports-designer.html +++ /dev/null @@ -1,973 +0,0 @@ - - - - -Report Designer · Reader-view support — CSharpDB Studio Mockup - - - - - -
- -
CSharpDB.Admin.Reports · Designer-side proposal
-

Add Reader-view support to the report designer

-

- The previous mockup (reports-mobile.html) added a Reader - view at runtime so phones aren't stuck side-scrolling an 816 px paper page. But if the - designer can't see or shape the Reader view, it's effectively invisible to the people - building reports — so the runtime view drifts from intent over time. -

-

- This proposal adds three things to Pages/Designer.razor: a Paper / Reader / - Split view toggle in the toolbar, a Reader-view subsection in the Selection inspector, and - a Reader-lint summary panel that catches problems before users hit them on a phone. The - existing 3-column layout (Toolbox / Bands / Page-settings + Selection) stays. -

- - -

1 · Designer toolbar gains a view-mode switch (Paper / Reader / Split)

- -
-
- - - - - - -
- Designer view -
- - - -
-
- -
- Reader device -
- - -
-
- -
- 3 reader hints needed - Letter · Portrait -
- -
- - - - -
-
- -
-
- Page Header - -
-
-
-
-
- Customer Sales — Q1 2026 -
-
-
-
- - -
-
- Group Header · tier - -
-
-
-
-
- Tier · {tier} - title -
-
-
- - -
-
- Detail - -
-
-
-
-
- {id} -
-
- {customer_name} - title -
-
- {orders} - field -
-
- {avg_amount} - field -
-
- {total_amount} - field -
-
- {last_order_at} -
-
-
- - -
-
- Group Footer · tier - -
-
-
-
-
- Subtotal · =SUM(orders) · =SUM(total_amount) -
-
-
-
- - -
-
- - Reader preview · phone · 380 px -
- - -
-
-
-
-

Customer Sales — Q1 2026

-
vw_customer_sales · live preview
-
-
- Tier · Gold - 3 -
-
-
Alice Smith
-
CUST-00001
-
-
Orders
12
-
Avg
$412.50
-
Total
$4,950.00
-
-
-
-
David Smith
-
CUST-00004
-
-
Orders
7
-
Avg
$389.00
-
Total
$2,723.00
-
-
-
-
-
- - - -
-
- - -

2 · Auto-derive Reader hints + lint warnings

-

- For existing reports the designer can synthesize hints from the layout in one click. The - lint panel surfaces problems specific to Reader so they don't get discovered on a phone. -

- -
-

- Reader-view lint · Customer Sales — Q1 2026 - -

- -
- -
- 3 BoundText controls have no Reader role assigned. -
Detail band · {orders}, {avg_amount}, {last_order_at}
-
- -
- -
- -
- No Card title set on Detail band. -
First BoundText {id} would auto-promote at runtime — set explicitly to avoid surprises.
-
- -
- -
- -
- 2 Line controls in Page Header will be hidden in Reader. -
Decorative controls (Line, Box) auto-hide. No action needed unless you want them shown.
-
- -
- -
- -
- Group Footer formula =SUM(orders) renders as a single line in Reader. -
Group Footer · tier · width 280 px on paper, full width on phone.
-
- -
-
- - -

3 · Implementation notes & non-goals

- -
-
    -
  • - - Toolbar gains two segmented controls — Designer view (Paper / - Reader / Split) and, when Reader or Split is active, a - Reader-device toggle (Tablet / Phone). State persists per - report in localStorage; default is Split. -
  • -
  • - - Split mode reuses the runtime Reader projection. The reader pane on the - right is the same IReportReaderService output described in - reports-mobile.html, rendered into an iframe-like - container at the chosen device width. Edits on the paper canvas trigger a debounced - preview rebuild. -
  • -
  • - - Selection inspector grows a "Reader view" subsection — Role dropdown - (title / subtitle / field / hidden), custom label override, "show on - phone" toggle, and three quick-action buttons (Title / Field / Hide) for one-click - changes. Stored in ReportControlDefinition.Props["readerHint"] via the - existing PropertyBag — no schema migration. -
  • -
  • - - Controls on the canvas show a tiny role badge (title, - field, hidden) when a Reader hint is set — quick visual - map of what the Reader view will pick up. Toggle-able from a View menu. -
  • -
  • - - "Auto-derive all hints" runs the inference rules on the whole report — first - BoundText in Detail → title; remaining BoundText → fields with BoundFieldName as - the label; Line / Box → hidden; GroupHeader BoundText → section title; GroupFooter - CalculatedText → summary row. Pre-populates everything; users only refine. -
  • -
  • - - Lint panel appears below the designer (or as a collapsible footer). Each - item has a one-click fix. The "3 reader hints needed" pill in the toolbar is the - count summary; clicking it scrolls the lint panel into view. -
  • -
  • - - Page Settings stays paper-only. Reader has no concept of paper size / - margins — it's a continuous list. Margin/orientation controls don't move; they just - don't affect the reader pane. -
  • -
  • - - Non-goal · mobile-friendly designer. The 3-column desktop layout - (220 / flex / 320) and pointer-driven drag-resize are the right tools for building - a paper report. Making the designer touch-friendly would compromise it for a use - case that doesn't really exist (nobody designs a report on a phone). The Reader - preview pane goes mobile-sized, but the surrounding designer chrome stays - desktop-only. -
  • -
  • - - Out of scope — drag-and-drop within the Reader preview (Reader is read-only), - a separate "mobile report definition" (whole point is one definition / two views), - Reader-specific charts (current renderer only supports text / line / box anyway). -
  • -
-
- -

- Pairs with: reports-mobile.html (the runtime Reader view). - Back to all mockups. -

-
- - diff --git a/www/admin-ui-mockups/reports-mobile.html b/www/admin-ui-mockups/reports-mobile.html deleted file mode 100644 index 9dac6e6b..00000000 --- a/www/admin-ui-mockups/reports-mobile.html +++ /dev/null @@ -1,817 +0,0 @@ - - - - -Mobile-friendly Reports — CSharpDB Studio Mockup - - - - - -
- -
CSharpDB.Admin.Reports · UI proposal
-

Mobile-friendly reports — keep paper, add a Reader view

-

- Reports are different from forms: a report is supposed to look like paper. The current - renderer fixes an 816 × 1056 page surface and absolutely-positions every band's controls in - pixels (Pages/Preview.razor, reports.css). On a phone the page - side-scrolls. That's painful to read but right for print fidelity. -

-

- The proposal: do not reflow the paper. Add a Reader view that the same - ReportDefinition produces — bands become sections, repeated detail rows become - cards, group footers become summary lines. Phone visitors get Reader by default, desktop - gets Paper, and the toolbar lets the user swap. Print & Export PDF always use Paper. -

- - -

1 · Preview toolbar gains a View-mode switch

- -
-
-
-
- View -
- - -
-
-
- Page -
- - - - -
-
-
- Print uses Paper -
-
-
- - - -
-
- -
-
-
-

Customer Sales — Q1 2026

-
vw_customer_sales · 2026-04-26 · Letter · Portrait
- -
- IDCustomerOrdersAvg ($)Total ($) -
- -
Tier · Gold
-
1Alice Smith12412.504,950.00
-
4David Smith7389.002,723.00
-
5Eve Smithers22221.004,862.00
-
Subtotal · 41 orders · $12,535.00
- -
Tier · Silver
-
2Bob Lee3102.00306.00
-
7Grace Smith588.00440.00
-
Subtotal · 8 orders · $746.00
- - -
-
tablet preview · Paper view · Letter portrait
-
-
-
- - -
-

Reader-view settings

- -
- Reader enabled -
-
-
- Auto on phone -
-
-
- Group cards - Collapsible -
-
- Card sort - Match Paper order -
- -

Selected control

- -
- Field - customer_name -
-
- Reader role - - - -
-
- Reader label - -
-
- Show on phone -
-
- -

- Stored in ControlDefinition.Props["readerHint"]. Decorative - Line / Box controls auto-hide in Reader. BoundText controls auto-promote: first - in the Detail band → card title, others → labelled fields. -

-
-
- - -

2 · Same report on desktop, tablet, and phone

-

- Desktop & tablet stay on Paper for fidelity. Phone defaults to Reader. -

- -
- -
-
-

Customer Sales — Q1 2026

-
vw_customer_sales · Letter · Portrait
-
- IDCustomerOrdersAvg ($)Total ($) -
-
Tier · Gold
-
1Alice Smith12412.504,950.00
-
4David Smith7389.002,723.00
-
5Eve Smithers22221.004,862.00
-
Subtotal · 41 · $12,535.00
-
Tier · Silver
-
2Bob Lee3102.00306.00
-
7Grace S.588.00440.00
-
Subtotal · 8 · $746.00
-
-
desktop · Paper · ≥ 1024 px
-
- - -
-
-

Customer Sales — Q1 2026

-
Fit-width · 75%
-
- IDCustomerOrdersAvgTotal -
-
Tier · Gold
-
1Alice Smith124124,950
-
4David Smith73892,723
-
5Eve Smithers222214,862
-
Subtotal · $12,535
-
Tier · Silver
-
2Bob Lee3102306
-
7Grace S.588440
-
Subtotal · $746
-
-
tablet · Paper @ Fit-width · ≥ 640 px
-
- - -
-
-
-
-

Customer Sales — Q1 2026

-
vw_customer_sales · 41 rows · 2 groups
-
- -
- Tier · Gold - 3 customers -
- -
-
Alice Smith
-
CUST-00001
-
-
Orders
12
-
Avg
$412.50
-
Total
$4,950.00
-
-
-
-
David Smith
-
CUST-00004
-
-
Orders
7
-
Avg
$389.00
-
Total
$2,723.00
-
-
- -
- Subtotal · 41 orders - $12,535.00 -
- -
- Tier · Silver - 2 customers -
- -
Customer Sales · 1–5 of 41 · scroll for more
-
-
phone · Reader (default) · < 640 px
-
-
- - -

3 · Today vs. Reader on a 380 px phone

- -
- -
-
-
-
-

Customer Sales — Q1 2026

-
vw_customer_sales · 2026-04-26 · Letter
-
- IDCustomerOrdersAvg ($)Total ($) -
-
Tier · Gold
-
1Alice Smith12412.504,950.00
-
4David Smith7389.002,723.00
-
5Eve Smithers22221.004,862.00
-
Subtotal · 41 orders · $12,535.00
-
-
-
- - Page 1 of 4 · pinch to zoom - -
-
TODAY · Paper side-scrolls · text tiny · headers off-screen
-
- - -
-
-
-
-

Customer Sales — Q1 2026

-
vw_customer_sales · 41 rows · 2 groups
-
- -
- Tier · Gold - 3 customers -
- -
-
Alice Smith
-
CUST-00001
-
-
Orders
12
-
Avg
$412.50
-
Total
$4,950.00
-
-
-
-
David Smith
-
CUST-00004
-
-
Orders
7
-
Avg
$389.00
-
Total
$2,723.00
-
-
-
-
Eve Smithers
-
CUST-00005
-
-
Orders
22
-
Avg
$221.00
-
Total
$4,862.00
-
-
- -
- Gold subtotal · 41 - $12,535.00 -
- -
- - -
-
-
READER · readable · cards · footer summary · paper still one tap away
-
-
- - -

4 · Implementation notes (no code changed)

- -
-
    -
  • - - Don't touch Paper. The existing band-based, pixel-positioned layout from - Pages/Preview.razor stays exactly as-is for fidelity. Print and Export - PDF route through Paper unconditionally. -
  • -
  • - - Add a ViewMode selector to the preview toolbar: - PaperReader. Auto-pick on first render based on - viewport (Reader below ~640 px, Paper above) — a single - matchMedia probe in JS interop is enough. User toggle persists per - report in localStorage. -
  • -
  • - - Reader is a derived view, not a second layout. A new - IReportReaderService takes the same ReportPreviewResult - that DefaultReportPreviewService already produces and walks the bands - semantically: -
    ReportHeader / PageHeader → page intro (rendered once). -
    GroupHeader → collapsible <section>. -
    Detail band repeated → list of cards. First - BoundText in the band becomes the card title; others become - <dt>/<dd> pairs labelled with BoundFieldName. -
    GroupFooter → summary row at the end of the section. -
    Line / Box controls → hidden in reader. -
  • -
  • - - Per-control overrides via Props["readerHint"] — - { "role": "title" | "subtitle" | "field" | "hidden", "label": "Customer", - "showOnPhone": true }. Property inspector grows a Reader-view sub-section. - Schema unchanged; this is just data inside the existing PropertyBag. -
  • -
  • - - Auto-reader for legacy reports — the inference rules above mean any existing - ReportDefinition gets a usable Reader view with zero edits. Hints only - exist to refine it. -
  • -
  • - - Paper view on phone gets quality-of-life fixes too — toolbar gains - Fit-width / 100% / 150% / 200% zoom, and a sticky page pager (today the preview - stacks all pages vertically and lets each page surface side-scroll, see - reports.css .report-page-surface { overflow-x: auto; }). -
  • -
  • - - Out of scope: rich charts in Reader (current renderer only supports text / - line / box), interactive drill-down (Reader is read-only), and a separate "mobile - report definition" — the whole point is one definition, two views. -
  • -
-
- -

- Compare with the forms equivalent: forms-mobile.html. Back - to all mockups. -

-
- - diff --git a/www/admin-ui-mockups/sidebar.html b/www/admin-ui-mockups/sidebar.html deleted file mode 100644 index c731a489..00000000 --- a/www/admin-ui-mockups/sidebar.html +++ /dev/null @@ -1,474 +0,0 @@ - - - - -Object Explorer — CSharpDB Studio Mockup - - - - - -
- -
-
- - CSharpDB Studio - — relational.db -
- - -
- Connected - - -
- -
- - -
-
-
Customers
-
-
-
- -

Hover items in the sidebar to see star & "more" actions.

-

Click a chevron next to a table to drill into its columns, indexes, and triggers.

-
-
-
-
- -
- Connected - relational.db - 24 tables · 6 views · 11 procs -
- 2.4 GB / 3.5 GB -
-
- - -
-
Reality check vs. the live screen
-
    -
  • ALREADY EXISTS — Object Explorer header with Collapse-All button, search/filter input, expandable groups (User Tables, System Tables, Forms, Reports, System Catalog, Views, Indexes, Triggers, Procedures), per-item count badges, active-tab highlight, right-click context menus for tables / forms / reports / views / indexes / triggers / procedures, drag-resize handle.
  • -
  • NEW: Pinned section — any item can be starred and persists at the top across sessions. Today there's no concept of pinning.
  • -
  • NEW: Recent section — last few opened objects with relative timestamps.
  • -
  • NEW: Type filter chips (All / Tables / Forms / Reports / More) for quick narrowing.
  • -
  • NEW: Drill-down under each table — a chevron expands columns (with type icons), indexes, and triggers as nested rows. Today indexes/triggers are scattered across separate top-level groups; columns require switching to the Schema view.
  • -
  • RESTYLE: Per-item hover actions — star + "⋯" appear on hover. The same operations are available today but only via right-click, which is undiscoverable for new users.
  • -
  • RESTYLE: Per-group "+" quick-add on hover (New Table, New Form, etc.) — same as the existing right-click "New …" items.
  • -
  • RESTYLE: Compact sort & refresh icons next to Collapse-All in the header. Refresh exists today but only on context menus.
  • -
  • NEW: Match highlighting in filter results (bold "Customers"). Today filter is text-substring match without highlighting.
  • -
-
- - diff --git a/www/admin-ui-mockups/styles.css b/www/admin-ui-mockups/styles.css deleted file mode 100644 index d5707d98..00000000 --- a/www/admin-ui-mockups/styles.css +++ /dev/null @@ -1,355 +0,0 @@ -/* ───────────────────────────────────────────────────────── - Shared design tokens & frame styles for mockups. - Tokens mirror src/CSharpDB.Admin/wwwroot/css/app.css so - the mockups feel like real previews of the admin shell. - ───────────────────────────────────────────────────────── */ - -:root { - /* Tokyo-Night-inspired dark palette (matches the live admin) */ - --bg-primary: #1a1b26; - --bg-secondary: #16171f; - --bg-tertiary: #1f2029; - --bg-elevated: #24253a; - --bg-hover: #292a3e; - --bg-active: #33345a; - --border-color: #2a2b3d; - --border-light: #363750; - --text-primary: #c0caf5; - --text-secondary: #7982a9; - --text-muted: #565f89; - --accent-blue: #7aa2f7; - --accent-cyan: #7dcfff; - --accent-green: #9ece6a; - --accent-yellow: #e0af68; - --accent-orange: #ff9e64; - --accent-red: #f7768e; - --accent-magenta: #bb9af7; - --accent-teal: #2ac3de; - --shadow-popup: 0 16px 48px rgba(0,0,0,0.55); - - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 10px; - - --font-ui: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace; -} - -* { box-sizing: border-box; margin: 0; padding: 0; } - -html, body { height: 100%; } - -body { - font-family: var(--font-ui); - background: var(--bg-primary); - color: var(--text-primary); - font-size: 13px; - line-height: 1.5; - -webkit-font-smoothing: antialiased; -} - -a { color: var(--accent-blue); text-decoration: none; } -a:hover { text-decoration: underline; } - -/* ─── App frame ─── */ -.app { - display: grid; - grid-template-rows: 38px 1fr 26px; - height: 100vh; -} - -.titlebar { - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 12px; - padding: 0 12px; - user-select: none; -} - -.brand { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; -} - -.brand .logo { - width: 24px; height: 24px; - background: linear-gradient(135deg, var(--accent-blue), var(--accent-magenta)); - border-radius: 5px; - display: grid; - place-items: center; - font-size: 12px; - color: white; - font-weight: 700; -} - -.brand .dbname { color: var(--text-muted); font-weight: 400; font-size: 12px; } - -.tb-actions { - display: flex; - align-items: center; - gap: 4px; - padding-left: 12px; - margin-left: 0; - border-left: 1px solid var(--border-color); -} - -.tb-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 11px; - cursor: pointer; - white-space: nowrap; -} - -.tb-btn:hover { color: var(--text-primary); background: var(--bg-hover); } - -.tb-btn.icon-only { - width: 30px; height: 26px; - padding: 0; - justify-content: center; -} - -.spacer { flex: 1; } - -/* Database switcher dropdown trigger */ -.db-switcher { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - color: var(--text-primary); - font-size: 12px; - cursor: pointer; -} - -.db-switcher:hover { border-color: var(--border-light); } - -.db-switcher .bi-caret-down-fill { font-size: 9px; color: var(--text-muted); } - -/* Command palette launcher in titlebar */ -.palette-launcher { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 10px; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - color: var(--text-muted); - font-size: 12px; - min-width: 240px; - cursor: pointer; -} - -.palette-launcher:hover { border-color: var(--border-light); color: var(--text-secondary); } - -.palette-launcher .kbd { margin-left: auto; } - -.titlebar .conn-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 3px 9px; - background: rgba(158,206,106,0.1); - border: 1px solid rgba(158,206,106,0.3); - color: var(--accent-green); - border-radius: 999px; - font-size: 11px; -} - -.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-green); } - -/* ─── Main split ─── */ -.body { - display: grid; - grid-template-columns: 280px 1fr; - overflow: hidden; - min-height: 0; -} - -.sidebar { - background: var(--bg-secondary); - border-right: 1px solid var(--border-color); - overflow: hidden; - display: flex; - flex-direction: column; -} - -.content { - background: var(--bg-primary); - overflow: auto; - display: flex; - flex-direction: column; -} - -/* ─── Tab strip (lightweight) ─── */ -.tabstrip { - display: flex; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - height: 36px; - flex-shrink: 0; -} - -.tabstrip .tab { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 14px; - color: var(--text-muted); - font-size: 12px; - border-right: 1px solid var(--border-color); - border-top: 2px solid transparent; - cursor: pointer; -} - -.tabstrip .tab.active { - color: var(--text-primary); - background: var(--bg-primary); - border-top-color: var(--accent-blue); -} - -.tabstrip .tab .bi-x { color: var(--text-muted); margin-left: 4px; opacity: 0; } -.tabstrip .tab:hover .bi-x { opacity: 0.6; } -.tabstrip .tab.dirty::after { content: ""; width: 6px; height: 6px; background: var(--accent-blue); border-radius: 50%; margin-left: 4px; } - -.tabstrip .tab-new { - width: 32px; - border: none; - background: transparent; - color: var(--text-muted); - cursor: pointer; -} - -.tabstrip .tab-new:hover { color: var(--text-primary); background: var(--bg-hover); } - -/* ─── Status bar ─── */ -.statusbar { - background: var(--bg-secondary); - border-top: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 18px; - padding: 0 12px; - color: var(--text-muted); - font-size: 11px; -} - -.statusbar .pill { - display: inline-flex; - align-items: center; - gap: 5px; -} - -.statusbar .pill.warn { color: var(--accent-yellow); } -.statusbar .pill.ok { color: var(--accent-green); } -.statusbar .pill.action { color: var(--accent-blue); cursor: pointer; } -.statusbar .pill.action:hover { color: var(--accent-cyan); } - -/* ─── Reusable bits ─── */ -.kbd { - display: inline-flex; - align-items: center; - padding: 1px 6px; - font-family: var(--font-mono); - font-size: 10px; - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.08); - border-bottom-color: rgba(255,255,255,0.04); - border-radius: 4px; - color: var(--text-secondary); -} - -.btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-color); - background: var(--bg-tertiary); - color: var(--text-primary); - font-size: 12px; - cursor: pointer; -} - -.btn:hover { background: var(--bg-hover); } - -.btn.primary { background: var(--accent-blue); color: #0a0b13; border-color: var(--accent-blue); font-weight: 600; } -.btn.primary:hover { filter: brightness(1.08); } -.btn.ghost { background: transparent; } - -.icon-table { color: var(--accent-blue); } -.icon-system { color: var(--accent-cyan); } -.icon-view { color: var(--accent-magenta); } -.icon-trigger { color: var(--accent-orange); } -.icon-index { color: var(--accent-green); } -.icon-form { color: var(--accent-magenta); } -.icon-report { color: var(--accent-cyan); } - -/* ─── Annotations panel ─── */ -.notes { - position: fixed; - right: 18px; - bottom: 44px; - width: 320px; - max-height: 60vh; - overflow: auto; - background: var(--bg-elevated); - border: 1px solid var(--border-light); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-popup); - z-index: 50; -} - -.notes header { - padding: 10px 14px; - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 600; - color: var(--text-primary); -} - -.notes header .bi { color: var(--accent-yellow); } - -.notes ul { - list-style: none; - padding: 8px 0; -} - -.notes li { - padding: 8px 14px; - font-size: 12px; - color: var(--text-secondary); - border-bottom: 1px solid var(--border-color); -} - -.notes li:last-child { border-bottom: none; } - -.notes li b { color: var(--text-primary); font-weight: 600; } - -.notes .pin { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent-blue); - margin-right: 6px; - vertical-align: middle; -} From 370796c4de8c55ee2fbfc55fc7e807b4ec458dab Mon Sep 17 00:00:00 2001 From: Maximum Code Date: Sat, 2 May 2026 18:25:04 -0700 Subject: [PATCH 33/39] Add tableless scalar function support --- Directory.Build.props | 6 + Directory.Build.targets | 8 + docs/admin-forms-access-parity/README.md | 3 + .../access-style-functions-and-macros.md | 244 +++ scripts/Start-CSharpDbAdminDirect.ps1 | 114 +- .../Designer/PropertyInspector.razor | 166 +- .../Evaluation/FormulaEvaluator.cs | 1601 +++++++++++++++-- .../Evaluation/FormulaFunctionCatalog.cs | 94 + .../Pages/DataEntry.razor | 194 +- .../Services/FormAutomationMetadata.cs | 4 +- .../wwwroot/css/designer.css | 163 ++ .../Components/Layout/NavMenu.razor | 167 +- .../Components/Tabs/CallbacksTab.razor | 341 +++- .../Components/Tabs/QueryTab.razor | 14 + .../Helpers/QueryPagingSqlBuilder.cs | 8 +- .../Helpers/SqlCompletionProvider.cs | 129 +- .../Services/AdminHostCallbacks.cs | 3 +- .../Services/HostCallbackCatalogService.cs | 46 + .../Services/HostCallbackReadinessService.cs | 7 + src/CSharpDB.Admin/wwwroot/css/app.css | 201 ++- src/CSharpDB.Admin/wwwroot/js/interop.js | 177 ++ src/CSharpDB.Execution/QueryPlanner.cs | 41 +- .../ScalarFunctionEvaluator.cs | 48 +- .../DbBuiltInScalarFunctions.cs | 829 +++++++++ src/CSharpDB.Primitives/DbFunctions.cs | 3 +- src/CSharpDB.Primitives/DbHostCallbacks.cs | 6 +- src/CSharpDB.Sql/Ast.cs | 4 + src/CSharpDB.Sql/Parser.cs | 19 +- .../Admin/HostCallbackCatalogServiceTests.cs | 1 + .../Shared/SqlCompletionProviderTests.cs | 41 + .../Evaluation/FormulaEvaluatorTests.cs | 189 ++ .../Helpers/QueryPagingSqlBuilderTests.cs | 65 + tests/CSharpDB.Api.Tests/ProcedureApiTests.cs | 10 +- .../ClientDirectDatabaseOptionsTests.cs | 44 + tests/CSharpDB.Tests/ClientProcedureTests.cs | 10 +- .../CSharpDB.Tests/ClientSqlExecutionTests.cs | 65 + tests/CSharpDB.Tests/ParserTests.cs | 14 + 37 files changed, 4850 insertions(+), 229 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Build.targets create mode 100644 docs/admin-forms-access-parity/access-style-functions-and-macros.md create mode 100644 src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs create mode 100644 src/CSharpDB.Primitives/DbBuiltInScalarFunctions.cs create mode 100644 tests/CSharpDB.Admin.Forms.Tests/Helpers/QueryPagingSqlBuilderTests.cs diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..8d072b1f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + + $(DefaultItemExcludes);artifacts/**;**/artifacts/**;.tmp/**;**/.tmp/**;tmp/**;**/tmp/**;temp/**;**/temp/**;publish/**;**/publish/** + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..cacfd2c6 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + diff --git a/docs/admin-forms-access-parity/README.md b/docs/admin-forms-access-parity/README.md index 2ae943cf..f04845df 100644 --- a/docs/admin-forms-access-parity/README.md +++ b/docs/admin-forms-access-parity/README.md @@ -145,3 +145,6 @@ Host-owned validation callbacks can be registered for field-level and form-level save checks. See [Trusted Validation Rules](../trusted-csharp-functions/validation-rules.md) for registration, designer metadata, policy, diagnostics, and sample code. + +For Access-style expression functions and macro/action candidates, see +[Access-Style Functions and Macros](access-style-functions-and-macros.md). diff --git a/docs/admin-forms-access-parity/access-style-functions-and-macros.md b/docs/admin-forms-access-parity/access-style-functions-and-macros.md new file mode 100644 index 00000000..32f29b80 --- /dev/null +++ b/docs/admin-forms-access-parity/access-style-functions-and-macros.md @@ -0,0 +1,244 @@ +# Access-Style Functions and Macros + +This note captures the Access-parity function and macro set we want available +in Admin Forms. The goal is not to clone every Access/VBA surface area at once. +The goal is to include the small, familiar built-in set that makes form +expressions, validation, conditional formatting, buttons, and simple workflows +feel productive without requiring host code for every common task. + +## Design Direction + +- Keep simple expression functions built into the formula engine. +- Keep user-facing form actions as declarative Admin Form actions where possible. +- Route dangerous or host-specific actions through trusted callbacks, policy, and + diagnostics. +- Treat database-owned C# code modules as the later `RunCode` target. +- Preserve the existing saved form wire shape by storing action and expression + settings in metadata/property bags. + +Implementation note: Admin Forms formulas now include the expression functions +listed below as built-ins. Macro/action commands remain roadmap items unless +already covered by the existing form action model. + +## Included Expression Functions + +### Null and Conditional + +These should be first because they appear constantly in form defaults, +calculated controls, validation messages, and visibility/enabled rules. + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Nz(value, fallback)` | Replace null/empty values with a fallback. | V1 | +| `IsNull(value)` | Test for null. | V1 | +| `IsEmpty(value)` | Test for empty/unset values where applicable. | V1 | +| `IIf(condition, trueValue, falseValue)` | Inline conditional. | V1 | +| `Switch(condition1, value1, ...)` | Multi-branch conditional. | V2 | +| `Choose(index, value1, value2, ...)` | Pick from positional values. | V2 | + +### Text + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Len(value)` | Text length. | V1 | +| `Left(value, count)` | Left substring. | V1 | +| `Right(value, count)` | Right substring. | V1 | +| `Mid(value, start, count)` | Middle substring. | V1 | +| `Trim(value)` | Trim both ends. | V1 | +| `LTrim(value)` | Trim left. | V2 | +| `RTrim(value)` | Trim right. | V2 | +| `UCase(value)` | Uppercase text. | V1 | +| `LCase(value)` | Lowercase text. | V1 | +| `InStr(value, search)` | Find substring position. | V1 | +| `Replace(value, search, replacement)` | Replace text. | V1 | +| `StrComp(left, right, comparison)` | Compare strings. | V2 | +| `Val(value)` | Parse leading numeric text. | V2 | + +### Date and Time + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Date()` | Current date. | V1 | +| `Time()` | Current time. | V1 | +| `Now()` | Current date/time. | V1 | +| `Year(value)` | Extract year. | V1 | +| `Month(value)` | Extract month number. | V1 | +| `Day(value)` | Extract day of month. | V1 | +| `Hour(value)` | Extract hour. | V2 | +| `Minute(value)` | Extract minute. | V2 | +| `Second(value)` | Extract second. | V2 | +| `DateAdd(interval, amount, value)` | Add date/time interval. | V1 | +| `DateDiff(interval, start, end)` | Difference between dates. | V1 | +| `DatePart(interval, value)` | Extract date/time part. | V2 | +| `DateSerial(year, month, day)` | Construct date. | V2 | +| `TimeSerial(hour, minute, second)` | Construct time. | V2 | +| `Weekday(value)` | Day of week number. | V2 | +| `MonthName(month)` | Month display name. | V2 | + +### Number and Conversion + +| Function | Purpose | Priority | +| --- | --- | --- | +| `Abs(value)` | Absolute value. | V1 | +| `Round(value, digits)` | Round number. | V1 | +| `Int(value)` | Floor-like integer conversion. | V1 | +| `Fix(value)` | Truncate toward zero. | V2 | +| `Sgn(value)` | Sign of number. | V2 | +| `CStr(value)` | Convert to string. | V1 | +| `CInt(value)` | Convert to integer. | V1 | +| `CLng(value)` | Convert to long integer. | V2 | +| `CDbl(value)` | Convert to double. | V1 | +| `CBool(value)` | Convert to boolean. | V1 | +| `CDate(value)` | Convert to date/time. | V1 | +| `Format(value, format)` | Format date/number/text. | V2 | + +### Domain Aggregates + +Domain aggregate functions are important for Access familiarity, but they need a +careful implementation because they read other rows/tables during form +evaluation. + +| Function | Purpose | Priority | +| --- | --- | --- | +| `DLookup(expr, domain, criteria)` | Read one value from a table/query. | V2 | +| `DCount(expr, domain, criteria)` | Count matching rows. | V2 | +| `DSum(expr, domain, criteria)` | Sum matching rows. | V2 | +| `DAvg(expr, domain, criteria)` | Average matching rows. | V2 | +| `DMin(expr, domain, criteria)` | Minimum matching value. | V2 | +| `DMax(expr, domain, criteria)` | Maximum matching value. | V2 | + +V2 should enforce query/table access through the same callback and diagnostics +boundary used for trusted extensions where relevant. These functions should also +have row limits and clear error handling so formula evaluation cannot become an +unbounded database workload. + +## Included Macro and Action Commands + +### Record Actions + +These map cleanly to existing form runtime behavior and should remain +declarative actions rather than host callbacks. + +| Action | Purpose | Priority | +| --- | --- | --- | +| `NewRecord` | Start a new record. | V1 | +| `SaveRecord` | Save the current record. | V1 | +| `DeleteRecord` | Delete the current record. | V1 | +| `UndoRecord` | Revert unsaved edits. | V1 | +| `RefreshRecord` | Reload the current record. | V1 | +| `Requery` | Reload the form record source. | V1 | +| `GoToRecord` | Navigate to a specific record. | V1 | +| `FindRecord` | Search/navigate by criteria. | V2 | +| `NextRecord` | Navigate forward. | V1 | +| `PreviousRecord` | Navigate backward. | V1 | + +### Form, Window, and Report Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `OpenForm` | Open another saved form. | V1 | +| `CloseForm` | Close current or named form. | V1 | +| `OpenReport` | Open a saved report. | V2 | +| `CloseReport` | Close a report surface. | V2 | +| `PreviewReport` | Open report preview. | V2 | +| `PrintReport` | Print or export through report pipeline. | V2 | + +### Filter and Sort Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `ApplyFilter` | Apply a form filter. | V1 | +| `ClearFilter` | Clear current filter. | V1 | +| `SetOrderBy` | Apply sort order. | V1 | +| `ClearOrderBy` | Clear sort order. | V1 | +| `SearchRecords` | Search over configured searchable fields. | V2 | + +### UI and Control Actions + +These are needed for Access-style command buttons and conditional workflows. + +| Action | Purpose | Priority | +| --- | --- | --- | +| `SetValue` | Set a field/control value. | V1 | +| `SetProperty` | Set visible/enabled/read-only/text/style properties. | V1 | +| `SetFocus` | Move focus to a control. | V1 | +| `EnableControl` | Set enabled state. | V1 | +| `DisableControl` | Clear enabled state. | V1 | +| `ShowControl` | Set visible state. | V1 | +| `HideControl` | Clear visible state. | V1 | +| `LockControl` | Set read-only state. | V1 | +| `UnlockControl` | Clear read-only state. | V1 | +| `MsgBox` | Show a message dialog. | V1 | +| `InputBox` | Prompt for a value. | V2 | + +### Flow Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `If` / `Else` | Conditional action branching. | V1 | +| `StopMacro` | Stop the current action sequence. | V1 | +| `RunMacro` | Run a named action sequence. | V1 | +| `RunActionSequence` | Existing explicit reusable sequence action. | V1 | +| `OnError` | Configure failure handling. | V2 | + +### Data and Query Actions + +These must be gated carefully because they can mutate data outside the current +form record. + +| Action | Purpose | Priority | +| --- | --- | --- | +| `OpenQuery` | Open a saved query result. | V2 | +| `RunQuery` | Execute a saved query. | V2 trusted | +| `RunProcedure` | Execute a saved procedure. | V2 trusted | +| `RunSQL` | Execute SQL text. | Later trusted | +| `ExportData` | Export records. | Later trusted | +| `ImportData` | Import records. | Later trusted | + +### Code Bridge Actions + +| Action | Purpose | Priority | +| --- | --- | --- | +| `RunCommand` | Invoke host-registered trusted command callback. | Existing | +| `RunCode` | Invoke database-owned C# code module function. | Later | + +`RunCode` should wait for the database code modules work. It should compile and +execute database-owned C# through a trusted build/runtime model, not arbitrary +source text embedded directly inside form JSON. + +### Temp and Session Variables + +| Action | Purpose | Priority | +| --- | --- | --- | +| `SetTempVar` | Store a session-scoped value. | V2 | +| `RemoveTempVar` | Remove one session value. | V2 | +| `RemoveAllTempVars` | Clear session values. | V2 | + +Temp variables should be scoped to the current user/session and be available to +form expressions, filters, and action sequences. + +## Recommended Implementation Order + +1. Add core formula functions: `Nz`, `IIf`, `Date`, `Now`, common text helpers, + numeric helpers, and conversions. +2. Add declarative form actions: `MsgBox`, `SetValue`, `SetProperty`, + `SetFocus`, `ApplyFilter`, `ClearFilter`, `Requery`, `OpenForm`, and + `CloseForm`. +3. Add domain aggregates with clear access checks, row limits, and diagnostics. +4. Add temp/session variables and make them visible to expressions and actions. +5. Add `RunCode` after database code modules exist. +6. Add import/export/file/app-launch style actions only as trusted operations. + +## Notes on Access Compatibility + +Microsoft Access exposes many functions and macro commands. CSharpDB should use +the familiar names where it makes sense, but implementation should follow the +CSharpDB security and diagnostics model. Anything that can call host code, +modify unrelated data, access files, or run arbitrary SQL should go through an +explicit trusted boundary. + +Useful Microsoft references: + +- [Access functions by category](https://support.microsoft.com/en-us/office/access-functions-by-category-b8b136c3-2716-4d39-94a2-658ce330ed83) +- [Introduction to macros](https://support.microsoft.com/en-gb/office/introduction-to-macros-a39c2a26-e745-4957-8d06-89e0b435aac3) +- [Access macro commands](https://learn.microsoft.com/en-ie/office/client-developer/access/desktop-database-reference/macro-commands) diff --git a/scripts/Start-CSharpDbAdminDirect.ps1 b/scripts/Start-CSharpDbAdminDirect.ps1 index 26ecc76a..7f5b63c9 100644 --- a/scripts/Start-CSharpDbAdminDirect.ps1 +++ b/scripts/Start-CSharpDbAdminDirect.ps1 @@ -6,11 +6,12 @@ Configures the admin site for direct mode, then starts only the admin host. This script is intended for local development and manual operator workflows. It updates `src/CSharpDB.Admin/appsettings.json` to use `CSharpDbTransport.Direct`, removes any daemon endpoint from the admin config, -ensures a connection string exists, and then starts `CSharpDB.Admin`. +ensures a connection string exists, builds `CSharpDB.Admin`, and then starts it. -The script does not install a Windows service or a background task. It launches -one `dotnet run` process. If you close the shell that launched this script, the -child process continues running until you stop it explicitly. +The script does not install a Windows service or a background task. It builds +the admin project first, then launches one `dotnet run --no-build` process. If +you close the shell that launched this script, the child process continues +running until you stop it explicitly. .PARAMETER NoLaunch Only updates the admin configuration. Does not start the admin host. @@ -26,7 +27,8 @@ later with `Stop-Process`. Overrides the admin database connection string before launch. .PARAMETER AdminStartupTimeoutSeconds -How long to wait for the admin endpoint to start accepting TCP connections. +How long to wait for the admin endpoint to start accepting TCP connections +after the admin project has built and the app process has launched. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\scripts\Start-CSharpDbAdminDirect.ps1 @@ -52,7 +54,7 @@ param( [switch]$OpenAdmin, [switch]$PassThru, [string]$ConnectionString, - [int]$AdminStartupTimeoutSeconds = 30 + [int]$AdminStartupTimeoutSeconds = 60 ) $ErrorActionPreference = 'Stop' @@ -182,6 +184,47 @@ function Select-PreferredUrl { return $urls[0] } +function Get-LaunchUris { + param( + [Parameter(Mandatory = $true)] + [string]$ApplicationUrl + ) + + return @( + $ApplicationUrl.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries) | + ForEach-Object { $_.Trim() } | + Where-Object { $_ } | + ForEach-Object { [Uri]$_ } + ) +} + +function Test-TcpEndpoint { + param( + [Parameter(Mandatory = $true)] + [Uri]$Uri + ) + + $client = $null + + try { + $client = [System.Net.Sockets.TcpClient]::new() + $connectTask = $client.ConnectAsync($Uri.Host, $Uri.Port) + + if ($connectTask.Wait([TimeSpan]::FromSeconds(1)) -and $client.Connected) { + return $true + } + } + catch { + } + finally { + if ($null -ne $client) { + $client.Dispose() + } + } + + return $false +} + function Wait-ForTcpEndpoint { param( [Parameter(Mandatory = $true)] @@ -193,28 +236,45 @@ function Wait-ForTcpEndpoint { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { - $client = $null + if (Test-TcpEndpoint -Uri $Uri) { + return $true + } + + Start-Sleep -Milliseconds 500 + } + + return $false +} + +function Wait-ForAnyTcpEndpoint { + param( + [Parameter(Mandatory = $true)] + [Uri[]]$Uris, + [Parameter(Mandatory = $true)] + [int]$TimeoutSeconds, + [System.Diagnostics.Process]$Process + ) - try { - $client = [System.Net.Sockets.TcpClient]::new() - $connectTask = $client.ConnectAsync($Uri.Host, $Uri.Port) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - if ($connectTask.Wait([TimeSpan]::FromSeconds(1)) -and $client.Connected) { - return $true + while ((Get-Date) -lt $deadline) { + if ($null -ne $Process) { + $Process.Refresh() + if ($Process.HasExited) { + return $null } } - catch { - } - finally { - if ($null -ne $client) { - $client.Dispose() + + foreach ($uri in $Uris) { + if (Test-TcpEndpoint -Uri $uri) { + return $uri } } Start-Sleep -Milliseconds 500 } - return $false + return $null } function Stop-ProcessIfRunning { @@ -242,6 +302,7 @@ $originalAdminJson = $adminConfig | ConvertTo-Json -Depth 20 $adminLaunchProfile = Get-LaunchProfile -LaunchSettings $adminLaunchSettings -PreferredProfileName 'CSharpDB.Admin' $adminUrl = Select-PreferredUrl -ApplicationUrl $adminLaunchProfile.Profile.applicationUrl -PreferredScheme 'https' +$adminUris = @(Get-LaunchUris -ApplicationUrl $adminLaunchProfile.Profile.applicationUrl) $csharpDbSection = Get-OrAddProperty -Object $adminConfig -Name 'CSharpDB' -DefaultValue ([pscustomobject]@{}) Set-JsonProperty -Object $csharpDbSection -Name 'Transport' -Value 'direct' @@ -291,19 +352,30 @@ if ($NoLaunch) { $adminArgs = @( 'run', + '--no-build', '--project', $adminProjectPath, '--launch-profile', $adminLaunchProfile.Name ) +Write-Host "Building admin project..." +dotnet build $adminProjectPath | Out-Host +if ($LASTEXITCODE -ne 0) { + throw "The admin project build failed with exit code $LASTEXITCODE." +} + Write-Host "Starting admin profile '$($adminLaunchProfile.Name)'..." $adminProcess = Start-Process -FilePath 'dotnet' -ArgumentList $adminArgs -WorkingDirectory $repoRoot -PassThru -if ($adminUrl) { - if (-not (Wait-ForTcpEndpoint -Uri ([Uri]$adminUrl) -TimeoutSeconds $AdminStartupTimeoutSeconds)) { +if ($adminUris.Count -gt 0) { + $listeningUri = Wait-ForAnyTcpEndpoint -Uris $adminUris -TimeoutSeconds $AdminStartupTimeoutSeconds -Process $adminProcess + if ($null -eq $listeningUri) { Stop-ProcessIfRunning -Process $adminProcess - throw "The admin site did not start listening on $adminUrl within $AdminStartupTimeoutSeconds seconds." + + $configuredUrls = ($adminUris | ForEach-Object { $_.ToString().TrimEnd('/') }) -join ', ' + throw "The admin site did not start listening on any configured URL ($configuredUrls) within $AdminStartupTimeoutSeconds seconds." } + $adminUrl = $listeningUri.ToString().TrimEnd('/') Write-Host "Admin is listening on $adminUrl (PID $($adminProcess.Id))." if ($OpenAdmin) { diff --git a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor index d37beba7..ee78c33d 100644 --- a/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor +++ b/src/CSharpDB.Admin.Forms/Components/Designer/PropertyInspector.razor @@ -414,19 +414,84 @@
- +
+ + +
+ @if (_showFormulaHelper) + { +
+
+ + +
+ @if (_sourceTableDef?.Fields.Count > 0) + { +
+ @foreach (FormFieldDefinition field in _sourceTableDef.Fields.Where(static field => field.DataType != FieldDataType.Blob).Take(12)) + { + + } +
+ } + @foreach (FormulaFunctionGroup group in GetFormulaFunctionGroups()) + { +
+
@group.Category
+ @foreach (FormulaFunctionDescriptor function in group.Functions) + { +
+ + +
+ } +
+ } + @if (GetFormulaFunctionGroups().Count == 0) + { +
No matching functions.
+ } +
+ }
-
- Field: =A * B + C
- Aggregate: =SUM(Table.Field) +
+ Examples: =Nz(Quantity, 0) * Nz(UnitPrice, 0)
+ =DateDiff('d', OrderDate, Date())
} @@ -955,6 +1020,8 @@ private enum RectPart { X, Y, W, H } + private sealed record FormulaFunctionGroup(string Category, IReadOnlyList Functions); + // DataGrid configuration state private IReadOnlyList? _availableTables; private IReadOnlyList? _availableForms; @@ -976,6 +1043,9 @@ private string? _loadedTabPagesControlId; private string _tabPagesText = string.Empty; private string? _tabPagesError; + private bool _showFormulaHelper; + private string _formulaFunctionQuery = string.Empty; + private string _formulaFunctionCategory = string.Empty; private IReadOnlyList RegisteredCommands => CommandRegistry.Commands.ToList(); @@ -1063,6 +1133,13 @@ _tabPagesError = null; } + if (_selected?.ControlType != "computed") + { + _showFormulaHelper = false; + _formulaFunctionQuery = string.Empty; + _formulaFunctionCategory = string.Empty; + } + await InvokeAsync(StateHasChanged); } @@ -1156,6 +1233,83 @@ State.UpdateControlProp(_selected.ControlId, key, value); } + private void ToggleFormulaHelper() + { + _showFormulaHelper = !_showFormulaHelper; + } + + private void OnFormulaCategoryChanged(ChangeEventArgs e) + { + _formulaFunctionCategory = e.Value?.ToString() ?? string.Empty; + } + + private void OnFormulaQueryChanged(ChangeEventArgs e) + { + _formulaFunctionQuery = e.Value?.ToString() ?? string.Empty; + } + + private IReadOnlyList GetFormulaFunctionGroups() + { + IEnumerable functions = FormulaFunctionCatalog.AllFunctions; + if (!string.IsNullOrWhiteSpace(_formulaFunctionCategory)) + { + functions = functions.Where(function => + string.Equals(function.Category, _formulaFunctionCategory, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.IsNullOrWhiteSpace(_formulaFunctionQuery)) + { + string query = _formulaFunctionQuery.Trim(); + functions = functions.Where(function => FormulaFunctionMatches(function, query)); + } + + return functions + .GroupBy(static function => function.Category, StringComparer.OrdinalIgnoreCase) + .Select(static group => new FormulaFunctionGroup(group.Key, group.ToArray())) + .ToArray(); + } + + private static bool FormulaFunctionMatches(FormulaFunctionDescriptor function, string query) + => function.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Signature.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Description.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Example.Contains(query, StringComparison.OrdinalIgnoreCase); + + private void InsertFormulaFunction(FormulaFunctionDescriptor function) + { + AppendFormulaText(function.InsertText); + } + + private void UseFormulaExample(FormulaFunctionDescriptor function) + { + OnPropChanged(PropFormula, function.Example); + } + + private void InsertFormulaField(string fieldName) + { + if (string.IsNullOrWhiteSpace(fieldName)) + return; + + AppendFormulaText($"[{fieldName}]"); + } + + private void AppendFormulaText(string text) + { + if (_selected is null || string.IsNullOrWhiteSpace(text)) + return; + + string current = GetProp(PropFormula, string.Empty).TrimEnd(); + string updated = current switch + { + "" => $"={text}", + "=" => $"={text}", + _ when current.StartsWith('=') => $"{current} {text}", + _ => $"={current} {text}", + }; + + OnPropChanged(PropFormula, updated); + } + private string GetAnchorPreset() { var anchors = GetCurrentAnchors(); diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs index 09d41cd1..bb94c79e 100644 --- a/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaEvaluator.cs @@ -1,23 +1,37 @@ +using System.Globalization; +using System.Text.Json; using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Evaluation; +public sealed record FormulaDomainFunctionRequest( + string FunctionName, + string Expression, + string Domain, + string? Criteria); + +public delegate object? FormulaDomainFunctionResolver(FormulaDomainFunctionRequest request); + /// -/// Evaluates formulas for computed fields. +/// Evaluates Admin Forms formulas. /// -/// Field formulas start with '=' and support arithmetic (+, -, *, /), parentheses, -/// numeric literals, and field references (e.g., =Quantity * UnitPrice). -/// -/// Aggregate formulas reference child table fields: =SUM(OrderItems.LineTotal), -/// =COUNT(OrderItems.LineTotal), =AVG(...), =MIN(...), =MAX(...) +/// Formulas start with '=' and support arithmetic, comparisons, logical +/// operators, field references, registered scalar callbacks, and Access-style +/// built-in functions. /// public static class FormulaEvaluator { private static readonly string[] AggregateFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + private static readonly string[] DomainFunctionNames = ["DLOOKUP", "DCOUNT", "DSUM", "DAVG", "DMIN", "DMAX"]; + private static readonly HashSet BuiltInFunctionNameSet = + new(FormulaFunctionCatalog.BuiltInFunctionNames, StringComparer.OrdinalIgnoreCase); + + public static IReadOnlyCollection BuiltInFunctionNames => FormulaFunctionCatalog.BuiltInFunctionNames; /// - /// Evaluate a field-level formula (e.g., =Quantity * UnitPrice). - /// Returns null if any referenced field is null, formula is invalid, or division by zero. + /// Evaluate a field-level formula as a number. + /// Returns null if the formula is invalid, returns a nonnumeric value, or + /// hits an invalid numeric operation. /// public static double? Evaluate(string? formula, Func fieldResolver) => Evaluate(formula, fieldResolver, DbFunctionRegistry.Empty); @@ -34,21 +48,53 @@ public static class FormulaEvaluator DbFunctionRegistry? functions, DbExtensionPolicy? callbackPolicy) { - if (string.IsNullOrWhiteSpace(formula)) return null; + object? value = EvaluateValue( + formula, + field => fieldResolver(field), + functions, + callbackPolicy); + + return TryConvertDouble(value, out double numeric) ? numeric : null; + } + + public static object? EvaluateValue(string? formula, Func fieldResolver) + => EvaluateValue(formula, fieldResolver, DbFunctionRegistry.Empty); + + public static object? EvaluateValue( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions) + => EvaluateValue(formula, fieldResolver, functions, callbackPolicy: null); + + public static object? EvaluateValue( + string? formula, + Func fieldResolver, + DbFunctionRegistry? functions, + DbExtensionPolicy? callbackPolicy, + FormulaDomainFunctionResolver? domainResolver = null) + { + if (string.IsNullOrWhiteSpace(formula)) + return null; + + string expr = formula.Trim(); + if (!expr.StartsWith('=')) + return null; - var expr = formula.Trim(); - if (!expr.StartsWith('=')) return null; expr = expr[1..].Trim(); - if (expr.Length == 0) return null; + if (expr.Length == 0) + return null; try { - var parser = new Parser(expr, fieldResolver, functions ?? DbFunctionRegistry.Empty, callbackPolicy); - var result = parser.ParseExpression(); - // Ensure we consumed all input - if (parser.Position < parser.Input.Length) - return null; - return result; + var parser = new Parser( + expr, + fieldResolver, + functions ?? DbFunctionRegistry.Empty, + callbackPolicy, + domainResolver); + object? result = parser.ParseExpression(); + parser.SkipWhitespace(); + return parser.Failed || parser.Position < parser.Input.Length ? null : NormalizeValue(result); } catch { @@ -56,6 +102,74 @@ public static class FormulaEvaluator } } + public static bool IsBuiltInFunctionName(string name) + => BuiltInFunctionNameSet.Contains(name); + + public static IReadOnlyList GetDomainReferences(string? formula) + { + string? expression = GetExpressionBody(formula); + if (expression is null) + return []; + + var domains = new HashSet(StringComparer.OrdinalIgnoreCase); + ReadOnlySpan input = expression.AsSpan(); + for (int i = 0; i < input.Length; i++) + { + char current = input[i]; + if (current is '\'' or '"') + { + i = SkipQuoted(input, i, current); + continue; + } + + if (current == '[') + { + i = SkipBracketed(input, i); + continue; + } + + if (!IsIdentifierStart(current)) + continue; + + int start = i; + i++; + while (i < input.Length && IsIdentifierPart(input[i])) + i++; + + string name = input[start..i].ToString(); + if (!DomainFunctionNames.Contains(name, StringComparer.OrdinalIgnoreCase)) + { + i--; + continue; + } + + int cursor = i; + while (cursor < input.Length && char.IsWhiteSpace(input[cursor])) + cursor++; + + if (cursor >= input.Length || input[cursor] != '(') + { + i--; + continue; + } + + if (TryReadFunctionArguments(input, cursor, out string argumentsText, out int closeParen)) + { + string[] arguments = SplitTopLevelArguments(argumentsText); + if (arguments.Length >= 2 && TryReadLiteralText(arguments[1], out string? domain) && !string.IsNullOrWhiteSpace(domain)) + domains.Add(domain); + + i = closeParen; + } + else + { + i--; + } + } + + return domains.OrderBy(static domain => domain, StringComparer.OrdinalIgnoreCase).ToArray(); + } + /// /// Try to parse an aggregate formula like =SUM(TableName.FieldName). /// Returns true if the formula matches the aggregate pattern. @@ -69,7 +183,6 @@ public static bool TryParseAggregate(string? formula, out string func, out strin if (!expr.StartsWith('=')) return false; expr = expr[1..].Trim(); - // Match: FUNC(Table.Field) foreach (var fn in AggregateFunctions) { if (!expr.StartsWith(fn, StringComparison.OrdinalIgnoreCase)) continue; @@ -97,7 +210,7 @@ public static bool TryParseAggregate(string? formula, out string func, out strin /// /// Evaluate an aggregate function over a set of values. - /// Returns null if values is empty (except COUNT which returns 0). + /// Returns null if values is empty (except COUNT and SUM which return 0). /// public static double? EvaluateAggregate(string func, IEnumerable values) { @@ -114,163 +227,354 @@ public static bool TryParseAggregate(string? formula, out string func, out strin }; } + private static string? GetExpressionBody(string? formula) + { + if (string.IsNullOrWhiteSpace(formula)) + return null; + + string expression = formula.Trim(); + if (!expression.StartsWith('=')) + return null; + + expression = expression[1..].Trim(); + return expression.Length == 0 ? null : expression; + } + private static bool IsValidIdentifier(string s) => s.Length > 0 && (char.IsLetter(s[0]) || s[0] == '_') && s.All(c => char.IsLetterOrDigit(c) || c == '_'); - /// - /// Recursive-descent parser for arithmetic expressions with field references. - /// Grammar: - /// Expression = Term (('+' | '-') Term)* - /// Term = Factor (('*' | '/') Factor)* - /// Factor = ['-'] Atom - /// Atom = Number | '(' Expression ')' | FieldName - /// private ref struct Parser { public ReadOnlySpan Input; public int Position; - private readonly Func _fieldResolver; + public bool Failed; + + private readonly Func _fieldResolver; private readonly DbFunctionRegistry _functions; private readonly DbExtensionPolicy? _callbackPolicy; + private readonly FormulaDomainFunctionResolver? _domainResolver; public Parser( string input, - Func fieldResolver, + Func fieldResolver, DbFunctionRegistry functions, - DbExtensionPolicy? callbackPolicy) + DbExtensionPolicy? callbackPolicy, + FormulaDomainFunctionResolver? domainResolver) { Input = input.AsSpan(); Position = 0; + Failed = false; _fieldResolver = fieldResolver; _functions = functions; _callbackPolicy = callbackPolicy; + _domainResolver = domainResolver; } - public double? ParseExpression() + public object? ParseExpression() => ParseOr(); + + private object? ParseOr() { - var left = ParseTerm(); - while (Position < Input.Length) + object? left = ParseAnd(); + while (!Failed) + { + if (!MatchKeyword("OR")) + return left; + + object? right = ParseAnd(); + left = IsTruthy(left) || IsTruthy(right); + } + + return null; + } + + private object? ParseAnd() + { + object? left = ParseNot(); + while (!Failed) + { + if (!MatchKeyword("AND")) + return left; + + object? right = ParseNot(); + left = IsTruthy(left) && IsTruthy(right); + } + + return null; + } + + private object? ParseNot() + { + if (MatchKeyword("NOT")) + return !IsTruthy(ParseNot()); + + return ParseComparison(); + } + + private object? ParseComparison() + { + object? left = ParseAdditive(); + SkipWhitespace(); + string? op = MatchComparisonOperator(); + if (op is null) + return left; + + object? right = ParseAdditive(); + return Compare(left, right, op); + } + + private object? ParseAdditive() + { + object? left = ParseTerm(); + while (!Failed) { SkipWhitespace(); - if (Position >= Input.Length) break; - var ch = Input[Position]; - if (ch == '+') + if (Position >= Input.Length) + return left; + + char ch = Input[Position]; + if (ch == '+' || ch == '-') { Position++; - var right = ParseTerm(); - if (left is null || right is null) return null; - left = left.Value + right.Value; + object? right = ParseTerm(); + if (left is null || right is null) + { + left = null; + } + else if (TryConvertDouble(left, out double leftNumber) && TryConvertDouble(right, out double rightNumber)) + { + left = ch == '+' ? leftNumber + rightNumber : leftNumber - rightNumber; + } + else if (ch == '+') + { + left = ToFormulaString(left) + ToFormulaString(right); + } + else + { + Failed = true; + return null; + } + + continue; } - else if (ch == '-') + + if (ch == '&') { Position++; - var right = ParseTerm(); - if (left is null || right is null) return null; - left = left.Value - right.Value; - } - else - { - break; + object? right = ParseTerm(); + left = ToFormulaString(left) + ToFormulaString(right); + continue; } + + return left; } - return left; + + return null; } - private double? ParseTerm() + private object? ParseTerm() { - var left = ParseFactor(); - while (Position < Input.Length) + object? left = ParseFactor(); + while (!Failed) { SkipWhitespace(); - if (Position >= Input.Length) break; - var ch = Input[Position]; - if (ch == '*') + if (Position >= Input.Length) + return left; + + char ch = Input[Position]; + if (ch != '*' && ch != '/') + return left; + + Position++; + object? right = ParseFactor(); + if (left is null || right is null) { - Position++; - var right = ParseFactor(); - if (left is null || right is null) return null; - left = left.Value * right.Value; + left = null; + continue; } - else if (ch == '/') + + if (!TryConvertDouble(left, out double leftNumber) || !TryConvertDouble(right, out double rightNumber)) { - Position++; - var right = ParseFactor(); - if (left is null || right is null) return null; - if (right.Value == 0) return null; // Division by zero → null - left = left.Value / right.Value; + Failed = true; + return null; } - else + + if (ch == '/' && Math.Abs(rightNumber) < double.Epsilon) { - break; + left = null; + continue; } + + left = ch == '*' ? leftNumber * rightNumber : leftNumber / rightNumber; } - return left; + + return null; } - private double? ParseFactor() + private object? ParseFactor() { SkipWhitespace(); if (Position < Input.Length && Input[Position] == '-') { Position++; - var val = ParseAtom(); - return val.HasValue ? -val.Value : null; + object? value = ParseFactor(); + return TryConvertDouble(value, out double numeric) ? -numeric : null; + } + + if (Position < Input.Length && Input[Position] == '+') + { + Position++; + object? value = ParseFactor(); + return TryConvertDouble(value, out double numeric) ? numeric : null; } + return ParseAtom(); } - private double? ParseAtom() + private object? ParseAtom() { SkipWhitespace(); - if (Position >= Input.Length) return null; - - var ch = Input[Position]; + if (Position >= Input.Length) + { + Failed = true; + return null; + } - // Parenthesized expression + char ch = Input[Position]; if (ch == '(') { Position++; - var val = ParseExpression(); + object? value = ParseExpression(); SkipWhitespace(); if (Position < Input.Length && Input[Position] == ')') + { Position++; - else - return null; // Missing closing paren - return val; + return value; + } + + Failed = true; + return null; } - // Number literal + if (ch is '\'' or '"') + return ParseString(ch); + + if (ch == '[') + return ParseBracketedField(); + if (char.IsDigit(ch) || ch == '.') + return ParseNumber(); + + if (IsIdentifierStart(ch)) { - var start = Position; - while (Position < Input.Length && (char.IsDigit(Input[Position]) || Input[Position] == '.')) - Position++; - var numStr = Input[start..Position].ToString(); - return double.TryParse(numStr, System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, out var num) ? num : null; + string identifier = ParseIdentifier(); + if (string.Equals(identifier, "NULL", StringComparison.OrdinalIgnoreCase)) + return null; + if (string.Equals(identifier, "TRUE", StringComparison.OrdinalIgnoreCase)) + return true; + if (string.Equals(identifier, "FALSE", StringComparison.OrdinalIgnoreCase)) + return false; + + SkipWhitespace(); + if (Position < Input.Length && Input[Position] == '(') + return ParseFunctionCall(identifier); + + return NormalizeValue(_fieldResolver(identifier)); + } + + Failed = true; + return null; + } + + private string ParseString(char quote) + { + Position++; + var builder = new System.Text.StringBuilder(); + while (Position < Input.Length) + { + char ch = Input[Position++]; + if (ch == quote) + { + if (Position < Input.Length && Input[Position] == quote) + { + builder.Append(quote); + Position++; + continue; + } + + return builder.ToString(); + } + + builder.Append(ch); + } + + Failed = true; + return string.Empty; + } + + private object? ParseBracketedField() + { + Position++; + int start = Position; + while (Position < Input.Length && Input[Position] != ']') + Position++; + + if (Position >= Input.Length) + { + Failed = true; + return null; } - // Field reference (identifier) - if (char.IsLetter(ch) || ch == '_') + string fieldName = Input[start..Position].ToString(); + Position++; + return NormalizeValue(_fieldResolver(fieldName)); + } + + private object? ParseNumber() + { + int start = Position; + bool hasDecimal = false; + while (Position < Input.Length) { - var start = Position; - while (Position < Input.Length && (char.IsLetterOrDigit(Input[Position]) || Input[Position] == '_')) + char current = Input[Position]; + if (char.IsDigit(current)) + { Position++; - var fieldName = Input[start..Position].ToString(); - SkipWhitespace(); - if (Position < Input.Length && Input[Position] == '(') - return ParseFunctionCall(fieldName); + continue; + } + + if (current == '.' && !hasDecimal) + { + hasDecimal = true; + Position++; + continue; + } - return _fieldResolver(fieldName); + break; } - return null; // Unexpected character + ReadOnlySpan number = Input[start..Position]; + if (!hasDecimal && long.TryParse(number, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer)) + return integer; + + return double.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out double real) + ? real + : Fail(); } - private double? ParseFunctionCall(string functionName) + private string ParseIdentifier() { + int start = Position; Position++; - var arguments = new List(); + while (Position < Input.Length && IsIdentifierPart(Input[Position])) + Position++; + + return Input[start..Position].ToString(); + } + + private object? ParseFunctionCall(string functionName) + { + Position++; + var arguments = new List(); SkipWhitespace(); if (Position < Input.Length && Input[Position] == ')') { @@ -278,10 +582,9 @@ public Parser( return InvokeFunction(functionName, arguments); } - while (Position < Input.Length) + while (!Failed && Position < Input.Length) { - double? argument = ParseExpression(); - arguments.Add(argument.HasValue ? DbValue.FromReal(argument.Value) : DbValue.Null); + arguments.Add(ParseExpression()); SkipWhitespace(); if (Position < Input.Length && Input[Position] == ',') @@ -296,13 +599,23 @@ public Parser( return InvokeFunction(functionName, arguments); } + Failed = true; return null; } + Failed = true; return null; } - private double? InvokeFunction(string functionName, List arguments) + private object? InvokeFunction(string functionName, List arguments) + { + if (TryInvokeBuiltInFunction(functionName, arguments, _domainResolver, out object? builtInValue)) + return NormalizeValue(builtInValue); + + return InvokeRegisteredFunction(functionName, arguments); + } + + private object? InvokeRegisteredFunction(string functionName, List arguments) { if (!_functions.TryGetScalar(functionName, arguments.Count, out var definition)) { @@ -314,22 +627,17 @@ public Parser( return null; } - if (definition.Options.NullPropagating && arguments.Any(static argument => argument.IsNull)) + DbValue[] dbArguments = arguments.Select(ToDbValue).ToArray(); + if (definition.Options.NullPropagating && dbArguments.Any(static argument => argument.IsNull)) return null; try { - DbValue[] dbArguments = arguments.ToArray(); IReadOnlyDictionary? metadata = CreateFormCallbackMetadata(functionName); DbValue value = _callbackPolicy is null ? definition.Invoke(dbArguments, metadata) : definition.Invoke(dbArguments, metadata, _callbackPolicy, DbExtensionHostMode.Embedded); - return value.Type switch - { - DbType.Integer => value.AsInteger, - DbType.Real => value.AsReal, - _ => null, - }; + return FromDbValue(value); } catch { @@ -337,13 +645,1082 @@ public Parser( } } - private void SkipWhitespace() + public void SkipWhitespace() { while (Position < Input.Length && char.IsWhiteSpace(Input[Position])) Position++; } + + private bool MatchKeyword(string keyword) + { + SkipWhitespace(); + if (!Input[Position..].StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) + return false; + + int end = Position + keyword.Length; + if (end < Input.Length && IsIdentifierPart(Input[end])) + return false; + + Position = end; + return true; + } + + private string? MatchComparisonOperator() + { + SkipWhitespace(); + foreach (string op in new[] { ">=", "<=", "==", "!=", "<>", "=", ">", "<" }) + { + if (Input[Position..].StartsWith(op, StringComparison.Ordinal)) + { + Position += op.Length; + return op; + } + } + + return null; + } + + private object? Fail() + { + Failed = true; + return null; + } + } + + private static bool TryInvokeBuiltInFunction( + string functionName, + IReadOnlyList args, + FormulaDomainFunctionResolver? domainResolver, + out object? value) + { + value = null; + switch (functionName.ToUpperInvariant()) + { + case "NZ": + value = args.Count is 1 or 2 + ? IsNullOrEmpty(args[0]) ? args.Count == 2 ? args[1] : string.Empty : args[0] + : null; + return args.Count is 1 or 2; + case "ISNULL": + value = args.Count == 1 && NormalizeValue(args[0]) is null; + return args.Count == 1; + case "ISEMPTY": + value = args.Count == 1 && IsNullOrEmpty(args[0]); + return args.Count == 1; + case "IIF": + value = args.Count == 3 ? IsTruthy(args[0]) ? args[1] : args[2] : null; + return args.Count == 3; + case "SWITCH": + if (args.Count == 0 || args.Count % 2 != 0) + return true; + for (int i = 0; i < args.Count; i += 2) + { + if (IsTruthy(args[i])) + { + value = args[i + 1]; + return true; + } + } + return true; + case "CHOOSE": + if (args.Count < 2 || !TryConvertLong(args[0], out long index)) + return true; + value = index >= 1 && index < args.Count ? args[(int)index] : null; + return true; + case "LEN": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).Length : null; + return args.Count == 1; + case "LEFT": + value = args.Count == 2 && TryConvertLong(args[1], out long leftCount) + ? Left(ToFormulaString(args[0]), leftCount) + : null; + return args.Count == 2; + case "RIGHT": + value = args.Count == 2 && TryConvertLong(args[1], out long rightCount) + ? Right(ToFormulaString(args[0]), rightCount) + : null; + return args.Count == 2; + case "MID": + value = EvaluateMid(args); + return args.Count is 2 or 3; + case "TRIM": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).Trim() : null; + return args.Count == 1; + case "LTRIM": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).TrimStart() : null; + return args.Count == 1; + case "RTRIM": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).TrimEnd() : null; + return args.Count == 1; + case "UCASE": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).ToUpperInvariant() : null; + return args.Count == 1; + case "LCASE": + value = args.Count == 1 && args[0] is not null ? ToFormulaString(args[0]).ToLowerInvariant() : null; + return args.Count == 1; + case "INSTR": + value = EvaluateInStr(args); + return args.Count is 2 or 3; + case "REPLACE": + value = args.Count == 3 && args[0] is not null && args[1] is not null + ? ToFormulaString(args[0]).Replace(ToFormulaString(args[1]), ToFormulaString(args[2]), StringComparison.Ordinal) + : null; + return args.Count == 3; + case "STRCOMP": + value = EvaluateStrComp(args); + return args.Count is 2 or 3; + case "VAL": + value = args.Count == 1 ? ParseLeadingNumber(ToFormulaString(args[0])) : null; + return args.Count == 1; + case "DATE": + value = args.Count == 0 ? DateTime.Now.Date : null; + return args.Count == 0; + case "TIME": + value = args.Count == 0 ? DateTime.Now.TimeOfDay : null; + return args.Count == 0; + case "NOW": + value = args.Count == 0 ? DateTime.Now : null; + return args.Count == 0; + case "YEAR": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime yearDate) ? yearDate.Year : null; + return args.Count == 1; + case "MONTH": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime monthDate) ? monthDate.Month : null; + return args.Count == 1; + case "DAY": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime dayDate) ? dayDate.Day : null; + return args.Count == 1; + case "HOUR": + value = args.Count == 1 && TryConvertTime(args[0], out TimeSpan hourTime) ? hourTime.Hours : null; + return args.Count == 1; + case "MINUTE": + value = args.Count == 1 && TryConvertTime(args[0], out TimeSpan minuteTime) ? minuteTime.Minutes : null; + return args.Count == 1; + case "SECOND": + value = args.Count == 1 && TryConvertTime(args[0], out TimeSpan secondTime) ? secondTime.Seconds : null; + return args.Count == 1; + case "DATEADD": + value = EvaluateDateAdd(args); + return args.Count == 3; + case "DATEDIFF": + value = EvaluateDateDiff(args); + return args.Count == 3; + case "DATEPART": + value = EvaluateDatePart(args); + return args.Count == 2; + case "DATESERIAL": + value = EvaluateDateSerial(args); + return args.Count == 3; + case "TIMESERIAL": + value = EvaluateTimeSerial(args); + return args.Count == 3; + case "WEEKDAY": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime weekdayDate) + ? ((int)weekdayDate.DayOfWeek) + 1 + : null; + return args.Count == 1; + case "MONTHNAME": + value = EvaluateMonthName(args); + return args.Count is 1 or 2; + case "ABS": + value = args.Count == 1 && TryConvertDouble(args[0], out double absValue) ? Math.Abs(absValue) : null; + return args.Count == 1; + case "ROUND": + value = EvaluateRound(args); + return args.Count is 1 or 2; + case "INT": + value = args.Count == 1 && TryConvertDouble(args[0], out double intValue) ? Math.Floor(intValue) : null; + return args.Count == 1; + case "FIX": + value = args.Count == 1 && TryConvertDouble(args[0], out double fixValue) ? Math.Truncate(fixValue) : null; + return args.Count == 1; + case "SGN": + value = args.Count == 1 && TryConvertDouble(args[0], out double sgnValue) ? Math.Sign(sgnValue) : null; + return args.Count == 1; + case "CSTR": + value = args.Count == 1 ? ToFormulaString(args[0]) : null; + return args.Count == 1; + case "CINT": + case "CLNG": + value = args.Count == 1 && TryConvertDouble(args[0], out double integerValue) + ? Convert.ToInt64(Math.Round(integerValue, MidpointRounding.ToEven), CultureInfo.InvariantCulture) + : null; + return args.Count == 1; + case "CDBL": + value = args.Count == 1 && TryConvertDouble(args[0], out double doubleValue) ? doubleValue : null; + return args.Count == 1; + case "CBOOL": + value = args.Count == 1 && TryConvertBoolean(args[0], out bool boolValue) ? boolValue : null; + return args.Count == 1; + case "CDATE": + value = args.Count == 1 && TryConvertDateTime(args[0], out DateTime dateValue) ? dateValue : null; + return args.Count == 1; + case "FORMAT": + value = args.Count == 2 ? FormatValue(args[0], ToFormulaString(args[1])) : null; + return args.Count == 2; + case "DLOOKUP": + case "DCOUNT": + case "DSUM": + case "DAVG": + case "DMIN": + case "DMAX": + value = EvaluateDomainFunction(functionName, args, domainResolver); + return args.Count is 2 or 3; + default: + return false; + } + } + + private static object? EvaluateMid(IReadOnlyList args) + { + if (args.Count is not (2 or 3) || args[0] is null || !TryConvertLong(args[1], out long start)) + return null; + + string value = ToFormulaString(args[0]); + int zeroBasedStart = Math.Max(0, (int)start - 1); + if (zeroBasedStart >= value.Length) + return string.Empty; + + if (args.Count == 2) + return value[zeroBasedStart..]; + + if (!TryConvertLong(args[2], out long count)) + return null; + + int length = Math.Clamp((int)count, 0, value.Length - zeroBasedStart); + return value.Substring(zeroBasedStart, length); + } + + private static object? EvaluateInStr(IReadOnlyList args) + { + if (args.Count is not (2 or 3)) + return null; + + long start = 1; + object? source = args[0]; + object? search = args[1]; + if (args.Count == 3) + { + if (!TryConvertLong(args[0], out start)) + return null; + + source = args[1]; + search = args[2]; + } + + if (source is null || search is null) + return null; + + string sourceText = ToFormulaString(source); + string searchText = ToFormulaString(search); + int zeroBasedStart = Math.Clamp((int)start - 1, 0, sourceText.Length); + int index = sourceText.IndexOf(searchText, zeroBasedStart, StringComparison.OrdinalIgnoreCase); + return index < 0 ? 0 : index + 1; + } + + private static object? EvaluateStrComp(IReadOnlyList args) + { + if (args.Count is not (2 or 3) || args[0] is null || args[1] is null) + return null; + + StringComparison comparison = StringComparison.Ordinal; + if (args.Count == 3) + { + string mode = ToFormulaString(args[2]); + if (string.Equals(mode, "text", StringComparison.OrdinalIgnoreCase) || + string.Equals(mode, "1", StringComparison.OrdinalIgnoreCase)) + { + comparison = StringComparison.OrdinalIgnoreCase; + } + } + + int result = string.Compare(ToFormulaString(args[0]), ToFormulaString(args[1]), comparison); + return result < 0 ? -1 : result > 0 ? 1 : 0; + } + + private static object? EvaluateDateAdd(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertLong(args[1], out long amount) || + !TryConvertDateTime(args[2], out DateTime date)) + { + return null; + } + + return AddDateInterval(ToFormulaString(args[0]), date, amount); + } + + private static object? EvaluateDateDiff(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertDateTime(args[1], out DateTime start) || + !TryConvertDateTime(args[2], out DateTime end)) + { + return null; + } + + return DiffDateInterval(ToFormulaString(args[0]), start, end); } + private static object? EvaluateDatePart(IReadOnlyList args) + { + if (args.Count != 2 || !TryConvertDateTime(args[1], out DateTime date)) + return null; + + return GetDatePart(ToFormulaString(args[0]), date); + } + + private static object? EvaluateDateSerial(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertLong(args[0], out long year) || + !TryConvertLong(args[1], out long month) || + !TryConvertLong(args[2], out long day)) + { + return null; + } + + try + { + return new DateTime((int)year, 1, 1).AddMonths((int)month - 1).AddDays((int)day - 1); + } + catch + { + return null; + } + } + + private static object? EvaluateTimeSerial(IReadOnlyList args) + { + if (args.Count != 3 || + !TryConvertLong(args[0], out long hour) || + !TryConvertLong(args[1], out long minute) || + !TryConvertLong(args[2], out long second)) + { + return null; + } + + try + { + return TimeSpan.FromHours(hour) + TimeSpan.FromMinutes(minute) + TimeSpan.FromSeconds(second); + } + catch + { + return null; + } + } + + private static object? EvaluateMonthName(IReadOnlyList args) + { + if (args.Count is not (1 or 2) || !TryConvertLong(args[0], out long month) || month is < 1 or > 12) + return null; + + bool abbreviate = args.Count == 2 && TryConvertBoolean(args[1], out bool abbreviated) && abbreviated; + DateTimeFormatInfo format = CultureInfo.InvariantCulture.DateTimeFormat; + return abbreviate ? format.GetAbbreviatedMonthName((int)month) : format.GetMonthName((int)month); + } + + private static object? EvaluateRound(IReadOnlyList args) + { + if (args.Count is not (1 or 2) || !TryConvertDouble(args[0], out double value)) + return null; + + int digits = 0; + if (args.Count == 2) + { + if (!TryConvertLong(args[1], out long parsedDigits) || parsedDigits is < 0 or > 15) + return null; + + digits = (int)parsedDigits; + } + + return Math.Round(value, digits, MidpointRounding.ToEven); + } + + private static object? EvaluateDomainFunction( + string functionName, + IReadOnlyList args, + FormulaDomainFunctionResolver? domainResolver) + { + if (args.Count is not (2 or 3) || domainResolver is null) + return null; + + string expression = ToFormulaString(args[0]).Trim(); + string domain = ToFormulaString(args[1]).Trim(); + string? criteria = args.Count == 3 ? ToFormulaString(args[2]) : null; + if (string.IsNullOrWhiteSpace(expression) || string.IsNullOrWhiteSpace(domain)) + return null; + + return domainResolver(new FormulaDomainFunctionRequest( + functionName.ToUpperInvariant(), + expression, + domain, + string.IsNullOrWhiteSpace(criteria) ? null : criteria)); + } + + private static DateTime? AddDateInterval(string interval, DateTime date, long amount) + { + try + { + return NormalizeInterval(interval) switch + { + "yyyy" => date.AddYears((int)amount), + "q" => date.AddMonths((int)amount * 3), + "m" => date.AddMonths((int)amount), + "y" or "d" or "w" => date.AddDays(amount), + "ww" => date.AddDays(amount * 7), + "h" => date.AddHours(amount), + "n" => date.AddMinutes(amount), + "s" => date.AddSeconds(amount), + _ => null, + }; + } + catch + { + return null; + } + } + + private static long? DiffDateInterval(string interval, DateTime start, DateTime end) + => NormalizeInterval(interval) switch + { + "yyyy" => end.Year - start.Year, + "q" => ((end.Year - start.Year) * 4) + ((end.Month - 1) / 3) - ((start.Month - 1) / 3), + "m" => ((end.Year - start.Year) * 12) + end.Month - start.Month, + "y" or "d" or "w" => (long)(end.Date - start.Date).TotalDays, + "ww" => (long)Math.Floor((end.Date - start.Date).TotalDays / 7), + "h" => (long)(end - start).TotalHours, + "n" => (long)(end - start).TotalMinutes, + "s" => (long)(end - start).TotalSeconds, + _ => null, + }; + + private static long? GetDatePart(string interval, DateTime date) + => NormalizeInterval(interval) switch + { + "yyyy" => date.Year, + "q" => ((date.Month - 1) / 3) + 1, + "m" => date.Month, + "y" => date.DayOfYear, + "d" => date.Day, + "w" => ((int)date.DayOfWeek) + 1, + "ww" => ISOWeek.GetWeekOfYear(date), + "h" => date.Hour, + "n" => date.Minute, + "s" => date.Second, + _ => null, + }; + + private static string NormalizeInterval(string interval) + => interval.Trim().Trim('"', '\'').ToLowerInvariant(); + + private static string Left(string value, long count) + { + int length = Math.Clamp((int)count, 0, value.Length); + return value[..length]; + } + + private static string Right(string value, long count) + { + int length = Math.Clamp((int)count, 0, value.Length); + return value[(value.Length - length)..]; + } + + private static double ParseLeadingNumber(string text) + { + text = text.TrimStart(); + if (text.Length == 0) + return 0; + + int index = 0; + if (text[index] is '+' or '-') + index++; + + bool hasDigit = false; + bool hasDecimal = false; + while (index < text.Length) + { + char ch = text[index]; + if (char.IsDigit(ch)) + { + hasDigit = true; + index++; + continue; + } + + if (ch == '.' && !hasDecimal) + { + hasDecimal = true; + index++; + continue; + } + + break; + } + + if (!hasDigit) + return 0; + + if (index < text.Length && text[index] is 'e' or 'E') + { + int exponentStart = index; + index++; + if (index < text.Length && text[index] is '+' or '-') + index++; + + bool hasExponentDigit = false; + while (index < text.Length && char.IsDigit(text[index])) + { + hasExponentDigit = true; + index++; + } + + if (!hasExponentDigit) + index = exponentStart; + } + + return double.TryParse(text[..index], NumberStyles.Float, CultureInfo.InvariantCulture, out double result) + ? result + : 0; + } + + private static object? FormatValue(object? value, string format) + { + value = NormalizeValue(value); + if (value is null) + return null; + + try + { + if (value is DateTime dateTime) + return dateTime.ToString(format, CultureInfo.InvariantCulture); + + if (value is TimeSpan time) + return time.ToString(format, CultureInfo.InvariantCulture); + + if (TryConvertDouble(value, out double number)) + return number.ToString(format, CultureInfo.InvariantCulture); + + if (value is IFormattable formattable) + return formattable.ToString(format, CultureInfo.InvariantCulture); + } + catch + { + return ToFormulaString(value); + } + + return ToFormulaString(value); + } + + private static bool Compare(object? left, object? right, string op) + { + int comparison = CompareValues(left, right); + return op switch + { + "=" or "==" => comparison == 0, + "!=" or "<>" => comparison != 0, + ">" => comparison > 0, + ">=" => comparison >= 0, + "<" => comparison < 0, + "<=" => comparison <= 0, + _ => false, + }; + } + + private static int CompareValues(object? left, object? right) + { + left = NormalizeValue(left); + right = NormalizeValue(right); + + if (left is null || right is null) + return left is null && right is null ? 0 : left is null ? -1 : 1; + + if (TryConvertDouble(left, out double leftNumber) && TryConvertDouble(right, out double rightNumber)) + return leftNumber.CompareTo(rightNumber); + + if (TryConvertDateTime(left, out DateTime leftDate) && TryConvertDateTime(right, out DateTime rightDate)) + return leftDate.CompareTo(rightDate); + + if (TryConvertBoolean(left, out bool leftBool) && TryConvertBoolean(right, out bool rightBool)) + return leftBool.CompareTo(rightBool); + + return string.Compare(ToFormulaString(left), ToFormulaString(right), StringComparison.OrdinalIgnoreCase); + } + + private static bool IsTruthy(object? value) + { + value = NormalizeValue(value); + if (value is null) + return false; + + if (value is bool boolean) + return boolean; + + if (TryConvertDouble(value, out double number)) + return Math.Abs(number) > double.Epsilon; + + string text = ToFormulaString(value); + if (bool.TryParse(text, out bool parsed)) + return parsed; + + return !string.IsNullOrWhiteSpace(text); + } + + private static bool IsNullOrEmpty(object? value) + { + value = NormalizeValue(value); + return value is null || value is string text && text.Length == 0; + } + + private static bool TryConvertDouble(object? value, out double result) + { + value = NormalizeValue(value); + switch (value) + { + case byte number: + result = number; + return true; + case sbyte number: + result = number; + return true; + case short number: + result = number; + return true; + case ushort number: + result = number; + return true; + case int number: + result = number; + return true; + case uint number: + result = number; + return true; + case long number: + result = number; + return true; + case ulong number: + result = number; + return true; + case float number: + result = number; + return true; + case double number: + result = number; + return true; + case decimal number: + result = (double)number; + return true; + case bool boolean: + result = boolean ? 1 : 0; + return true; + case string text: + return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result); + default: + result = 0; + return false; + } + } + + private static bool TryConvertLong(object? value, out long result) + { + value = NormalizeValue(value); + switch (value) + { + case byte number: + result = number; + return true; + case sbyte number: + result = number; + return true; + case short number: + result = number; + return true; + case ushort number: + result = number; + return true; + case int number: + result = number; + return true; + case uint number: + result = number; + return true; + case long number: + result = number; + return true; + case float or double or decimal: + if (TryConvertDouble(value, out double numeric)) + { + result = Convert.ToInt64(Math.Round(numeric, MidpointRounding.ToEven), CultureInfo.InvariantCulture); + return true; + } + + break; + case string text when long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out long integer): + result = integer; + return true; + case string text when double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out double real): + result = Convert.ToInt64(Math.Round(real, MidpointRounding.ToEven), CultureInfo.InvariantCulture); + return true; + } + + result = 0; + return false; + } + + private static bool TryConvertBoolean(object? value, out bool result) + { + value = NormalizeValue(value); + switch (value) + { + case bool boolean: + result = boolean; + return true; + case string text: + if (bool.TryParse(text, out result)) + return true; + if (string.Equals(text, "yes", StringComparison.OrdinalIgnoreCase) || + string.Equals(text, "y", StringComparison.OrdinalIgnoreCase)) + { + result = true; + return true; + } + if (string.Equals(text, "no", StringComparison.OrdinalIgnoreCase) || + string.Equals(text, "n", StringComparison.OrdinalIgnoreCase)) + { + result = false; + return true; + } + if (TryConvertDouble(text, out double numericText)) + { + result = Math.Abs(numericText) > double.Epsilon; + return true; + } + break; + default: + if (TryConvertDouble(value, out double numeric)) + { + result = Math.Abs(numeric) > double.Epsilon; + return true; + } + break; + } + + result = false; + return false; + } + + private static bool TryConvertDateTime(object? value, out DateTime result) + { + value = NormalizeValue(value); + switch (value) + { + case DateTime dateTime: + result = dateTime; + return true; + case DateTimeOffset dateTimeOffset: + result = dateTimeOffset.LocalDateTime; + return true; + case DateOnly dateOnly: + result = dateOnly.ToDateTime(TimeOnly.MinValue); + return true; + case string text: + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out result) || + DateTime.TryParse(text, CultureInfo.CurrentCulture, DateTimeStyles.AllowWhiteSpaces, out result)) + { + return true; + } + break; + default: + if (TryConvertDouble(value, out double numeric)) + { + try + { + result = DateTime.FromOADate(numeric); + return true; + } + catch + { + } + } + break; + } + + result = default; + return false; + } + + private static bool TryConvertTime(object? value, out TimeSpan result) + { + value = NormalizeValue(value); + switch (value) + { + case TimeSpan timeSpan: + result = timeSpan; + return true; + case TimeOnly timeOnly: + result = timeOnly.ToTimeSpan(); + return true; + case DateTime dateTime: + result = dateTime.TimeOfDay; + return true; + case DateTimeOffset dateTimeOffset: + result = dateTimeOffset.TimeOfDay; + return true; + case string text: + if (TimeSpan.TryParse(text, CultureInfo.InvariantCulture, out result)) + return true; + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out DateTime parsedDate)) + { + result = parsedDate.TimeOfDay; + return true; + } + break; + } + + result = default; + return false; + } + + private static string ToFormulaString(object? value) + { + value = NormalizeValue(value); + return value switch + { + null => string.Empty, + DateTime dateTime => dateTime.TimeOfDay == TimeSpan.Zero + ? dateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : dateTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + TimeSpan timeSpan => timeSpan.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture), + bool boolean => boolean ? "True" : "False", + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty, + }; + } + + private static object? NormalizeValue(object? value) + => value is JsonElement json ? NormalizeJsonValue(json) : value; + + private static object? NormalizeJsonValue(JsonElement value) + => value.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => value.GetString(), + JsonValueKind.Number when value.TryGetInt64(out long integer) => integer, + JsonValueKind.Number => value.GetDouble(), + _ => value.ToString(), + }; + + private static DbValue ToDbValue(object? value) + { + value = NormalizeValue(value); + return value switch + { + null => DbValue.Null, + DbValue dbValue => dbValue, + bool boolean => DbValue.FromInteger(boolean ? 1 : 0), + byte or sbyte or short or ushort or int or uint or long => DbValue.FromInteger(Convert.ToInt64(value, CultureInfo.InvariantCulture)), + float or double or decimal => DbValue.FromReal(Convert.ToDouble(value, CultureInfo.InvariantCulture)), + string text => DbValue.FromText(text), + byte[] bytes => DbValue.FromBlob(bytes), + DateTime or DateTimeOffset or DateOnly or TimeOnly or TimeSpan => DbValue.FromText(ToFormulaString(value)), + _ => DbValue.FromText(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty), + }; + } + + private static object? FromDbValue(DbValue value) => value.Type switch + { + DbType.Null => null, + DbType.Integer => value.AsInteger, + DbType.Real => value.AsReal, + DbType.Text => value.AsText, + DbType.Blob => value.AsBlob, + _ => null, + }; + + private static bool TryReadFunctionArguments( + ReadOnlySpan input, + int openParen, + out string argumentsText, + out int closeParen) + { + int depth = 0; + for (int i = openParen; i < input.Length; i++) + { + char ch = input[i]; + if (ch is '\'' or '"') + { + i = SkipQuoted(input, i, ch); + continue; + } + + if (ch == '[') + { + i = SkipBracketed(input, i); + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + if (depth == 0) + { + argumentsText = input[(openParen + 1)..i].ToString(); + closeParen = i; + return true; + } + } + } + + argumentsText = string.Empty; + closeParen = -1; + return false; + } + + private static string[] SplitTopLevelArguments(string argumentsText) + { + if (string.IsNullOrWhiteSpace(argumentsText)) + return []; + + var arguments = new List(); + int start = 0; + int depth = 0; + bool inBracket = false; + char quote = '\0'; + for (int i = 0; i < argumentsText.Length; i++) + { + char ch = argumentsText[i]; + if (quote != '\0') + { + if (ch == quote) + { + if (i + 1 < argumentsText.Length && argumentsText[i + 1] == quote) + { + i++; + continue; + } + + quote = '\0'; + } + + continue; + } + + if (inBracket) + { + if (ch == ']') + inBracket = false; + continue; + } + + if (ch is '\'' or '"') + { + quote = ch; + continue; + } + + if (ch == '[') + { + inBracket = true; + continue; + } + + if (ch == '(') + { + depth++; + continue; + } + + if (ch == ')') + { + depth--; + continue; + } + + if (ch == ',' && depth == 0) + { + arguments.Add(argumentsText[start..i].Trim()); + start = i + 1; + } + } + + arguments.Add(argumentsText[start..].Trim()); + return arguments.Any(static argument => argument.Length == 0) ? [] : arguments.ToArray(); + } + + private static bool TryReadLiteralText(string token, out string? value) + { + value = null; + token = token.Trim(); + if (token.Length == 0) + return false; + + if (token.Length >= 2 && token[0] is '\'' or '"' && token[^1] == token[0]) + { + char quote = token[0]; + value = token[1..^1].Replace($"{quote}{quote}", quote.ToString(), StringComparison.Ordinal); + return true; + } + + if (token.StartsWith('[') && token.EndsWith(']') && token.Length > 2) + { + value = token[1..^1].Trim(); + return value.Length > 0; + } + + if (IsValidIdentifier(token)) + { + value = token; + return true; + } + + return false; + } + + private static int SkipQuoted(ReadOnlySpan input, int start, char quote) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] != quote) + continue; + + if (i + 1 < input.Length && input[i + 1] == quote) + { + i++; + continue; + } + + return i; + } + + return input.Length - 1; + } + + private static int SkipBracketed(ReadOnlySpan input, int start) + { + for (int i = start + 1; i < input.Length; i++) + { + if (input[i] == ']') + return i; + } + + return input.Length - 1; + } + + private static bool IsIdentifierStart(char value) + => char.IsLetter(value) || value == '_'; + + private static bool IsIdentifierPart(char value) + => char.IsLetterOrDigit(value) || value == '_'; + private static IReadOnlyDictionary? CreateFormCallbackMetadata(string functionName) => DbCallbackDiagnostics.IsInvocationEnabled ? new Dictionary(StringComparer.OrdinalIgnoreCase) diff --git a/src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs b/src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs new file mode 100644 index 00000000..dd7214ef --- /dev/null +++ b/src/CSharpDB.Admin.Forms/Evaluation/FormulaFunctionCatalog.cs @@ -0,0 +1,94 @@ +namespace CSharpDB.Admin.Forms.Evaluation; + +public sealed record FormulaFunctionDescriptor( + string Name, + string Category, + string Signature, + string Description, + string Example, + string InsertText); + +public static class FormulaFunctionCatalog +{ + public static IReadOnlyList ExpressionFunctions { get; } = + [ + new("Nz", "Null and Conditional", "Nz(value, fallback)", "Returns fallback when value is null or empty.", "=Nz(Quantity, 0)", "Nz(value, fallback)"), + new("IsNull", "Null and Conditional", "IsNull(value)", "Returns true when value is null.", "=IsNull(ClosedDate)", "IsNull(value)"), + new("IsEmpty", "Null and Conditional", "IsEmpty(value)", "Returns true when value is null or empty text.", "=IsEmpty(Notes)", "IsEmpty(value)"), + new("IIf", "Null and Conditional", "IIf(condition, trueValue, falseValue)", "Returns one of two values based on a condition.", "=IIf(Status = 'Closed', 'Done', 'Open')", "IIf(condition, trueValue, falseValue)"), + new("Switch", "Null and Conditional", "Switch(condition1, value1, ...)", "Returns the first value whose condition is true.", "=Switch(Priority = 1, 'High', Priority = 2, 'Normal')", "Switch(condition1, value1, condition2, value2)"), + new("Choose", "Null and Conditional", "Choose(index, value1, value2, ...)", "Returns the value at a 1-based position.", "=Choose(StatusCode, 'New', 'Open', 'Closed')", "Choose(index, value1, value2)"), + + new("Len", "Text", "Len(value)", "Returns text length.", "=Len(CustomerName)", "Len(value)"), + new("Left", "Text", "Left(value, count)", "Returns characters from the left side.", "=Left(CustomerCode, 3)", "Left(value, count)"), + new("Right", "Text", "Right(value, count)", "Returns characters from the right side.", "=Right(OrderNumber, 4)", "Right(value, count)"), + new("Mid", "Text", "Mid(value, start, count)", "Returns characters from the middle of text.", "=Mid(ProductCode, 2, 3)", "Mid(value, start, count)"), + new("Trim", "Text", "Trim(value)", "Trims leading and trailing spaces.", "=Trim(CustomerName)", "Trim(value)"), + new("LTrim", "Text", "LTrim(value)", "Trims leading spaces.", "=LTrim(Code)", "LTrim(value)"), + new("RTrim", "Text", "RTrim(value)", "Trims trailing spaces.", "=RTrim(Code)", "RTrim(value)"), + new("UCase", "Text", "UCase(value)", "Converts text to uppercase.", "=UCase(CustomerName)", "UCase(value)"), + new("LCase", "Text", "LCase(value)", "Converts text to lowercase.", "=LCase(Email)", "LCase(value)"), + new("InStr", "Text", "InStr(value, search)", "Returns the 1-based position of search text, or 0.", "=InStr(Email, '@')", "InStr(value, search)"), + new("Replace", "Text", "Replace(value, search, replacement)", "Replaces matching text.", "=Replace(Phone, '-', '')", "Replace(value, search, replacement)"), + new("StrComp", "Text", "StrComp(left, right, comparison)", "Compares two strings and returns -1, 0, or 1.", "=StrComp(Code, 'A1', 'text')", "StrComp(left, right, 'text')"), + new("Val", "Text", "Val(value)", "Parses leading numeric text.", "=Val(QuantityText)", "Val(value)"), + + new("Date", "Date and Time", "Date()", "Returns today's date.", "=Date()", "Date()"), + new("Time", "Date and Time", "Time()", "Returns the current time.", "=Time()", "Time()"), + new("Now", "Date and Time", "Now()", "Returns the current date and time.", "=Now()", "Now()"), + new("Year", "Date and Time", "Year(value)", "Returns the year number.", "=Year(OrderDate)", "Year(value)"), + new("Month", "Date and Time", "Month(value)", "Returns the month number.", "=Month(OrderDate)", "Month(value)"), + new("Day", "Date and Time", "Day(value)", "Returns the day of month.", "=Day(OrderDate)", "Day(value)"), + new("Hour", "Date and Time", "Hour(value)", "Returns the hour.", "=Hour(UpdatedAt)", "Hour(value)"), + new("Minute", "Date and Time", "Minute(value)", "Returns the minute.", "=Minute(UpdatedAt)", "Minute(value)"), + new("Second", "Date and Time", "Second(value)", "Returns the second.", "=Second(UpdatedAt)", "Second(value)"), + new("DateAdd", "Date and Time", "DateAdd(interval, amount, value)", "Adds a date/time interval.", "=DateAdd('d', 7, OrderDate)", "DateAdd('d', amount, value)"), + new("DateDiff", "Date and Time", "DateDiff(interval, start, end)", "Returns the difference between dates.", "=DateDiff('d', OrderDate, Date())", "DateDiff('d', start, end)"), + new("DatePart", "Date and Time", "DatePart(interval, value)", "Returns a date/time part.", "=DatePart('q', OrderDate)", "DatePart('q', value)"), + new("DateSerial", "Date and Time", "DateSerial(year, month, day)", "Builds a date.", "=DateSerial(Year(Date()), 1, 1)", "DateSerial(year, month, day)"), + new("TimeSerial", "Date and Time", "TimeSerial(hour, minute, second)", "Builds a time value.", "=TimeSerial(17, 0, 0)", "TimeSerial(hour, minute, second)"), + new("Weekday", "Date and Time", "Weekday(value)", "Returns day of week as 1 through 7.", "=Weekday(OrderDate)", "Weekday(value)"), + new("MonthName", "Date and Time", "MonthName(month)", "Returns the month name.", "=MonthName(Month(OrderDate))", "MonthName(month)"), + + new("Abs", "Number and Conversion", "Abs(value)", "Returns absolute value.", "=Abs(Balance)", "Abs(value)"), + new("Round", "Number and Conversion", "Round(value, digits)", "Rounds a number.", "=Round(Amount, 2)", "Round(value, digits)"), + new("Int", "Number and Conversion", "Int(value)", "Rounds down to an integer.", "=Int(Amount)", "Int(value)"), + new("Fix", "Number and Conversion", "Fix(value)", "Truncates toward zero.", "=Fix(Amount)", "Fix(value)"), + new("Sgn", "Number and Conversion", "Sgn(value)", "Returns -1, 0, or 1.", "=Sgn(Balance)", "Sgn(value)"), + new("CStr", "Number and Conversion", "CStr(value)", "Converts a value to text.", "=CStr(OrderId)", "CStr(value)"), + new("CInt", "Number and Conversion", "CInt(value)", "Converts a value to an integer.", "=CInt(QuantityText)", "CInt(value)"), + new("CLng", "Number and Conversion", "CLng(value)", "Converts a value to a long integer.", "=CLng(IdText)", "CLng(value)"), + new("CDbl", "Number and Conversion", "CDbl(value)", "Converts a value to a double.", "=CDbl(AmountText)", "CDbl(value)"), + new("CBool", "Number and Conversion", "CBool(value)", "Converts a value to boolean.", "=CBool(IsActive)", "CBool(value)"), + new("CDate", "Number and Conversion", "CDate(value)", "Converts a value to date/time.", "=CDate(DateText)", "CDate(value)"), + new("Format", "Number and Conversion", "Format(value, format)", "Formats a number, date, time, or text.", "=Format(Amount, '0.00')", "Format(value, format)"), + + new("DLookup", "Domain", "DLookup(expr, domain, criteria)", "Returns one value from another table/query.", "=DLookup('Name', 'Customers', 'CustomerId = 1')", "DLookup('FieldName', 'TableName', 'Field = value')"), + new("DCount", "Domain", "DCount(expr, domain, criteria)", "Counts matching rows in another table/query.", "=DCount('*', 'Orders', 'Status = ''Open''')", "DCount('*', 'TableName', 'Field = value')"), + new("DSum", "Domain", "DSum(expr, domain, criteria)", "Sums matching rows in another table/query.", "=DSum('Amount', 'OrderLines', 'OrderId = 1')", "DSum('FieldName', 'TableName', 'Field = value')"), + new("DAvg", "Domain", "DAvg(expr, domain, criteria)", "Averages matching rows in another table/query.", "=DAvg('Amount', 'OrderLines', 'OrderId = 1')", "DAvg('FieldName', 'TableName', 'Field = value')"), + new("DMin", "Domain", "DMin(expr, domain, criteria)", "Returns the minimum matching value.", "=DMin('Amount', 'OrderLines', 'OrderId = 1')", "DMin('FieldName', 'TableName', 'Field = value')"), + new("DMax", "Domain", "DMax(expr, domain, criteria)", "Returns the maximum matching value.", "=DMax('Amount', 'OrderLines', 'OrderId = 1')", "DMax('FieldName', 'TableName', 'Field = value')"), + ]; + + public static IReadOnlyList AggregateFunctions { get; } = + [ + new("SUM", "Child Aggregates", "SUM(Table.Field)", "Sums child-row values for the current parent record.", "=SUM(OrderLines.Amount)", "SUM(Table.Field)"), + new("COUNT", "Child Aggregates", "COUNT(Table.Field)", "Counts child-row values for the current parent record.", "=COUNT(OrderLines.Id)", "COUNT(Table.Field)"), + new("AVG", "Child Aggregates", "AVG(Table.Field)", "Averages child-row values for the current parent record.", "=AVG(OrderLines.Amount)", "AVG(Table.Field)"), + new("MIN", "Child Aggregates", "MIN(Table.Field)", "Returns the minimum child-row value.", "=MIN(OrderLines.Amount)", "MIN(Table.Field)"), + new("MAX", "Child Aggregates", "MAX(Table.Field)", "Returns the maximum child-row value.", "=MAX(OrderLines.Amount)", "MAX(Table.Field)"), + ]; + + public static IReadOnlyList AllFunctions { get; } = + ExpressionFunctions.Concat(AggregateFunctions).ToArray(); + + public static IReadOnlyList Categories { get; } = + AllFunctions + .Select(static function => function.Category) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public static IReadOnlyCollection BuiltInFunctionNames { get; } = + ExpressionFunctions.Select(static function => function.Name).ToArray(); +} diff --git a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor index a58bcbaf..4e4bd379 100644 --- a/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor +++ b/src/CSharpDB.Admin.Forms/Pages/DataEntry.razor @@ -257,6 +257,8 @@ private readonly Stack> _redoStack = new(); private Dictionary? _validationErrors; private readonly Dictionary _childTableDefs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _formulaDomainTableDefs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary>> _formulaDomainRows = new(StringComparer.OrdinalIgnoreCase); private readonly List _computedControls = []; private readonly HashSet _computedFieldNames = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _controlPropertyOverrides = new(StringComparer.OrdinalIgnoreCase); @@ -268,6 +270,7 @@ private const int RecordPaneMinWidth = 320; private const int RecordPaneMaxWidth = 720; + private const int FormulaDomainRowLimit = 5000; private bool CanOpenDesigner => ShowDesignerButton && OnOpenDesigner.HasDelegate; private int TotalPages => Math.Max(1, (int)Math.Ceiling(_totalRecords / (double)_pageSize)); @@ -358,6 +361,8 @@ _error = null; _validationErrors = null; _childTableDefs.Clear(); + _formulaDomainTableDefs.Clear(); + _formulaDomainRows.Clear(); _computedControls.Clear(); _computedFieldNames.Clear(); _controlPropertyOverrides.Clear(); @@ -2125,20 +2130,193 @@ } else { - _currentRecord[fieldName] = FormulaEvaluator.Evaluate(formula, field => + await LoadFormulaDomainReferencesAsync(formula); + _currentRecord[fieldName] = FormulaEvaluator.EvaluateValue(formula, field => { - if (TryGetFieldValue(_currentRecord, field, out object? value) && value is not null) + return TryGetFieldValue(_currentRecord, field, out object? value) + ? value + : null; + }, Functions, CallbackPolicy, ResolveFormulaDomainFunction); + } + } + } + + private async Task LoadFormulaDomainReferencesAsync(string formula) + { + foreach (string domain in FormulaEvaluator.GetDomainReferences(formula)) + { + if (_formulaDomainRows.ContainsKey(domain)) + continue; + + FormTableDefinition? domainTable = await SchemaProvider.GetTableDefinitionAsync(domain); + if (domainTable is null) + continue; + + FormRecordPage page = await RecordService.ListRecordPageAsync(domainTable, 1, FormulaDomainRowLimit); + _formulaDomainTableDefs[domain] = domainTable; + _formulaDomainRows[domain] = page.Records.Select(CloneRecord).ToList(); + } + } + + private object? ResolveFormulaDomainFunction(FormulaDomainFunctionRequest request) + { + if (!_formulaDomainTableDefs.TryGetValue(request.Domain, out FormTableDefinition? table) || + !_formulaDomainRows.TryGetValue(request.Domain, out List>? rows)) + { + return null; + } + + IReadOnlyList> filteredRows = FilterDomainRows(table, rows, request.Criteria); + string function = request.FunctionName.ToUpperInvariant(); + if (function == "DCOUNT") + { + if (string.Equals(request.Expression.Trim(), "*", StringComparison.Ordinal)) + return filteredRows.Count; + + return filteredRows.Count(row => ReadDomainExpressionValue(request.Expression, row) is not null); + } + + if (function == "DLOOKUP") + return filteredRows.Select(row => ReadDomainExpressionValue(request.Expression, row)).FirstOrDefault(value => value is not null); + + IEnumerable values = filteredRows.Select(row => TryConvertDomainNumber(ReadDomainExpressionValue(request.Expression, row), out double value) + ? value + : (double?)null); + + return function switch + { + "DSUM" => FormulaEvaluator.EvaluateAggregate("SUM", values), + "DAVG" => FormulaEvaluator.EvaluateAggregate("AVG", values), + "DMIN" => FormulaEvaluator.EvaluateAggregate("MIN", values), + "DMAX" => FormulaEvaluator.EvaluateAggregate("MAX", values), + _ => null, + }; + } + + private static IReadOnlyList> FilterDomainRows( + FormTableDefinition table, + IReadOnlyList> rows, + string? criteria) + { + if (string.IsNullOrWhiteSpace(criteria)) + return rows; + + string normalizedCriteria = NormalizeDomainCriteria(criteria, table); + return FormFilterExpression.TryParse(normalizedCriteria, table, out FormFilterExpression? filter, out _) + ? rows.Where(row => filter!.Evaluate(row)).ToArray() + : []; + } + + private static object? ReadDomainExpressionValue(string expression, IReadOnlyDictionary row) + { + string fieldName = NormalizeDomainFieldReference(expression); + if (TryGetFieldValue(row, fieldName, out object? value)) + return value; + + return FormulaEvaluator.EvaluateValue( + expression.StartsWith('=') ? expression : $"={expression}", + field => TryGetFieldValue(row, field, out object? fieldValue) ? fieldValue : null); + } + + private static string NormalizeDomainFieldReference(string expression) + { + string trimmed = expression.Trim(); + if (trimmed.StartsWith('[') && trimmed.EndsWith(']') && trimmed.Length > 2) + return trimmed[1..^1].Trim(); + + return trimmed; + } + + private static string NormalizeDomainCriteria(string criteria, FormTableDefinition table) + { + var fieldNames = new HashSet(table.Fields.Select(static field => field.Name), StringComparer.OrdinalIgnoreCase); + var builder = new System.Text.StringBuilder(criteria.Length + 16); + for (int i = 0; i < criteria.Length;) + { + char ch = criteria[i]; + if (ch is '\'' or '"') + { + int start = i; + i++; + while (i < criteria.Length) + { + if (criteria[i] == ch) { - if (value is double d) return d; - if (value is long l) return l; - if (value is int i) return i; - if (double.TryParse(value.ToString(), out var parsed)) return parsed; + i++; + if (i < criteria.Length && criteria[i] == ch) + { + i++; + continue; + } + + break; } - return null; - }, Functions, CallbackPolicy); + i++; + } + + builder.Append(criteria, start, i - start); + continue; + } + + if (ch == '[') + { + int start = i; + int end = criteria.IndexOf(']', i + 1); + if (end < 0) + { + builder.Append(criteria[i..]); + break; + } + + builder.Append(criteria, start, end - start + 1); + i = end + 1; + continue; } + + if (char.IsLetter(ch) || ch == '_') + { + int start = i; + i++; + while (i < criteria.Length && (char.IsLetterOrDigit(criteria[i]) || criteria[i] == '_')) + i++; + + string identifier = criteria[start..i]; + if (fieldNames.Contains(identifier)) + builder.Append('[').Append(identifier).Append(']'); + else + builder.Append(identifier); + continue; + } + + builder.Append(ch); + i++; } + + return builder.ToString(); + } + + private static bool TryConvertDomainNumber(object? value, out double result) + { + value = FormSql.NormalizeValue(value); + return value switch + { + byte number => Set(number, out result), + short number => Set(number, out result), + int number => Set(number, out result), + long number => Set(number, out result), + float number => Set(number, out result), + double number => Set(number, out result), + decimal number => Set((double)number, out result), + string text => double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out result), + _ => Set(0, out result, success: false), + }; + } + + private static bool Set(double value, out double result, bool success = true) + { + result = value; + return success; } private (string? ForeignKeyField, string? ParentKeyField) FindChildMapping(string childTableName) diff --git a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs index 5bb214ca..c6bd735c 100644 --- a/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs +++ b/src/CSharpDB.Admin.Forms/Services/FormAutomationMetadata.cs @@ -1,4 +1,5 @@ using CSharpDB.Admin.Forms.Models; +using CSharpDB.Admin.Forms.Evaluation; using CSharpDB.Primitives; namespace CSharpDB.Admin.Forms.Services; @@ -6,7 +7,8 @@ namespace CSharpDB.Admin.Forms.Services; public static class FormAutomationMetadata { private const string Surface = "admin.forms"; - private static readonly string[] IgnoredFormulaFunctions = ["SUM", "COUNT", "AVG", "MIN", "MAX"]; + private static readonly string[] IgnoredFormulaFunctions = + FormulaEvaluator.BuiltInFunctionNames.Concat(["SUM", "COUNT", "AVG", "MIN", "MAX"]).ToArray(); private static readonly HashSet BuiltInValidationRuleIds = new(StringComparer.OrdinalIgnoreCase) { "required", diff --git a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css index e4010418..7782a51d 100644 --- a/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css +++ b/src/CSharpDB.Admin.Forms/wwwroot/css/designer.css @@ -376,6 +376,146 @@ margin-top: 4px; } +.pi-input-action-row { + display: flex; + gap: 4px; + align-items: stretch; +} + +.pi-input-action-row input { + min-width: 0; +} + +.pi-icon-btn { + flex: 0 0 auto; + min-width: 34px; + padding: 4px 8px; + border: 1px solid #d0d0d0; + border-radius: 3px; + background: #fff; + color: #333; + cursor: pointer; + font-size: 11px; + font-weight: 600; +} + +.pi-icon-btn:hover { + background: #e8e8e8; +} + +.pi-formula-helper { + margin: 6px 0 8px; + padding: 8px; + border: 1px solid #d0d0d0; + border-radius: 4px; + background: #fafafa; +} + +.pi-formula-helper-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + margin-bottom: 8px; +} + +.pi-formula-fields { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.pi-formula-field { + max-width: 100%; + padding: 3px 6px; + border: 1px solid #d0d0d0; + border-radius: 999px; + background: #fff; + color: #333; + cursor: pointer; + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pi-formula-group { + margin-top: 8px; +} + +.pi-formula-group-label { + margin-bottom: 4px; + color: #777; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; +} + +.pi-formula-function { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px; + margin-bottom: 4px; +} + +.pi-formula-function-main, +.pi-formula-example-btn { + border: 1px solid #d0d0d0; + border-radius: 4px; + background: #fff; + color: #333; + cursor: pointer; +} + +.pi-formula-function-main { + min-width: 0; + padding: 6px; + text-align: left; +} + +.pi-formula-function-main strong, +.pi-formula-function-main code, +.pi-formula-function-main span { + display: block; +} + +.pi-formula-function-main strong { + color: #1a73e8; + font-size: 12px; +} + +.pi-formula-function-main code { + margin-top: 2px; + color: #555; + font-size: 10px; + white-space: normal; +} + +.pi-formula-function-main span { + margin-top: 2px; + color: #777; + font-size: 10px; + line-height: 1.3; +} + +.pi-formula-example-btn { + padding: 0 6px; + font-size: 10px; +} + +.pi-formula-function-main:hover, +.pi-formula-example-btn:hover, +.pi-formula-field:hover { + background: #e8f0fe; +} + +.pi-formula-empty { + color: #999; + font-size: 11px; + padding: 6px 0 2px; +} + .pi-readonly { background: #f5f5f5 !important; color: #888; @@ -2544,6 +2684,10 @@ .designer-toolbar button, .de-btn, .pi-btn, +.pi-icon-btn, +.pi-formula-field, +.pi-formula-function-main, +.pi-formula-example-btn, .cdg-btn, .tce-btn, .breakpoint-switcher button, @@ -2642,6 +2786,10 @@ .designer-toolbar button:hover, .de-btn:hover, .pi-btn:hover, +.pi-icon-btn:hover, +.pi-formula-field:hover, +.pi-formula-function-main:hover, +.pi-formula-example-btn:hover, .cdg-btn:hover, .tce-btn:hover, .feb-btn:hover, @@ -2846,6 +2994,21 @@ border-color: var(--fd-border); } +.pi-formula-helper { + background: var(--fd-bg-tertiary); + border-color: var(--fd-border); +} + +.pi-formula-group-label, +.pi-formula-function-main code, +.pi-formula-function-main span { + color: var(--fd-text-secondary); +} + +.pi-formula-function-main strong { + color: var(--fd-accent); +} + .pi-id-value { background: color-mix(in srgb, var(--fd-bg-elevated) 88%, var(--fd-accent-soft)); color: var(--fd-text); diff --git a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor index 7566f5e5..6f4edd38 100644 --- a/src/CSharpDB.Admin/Components/Layout/NavMenu.razor +++ b/src/CSharpDB.Admin/Components/Layout/NavMenu.razor @@ -1,3 +1,4 @@ +@using CSharpDB.Admin.Forms.Evaluation @inject ICSharpDbClient DbClient @inject DatabaseClientHolder DbHolder @inject DatabaseChangeService Changes @@ -575,21 +576,44 @@ Callbacks - @_callbacks.Count + @CallbackObjectCount
@if (_expandedGroups.Contains("callbacks")) {
- @foreach (HostCallbackCatalogEntry callback in FilterCallbacks()) + @{ + int internalCount = InternalCallbackObjectCount; + int externalCount = ExternalCallbackObjectCount; + } + + @if (internalCount == 0 && externalCount == 0) { - var currentCallback = callback; -
- - @currentCallback.Name - @(currentCallback.IsMissingRegistration ? "missing" : GetCallbackKindLabel(currentCallback.Kind)) +
No matching callbacks.
+ } + else + { +
+ @if (internalCount > 0) + { + + } + + @if (externalCount > 0) + { + + }
}
@@ -607,6 +631,8 @@ private const int SidebarColumnPreviewLimit = 12; private const int SidebarIndexPreviewLimit = 6; private const int SidebarTriggerPreviewLimit = 6; + private const string CallbackScopeInternal = "internal"; + private const string CallbackScopeExternal = "external"; private ContextMenu? _contextMenu; private List _contextMenuItems = new(); @@ -645,6 +671,14 @@ private bool ShouldShowUtilityGroups => string.Equals(_objectFilter, "all", StringComparison.Ordinal); + private int CallbackObjectCount => 2; + + private int InternalCallbackObjectCount + => FilterInternalCallbackFunctions().Count() + FilterInternalCallbacks().Count(); + + private int ExternalCallbackObjectCount + => FilterExternalCallbacks().Count(); + private IReadOnlyList PinnedItems { get @@ -935,6 +969,7 @@ { CSharpDB.Primitives.AutomationCallbackKind.ScalarFunction => "scalar", CSharpDB.Primitives.AutomationCallbackKind.Command => "command", + CSharpDB.Primitives.AutomationCallbackKind.ValidationRule => "validation", _ => kind.ToString().ToLowerInvariant(), }; @@ -970,11 +1005,43 @@ return _callbacks.Where(CallbackMatchesSearch); } + private IEnumerable FilterInternalCallbackFunctions() + { + IEnumerable functions = FormulaFunctionCatalog.AllFunctions + .OrderBy(static function => function.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(static function => function.Name, StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(_searchQuery)) + return functions; + + return functions.Where(InternalFunctionMatchesSearch); + } + + private IEnumerable FilterInternalCallbacks() + => FilterCallbacks().Where(IsInternalHostCallback); + + private IEnumerable FilterExternalCallbacks() + => FilterCallbacks().Where(callback => !IsInternalHostCallback(callback)); + + private bool InternalFunctionMatchesSearch(FormulaFunctionDescriptor function) + { + string query = _searchQuery.Trim(); + return function.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Category.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Signature.Contains(query, StringComparison.OrdinalIgnoreCase) + || function.Description.Contains(query, StringComparison.OrdinalIgnoreCase) + || "internal".Contains(query, StringComparison.OrdinalIgnoreCase) + || "built-in".Contains(query, StringComparison.OrdinalIgnoreCase) + || "formula".Contains(query, StringComparison.OrdinalIgnoreCase); + } + private bool CallbackMatchesSearch(HostCallbackCatalogEntry callback) { string query = _searchQuery.Trim(); return callback.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || callback.Kind.ToString().Contains(query, StringComparison.OrdinalIgnoreCase) + || (IsInternalHostCallback(callback) && "internal".Contains(query, StringComparison.OrdinalIgnoreCase)) + || (!IsInternalHostCallback(callback) && "external".Contains(query, StringComparison.OrdinalIgnoreCase)) || (callback.IsMissingRegistration && "missing".Contains(query, StringComparison.OrdinalIgnoreCase)) || (callback.Descriptor?.Runtime.ToString().Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) || (callback.Descriptor?.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false) @@ -1017,8 +1084,51 @@ private void OpenSystemCatalogTab(SystemCatalogItem item) => TabManager.OpenSystemCatalogTab(item.Name, item.Sql); + private void OpenInternalFunction(FormulaFunctionDescriptor function) + { + TabDescriptor tab = TabManager.OpenCallbacksTab(); + tab.State["CallbackScope"] = CallbackScopeInternal; + tab.State["SelectedBuiltInFunctionName"] = function.Name; + tab.State.Remove("SelectedCallbackName"); + tab.State.Remove("SelectedCallbackKind"); + tab.State.Remove("SelectedCallbackArity"); + } + + private void OpenCallbackScope(string scope) + { + TabDescriptor tab = TabManager.OpenCallbacksTab(); + tab.State["CallbackScope"] = scope; + tab.State.Remove("SelectedBuiltInFunctionName"); + tab.State.Remove("SelectedCallbackName"); + tab.State.Remove("SelectedCallbackKind"); + tab.State.Remove("SelectedCallbackArity"); + TabManager.ActivateTab(tab.Id); + } + private void OpenCallback(HostCallbackCatalogEntry callback) - => TabManager.OpenCallbacksTab(callback.Name, callback.Kind.ToString(), callback.Arity); + { + TabDescriptor tab = TabManager.OpenCallbacksTab(callback.Name, callback.Kind.ToString(), callback.Arity); + tab.State["CallbackScope"] = IsInternalHostCallback(callback) ? CallbackScopeInternal : CallbackScopeExternal; + tab.State.Remove("SelectedBuiltInFunctionName"); + } + + private static bool IsInternalHostCallback(HostCallbackCatalogEntry callback) + => callback.Descriptor is { Metadata: { } metadata } + && IsSystemGeneratedCallbackMetadata(metadata); + + private static bool IsSystemGeneratedCallbackMetadata(IReadOnlyDictionary metadata) + => HasMetadataValue(metadata, "origin", "system") + || HasMetadataValue(metadata, "source", "system") + || HasMetadataValue(metadata, "scope", CallbackScopeInternal) + || HasMetadataValue(metadata, "generatedBy", "system") + || HasMetadataValue(metadata, "systemGenerated", "true"); + + private static bool HasMetadataValue( + IReadOnlyDictionary metadata, + string key, + string expectedValue) + => metadata.TryGetValue(key, out string? value) + && string.Equals(value, expectedValue, StringComparison.OrdinalIgnoreCase); private bool IsActive(string type, string name) { @@ -1063,6 +1173,30 @@ return true; } + private bool IsActiveInternalFunction(FormulaFunctionDescriptor function) + { + var activeTab = TabManager.ActiveTab; + return activeTab?.Id == "callbacks:host" + && activeTab.State.TryGetValue("SelectedBuiltInFunctionName", out object? nameValue) + && nameValue is string selectedName + && selectedName.Equals(function.Name, StringComparison.OrdinalIgnoreCase); + } + + private bool IsActiveCallbackScope(string scope) + { + var activeTab = TabManager.ActiveTab; + if (activeTab?.Id != "callbacks:host") + return false; + + string activeScope = activeTab.State.TryGetValue("CallbackScope", out object? scopeValue) + && scopeValue is string value + && !string.IsNullOrWhiteSpace(value) + ? value + : CallbackScopeExternal; + + return activeScope.Equals(scope, StringComparison.OrdinalIgnoreCase); + } + private bool IsActiveForm(string formId) { var activeTab = TabManager.ActiveTab; @@ -1272,6 +1406,17 @@ }); } + private void ShowInternalFunctionItemMenu(MouseEventArgs e, FormulaFunctionDescriptor function) + { + ShowContextMenu(e, new List + { + new() { Label = "Open", Icon = "bi-code-slash", OnClick = () => OpenInternalFunction(function) }, + ContextMenuItem.Separator(), + new() { Label = "Copy Name", Icon = "bi-clipboard", OnClick = async () => await CopyCallbackNameAsync(function.Name) }, + new() { Label = "Copy Signature", Icon = "bi-clipboard-plus", OnClick = async () => await CopyCallbackNameAsync(function.Signature) }, + }); + } + private async Task OpenNewCollectionAsync() { string? enteredName = await Modal.PromptAsync( diff --git a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor index b62652de..4e6a3d20 100644 --- a/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor +++ b/src/CSharpDB.Admin/Components/Tabs/CallbacksTab.razor @@ -1,3 +1,4 @@ +@using CSharpDB.Admin.Forms.Evaluation @using CSharpDB.Primitives @implements IDisposable @inject HostCallbackCatalogService CallbackCatalog @@ -7,7 +8,7 @@ @inject ToastService Toast @inject IJSRuntime JS -
+
/ callbacks + / + @GetScopeLabel(_callbackScope)
@@ -35,6 +38,19 @@
+
+ + +
+