From 143e0b8d4d34bacf1eec6f1d5270e66fc52a98a8 Mon Sep 17 00:00:00 2001 From: luckydizzier <134082331+luckydizzier@users.noreply.github.com> Date: Wed, 1 Oct 2025 22:51:04 +0200 Subject: [PATCH] feat(demo): add ddl previews and index management --- .../ServiceCollectionExtensions.cs | 2 + .../ViewModels/IndexManagerViewModel.cs | 135 +++++++++++++ .../ViewModels/SchemaColumnChangeViewModel.cs | 33 +++ .../ViewModels/SchemaColumnViewModel.cs | 42 ++++ .../ViewModels/SchemaDesignerViewModel.cs | 165 +++++++++++++++ .../ViewModels/ShellViewModel.cs | 17 ++ .../XBase.Demo.Domain/Schema/DdlPreview.cs | 16 ++ .../Schema/TableSchemaDefinition.cs | 51 +++++ .../Services/IIndexManagementService.cs | 16 ++ .../Services/ISchemaDdlService.cs | 17 ++ .../Services/Models/IndexRequests.cs | 50 +++++ .../FileSystemIndexManagementService.cs | 90 +++++++++ .../Schema/TemplateSchemaDdlService.cs | 191 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 4 + src/demo/tasks.md | 6 +- tests/XBase.Demo.App.Tests/DemoHostFactory.cs | 15 ++ .../IndexManagerViewModelTests.cs | 86 ++++++++ .../SchemaDesignerViewModelTests.cs | 91 +++++++++ .../ShellViewModelTests.cs | 46 +---- tests/XBase.Demo.App.Tests/TempCatalog.cs | 50 +++++ 20 files changed, 1075 insertions(+), 48 deletions(-) create mode 100644 src/demo/XBase.Demo.App/ViewModels/IndexManagerViewModel.cs create mode 100644 src/demo/XBase.Demo.App/ViewModels/SchemaColumnChangeViewModel.cs create mode 100644 src/demo/XBase.Demo.App/ViewModels/SchemaColumnViewModel.cs create mode 100644 src/demo/XBase.Demo.App/ViewModels/SchemaDesignerViewModel.cs create mode 100644 src/demo/XBase.Demo.Domain/Schema/DdlPreview.cs create mode 100644 src/demo/XBase.Demo.Domain/Schema/TableSchemaDefinition.cs create mode 100644 src/demo/XBase.Demo.Domain/Services/IIndexManagementService.cs create mode 100644 src/demo/XBase.Demo.Domain/Services/ISchemaDdlService.cs create mode 100644 src/demo/XBase.Demo.Domain/Services/Models/IndexRequests.cs create mode 100644 src/demo/XBase.Demo.Infrastructure/Indexes/FileSystemIndexManagementService.cs create mode 100644 src/demo/XBase.Demo.Infrastructure/Schema/TemplateSchemaDdlService.cs create mode 100644 tests/XBase.Demo.App.Tests/DemoHostFactory.cs create mode 100644 tests/XBase.Demo.App.Tests/IndexManagerViewModelTests.cs create mode 100644 tests/XBase.Demo.App.Tests/SchemaDesignerViewModelTests.cs create mode 100644 tests/XBase.Demo.App.Tests/TempCatalog.cs diff --git a/src/demo/XBase.Demo.App/DependencyInjection/ServiceCollectionExtensions.cs b/src/demo/XBase.Demo.App/DependencyInjection/ServiceCollectionExtensions.cs index ea22e11..3b0b57a 100644 --- a/src/demo/XBase.Demo.App/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/demo/XBase.Demo.App/DependencyInjection/ServiceCollectionExtensions.cs @@ -16,6 +16,8 @@ public static IServiceCollection AddDemoApp(this IServiceCollection services) services.AddXBaseDemoInfrastructure(); services.AddXBaseDemoDiagnostics(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/demo/XBase.Demo.App/ViewModels/IndexManagerViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/IndexManagerViewModel.cs new file mode 100644 index 0000000..9d0d16e --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/IndexManagerViewModel.cs @@ -0,0 +1,135 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; +using ReactiveUI; +using XBase.Demo.Domain.Services; +using XBase.Demo.Domain.Services.Models; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Handles index create/drop operations for the selected table. +/// +public sealed class IndexManagerViewModel : ReactiveObject +{ + private readonly IIndexManagementService _indexService; + private TableListItemViewModel? _table; + private string? _indexName; + private string? _indexExpression; + private string? _statusMessage; + private string? _errorMessage; + private bool _isBusy; + + public IndexManagerViewModel(IIndexManagementService indexService) + { + _indexService = indexService; + + CreateIndexCommand = ReactiveCommand.CreateFromTask(ExecuteCreateIndexAsync); + CreateIndexCommand.Subscribe(ApplyResult); + CreateIndexCommand.ThrownExceptions.Subscribe(OnFault); + + DropIndexCommand = ReactiveCommand.CreateFromTask(ExecuteDropIndexAsync); + DropIndexCommand.Subscribe(ApplyResult); + DropIndexCommand.ThrownExceptions.Subscribe(OnFault); + + Observable.Merge( + CreateIndexCommand.IsExecuting, + DropIndexCommand.IsExecuting) + .Subscribe(isExecuting => IsBusy = isExecuting); + } + + public ReactiveCommand CreateIndexCommand { get; } + + public ReactiveCommand DropIndexCommand { get; } + + public bool IsBusy + { + get => _isBusy; + private set => this.RaiseAndSetIfChanged(ref _isBusy, value); + } + + public string? IndexName + { + get => _indexName; + set => this.RaiseAndSetIfChanged(ref _indexName, value); + } + + public string? IndexExpression + { + get => _indexExpression; + set => this.RaiseAndSetIfChanged(ref _indexExpression, value); + } + + public string? StatusMessage + { + get => _statusMessage; + private set => this.RaiseAndSetIfChanged(ref _statusMessage, value); + } + + public string? ErrorMessage + { + get => _errorMessage; + private set => this.RaiseAndSetIfChanged(ref _errorMessage, value); + } + + public void SetTargetTable(TableListItemViewModel? table) + { + _table = table; + StatusMessage = null; + ErrorMessage = null; + if (table is null) + { + IndexName = null; + IndexExpression = null; + } + } + + private async Task ExecuteCreateIndexAsync() + { + if (_table is null) + { + throw new InvalidOperationException("Select a table before creating an index."); + } + + if (string.IsNullOrWhiteSpace(IndexName) || string.IsNullOrWhiteSpace(IndexExpression)) + { + throw new InvalidOperationException("Index name and expression are required."); + } + + var request = IndexCreateRequest.Create(_table.Model.Path, IndexName!, IndexExpression!); + return await _indexService.CreateIndexAsync(request); + } + + private async Task ExecuteDropIndexAsync(IndexListItemViewModel index) + { + ArgumentNullException.ThrowIfNull(index); + if (_table is null) + { + throw new InvalidOperationException("Select a table before dropping an index."); + } + + var request = IndexDropRequest.Create(_table.Model.Path, index.Name); + return await _indexService.DropIndexAsync(request); + } + + private void ApplyResult(IndexOperationResult result) + { + if (result.Succeeded) + { + StatusMessage = result.Message; + ErrorMessage = null; + } + else + { + StatusMessage = null; + ErrorMessage = result.Message; + } + } + + private void OnFault(Exception exception) + { + StatusMessage = null; + ErrorMessage = exception.Message; + } +} diff --git a/src/demo/XBase.Demo.App/ViewModels/SchemaColumnChangeViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/SchemaColumnChangeViewModel.cs new file mode 100644 index 0000000..34d9ef8 --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/SchemaColumnChangeViewModel.cs @@ -0,0 +1,33 @@ +using System; +using XBase.Demo.Domain.Schema; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Represents an alteration action for schema preview generation. +/// +public sealed class SchemaColumnChangeViewModel +{ + public SchemaColumnChangeViewModel( + ColumnChangeOperation operation, + string columnName, + SchemaColumnViewModel? columnDefinition = null, + string? newColumnName = null) + { + Operation = operation; + ColumnName = columnName ?? throw new ArgumentNullException(nameof(columnName)); + ColumnDefinition = columnDefinition; + NewColumnName = newColumnName; + } + + public ColumnChangeOperation Operation { get; } + + public string ColumnName { get; } + + public SchemaColumnViewModel? ColumnDefinition { get; } + + public string? NewColumnName { get; } + + public ColumnChangeDefinition ToDefinition() + => new(Operation, ColumnName, ColumnDefinition?.ToDefinition(), NewColumnName); +} diff --git a/src/demo/XBase.Demo.App/ViewModels/SchemaColumnViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/SchemaColumnViewModel.cs new file mode 100644 index 0000000..a3dc6e1 --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/SchemaColumnViewModel.cs @@ -0,0 +1,42 @@ +using System; +using XBase.Demo.Domain.Schema; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Represents a column definition used by the schema designer. +/// +public sealed class SchemaColumnViewModel +{ + public SchemaColumnViewModel(string name, string dataType, bool allowNulls = true, int? length = null, int? scale = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Column name is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(dataType)) + { + throw new ArgumentException("Column data type is required.", nameof(dataType)); + } + + Name = name; + DataType = dataType; + AllowNulls = allowNulls; + Length = length; + Scale = scale; + } + + public string Name { get; } + + public string DataType { get; } + + public bool AllowNulls { get; } + + public int? Length { get; } + + public int? Scale { get; } + + public TableColumnDefinition ToDefinition() + => new(Name, DataType, AllowNulls, Length, Scale); +} diff --git a/src/demo/XBase.Demo.App/ViewModels/SchemaDesignerViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/SchemaDesignerViewModel.cs new file mode 100644 index 0000000..58b3d54 --- /dev/null +++ b/src/demo/XBase.Demo.App/ViewModels/SchemaDesignerViewModel.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using ReactiveUI; +using XBase.Demo.Domain.Schema; +using XBase.Demo.Domain.Services; + +namespace XBase.Demo.App.ViewModels; + +/// +/// Provides commands for generating schema DDL previews. +/// +public sealed class SchemaDesignerViewModel : ReactiveObject +{ + private readonly ISchemaDdlService _ddlService; + private readonly ObservableCollection _columns = new(); + private readonly ObservableCollection _alterations = new(); + private string? _tableName; + private string? _previewOperation; + private string _previewUpScript = string.Empty; + private string _previewDownScript = string.Empty; + private string? _errorMessage; + + public SchemaDesignerViewModel(ISchemaDdlService ddlService) + { + _ddlService = ddlService; + + Columns = new ReadOnlyObservableCollection(_columns); + Alterations = new ReadOnlyObservableCollection(_alterations); + + GenerateCreatePreviewCommand = ReactiveCommand.CreateFromTask(ExecuteGenerateCreatePreviewAsync); + GenerateCreatePreviewCommand.Subscribe(ApplyPreview); + GenerateCreatePreviewCommand.ThrownExceptions.Subscribe(OnPreviewFault); + + GenerateAlterPreviewCommand = ReactiveCommand.CreateFromTask(ExecuteGenerateAlterPreviewAsync); + GenerateAlterPreviewCommand.Subscribe(ApplyPreview); + GenerateAlterPreviewCommand.ThrownExceptions.Subscribe(OnPreviewFault); + + GenerateDropPreviewCommand = ReactiveCommand.CreateFromTask(ExecuteGenerateDropPreviewAsync); + GenerateDropPreviewCommand.Subscribe(ApplyPreview); + GenerateDropPreviewCommand.ThrownExceptions.Subscribe(OnPreviewFault); + } + + public ReadOnlyObservableCollection Columns { get; } + + public ReadOnlyObservableCollection Alterations { get; } + + public ReactiveCommand GenerateCreatePreviewCommand { get; } + + public ReactiveCommand GenerateAlterPreviewCommand { get; } + + public ReactiveCommand GenerateDropPreviewCommand { get; } + + public string? TableName + { + get => _tableName; + set => this.RaiseAndSetIfChanged(ref _tableName, value); + } + + public string? PreviewOperation + { + get => _previewOperation; + private set => this.RaiseAndSetIfChanged(ref _previewOperation, value); + } + + public string PreviewUpScript + { + get => _previewUpScript; + private set => this.RaiseAndSetIfChanged(ref _previewUpScript, value); + } + + public string PreviewDownScript + { + get => _previewDownScript; + private set => this.RaiseAndSetIfChanged(ref _previewDownScript, value); + } + + public string? ErrorMessage + { + get => _errorMessage; + private set => this.RaiseAndSetIfChanged(ref _errorMessage, value); + } + + public void SetTargetTable(TableListItemViewModel? table) + { + TableName = table?.Name; + ErrorMessage = null; + } + + public void SetColumns(params SchemaColumnViewModel[] columns) + { + _columns.Clear(); + foreach (var column in columns) + { + _columns.Add(column); + } + } + + public void SetAlterations(params SchemaColumnChangeViewModel[] alterations) + { + _alterations.Clear(); + foreach (var alteration in alterations) + { + _alterations.Add(alteration); + } + } + + private async Task ExecuteGenerateCreatePreviewAsync() + { + if (string.IsNullOrWhiteSpace(TableName)) + { + throw new InvalidOperationException("Table name is required to generate DDL."); + } + + if (_columns.Count == 0) + { + throw new InvalidOperationException("At least one column is required to generate a CREATE TABLE script."); + } + + var schema = new TableSchemaDefinition(TableName!, _columns.Select(column => column.ToDefinition()).ToArray()); + var preview = await _ddlService.BuildCreateTablePreviewAsync(schema); + return preview; + } + + private async Task ExecuteGenerateAlterPreviewAsync() + { + if (string.IsNullOrWhiteSpace(TableName)) + { + throw new InvalidOperationException("Table name is required to generate DDL."); + } + + if (_alterations.Count == 0) + { + throw new InvalidOperationException("At least one alteration is required to generate an ALTER TABLE script."); + } + + var definition = new TableAlterationDefinition(TableName!, _alterations.Select(change => change.ToDefinition()).ToArray()); + return await _ddlService.BuildAlterTablePreviewAsync(definition); + } + + private async Task ExecuteGenerateDropPreviewAsync() + { + if (string.IsNullOrWhiteSpace(TableName)) + { + throw new InvalidOperationException("Table name is required to generate DDL."); + } + + return await _ddlService.BuildDropTablePreviewAsync(TableName!); + } + + private void ApplyPreview(DdlPreview preview) + { + PreviewOperation = preview.Operation; + PreviewUpScript = string.Join(Environment.NewLine + Environment.NewLine, preview.UpStatements); + PreviewDownScript = string.Join(Environment.NewLine + Environment.NewLine, preview.DownStatements); + ErrorMessage = null; + } + + private void OnPreviewFault(Exception exception) + { + ErrorMessage = exception.Message; + } +} diff --git a/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs b/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs index 2bd724e..b73c9f8 100644 --- a/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs +++ b/src/demo/XBase.Demo.App/ViewModels/ShellViewModel.cs @@ -30,6 +30,8 @@ public ShellViewModel( ITableCatalogService catalogService, ITablePageService pageService, IDemoTelemetrySink telemetrySink, + SchemaDesignerViewModel schemaDesigner, + IndexManagerViewModel indexManager, ILogger logger) { _catalogService = catalogService; @@ -40,6 +42,8 @@ public ShellViewModel( Tables = new ReadOnlyObservableCollection(_tables); TelemetryEvents = new ReadOnlyObservableCollection(_telemetryEvents); TablePage = new TablePageViewModel(); + SchemaDesigner = schemaDesigner; + IndexManager = indexManager; var isIdle = this.WhenAnyValue(x => x.IsBusy).Select(isBusy => !isBusy); @@ -66,6 +70,13 @@ public ShellViewModel( this.WhenAnyValue(x => x.SelectedTable) .WhereNotNull() .InvokeCommand(LoadTableCommand); + + this.WhenAnyValue(x => x.SelectedTable) + .Subscribe(table => + { + SchemaDesigner.SetTargetTable(table); + IndexManager.SetTargetTable(table); + }); } private readonly ObservableCollection _tables = new(); @@ -109,6 +120,10 @@ public TableListItemViewModel? SelectedTable public TablePageViewModel TablePage { get; } + public SchemaDesignerViewModel SchemaDesigner { get; } + + public IndexManagerViewModel IndexManager { get; } + public ReadOnlyObservableCollection TelemetryEvents { get; } private async Task ExecuteBrowseCatalogAsync() @@ -158,6 +173,8 @@ private void OnOpenCatalogFault(Exception exception) CatalogStatus = "Catalog load failed. Review diagnostics for details."; _tables.Clear(); SelectedTable = null; + SchemaDesigner.SetTargetTable(null); + IndexManager.SetTargetTable(null); } private void OnCatalogLoaded(CatalogModel catalog) diff --git a/src/demo/XBase.Demo.Domain/Schema/DdlPreview.cs b/src/demo/XBase.Demo.Domain/Schema/DdlPreview.cs new file mode 100644 index 0000000..20d2bfb --- /dev/null +++ b/src/demo/XBase.Demo.Domain/Schema/DdlPreview.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace XBase.Demo.Domain.Schema; + +/// +/// Represents a set of statements generated for a schema operation preview. +/// +/// Logical operation identifier (e.g. CreateTable, AlterTable). +/// Statements executed to apply the change. +/// Statements that undo the change, when available. +public sealed record DdlPreview(string Operation, IReadOnlyList UpStatements, IReadOnlyList DownStatements) +{ + public static DdlPreview Empty(string operation) + => new(operation, Array.Empty(), Array.Empty()); +} diff --git a/src/demo/XBase.Demo.Domain/Schema/TableSchemaDefinition.cs b/src/demo/XBase.Demo.Domain/Schema/TableSchemaDefinition.cs new file mode 100644 index 0000000..1b4424e --- /dev/null +++ b/src/demo/XBase.Demo.Domain/Schema/TableSchemaDefinition.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace XBase.Demo.Domain.Schema; + +/// +/// Describes a table schema used when generating create/alter previews. +/// +/// Logical table name. +/// Ordered column definitions. +public sealed record TableSchemaDefinition(string TableName, IReadOnlyList Columns); + +/// +/// Describes a single column participating in schema operations. +/// +/// Column name. +/// Provider-specific data type token. +/// Indicates whether NULL values are permitted. +/// Optional data type length. +/// Optional data type scale. +public sealed record TableColumnDefinition(string Name, string DataType, bool AllowNulls = true, int? Length = null, int? Scale = null); + +/// +/// Enumerates supported column change operations for ALTER TABLE previews. +/// +public enum ColumnChangeOperation +{ + Add, + Alter, + Drop, + Rename +} + +/// +/// Describes a column-level change used when building ALTER TABLE previews. +/// +/// Type of change to apply. +/// Name of the target column. +/// Optional column definition used for add/alter/drop rollback scripts. +/// Optional new column name when renaming. +public sealed record ColumnChangeDefinition( + ColumnChangeOperation Operation, + string ColumnName, + TableColumnDefinition? ColumnDefinition = null, + string? NewColumnName = null); + +/// +/// Represents a collection of column changes for an ALTER TABLE preview. +/// +/// Target table name. +/// Ordered column change operations. +public sealed record TableAlterationDefinition(string TableName, IReadOnlyList ColumnChanges); diff --git a/src/demo/XBase.Demo.Domain/Services/IIndexManagementService.cs b/src/demo/XBase.Demo.Domain/Services/IIndexManagementService.cs new file mode 100644 index 0000000..dbba4d3 --- /dev/null +++ b/src/demo/XBase.Demo.Domain/Services/IIndexManagementService.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using XBase.Demo.Domain.Services.Models; + +namespace XBase.Demo.Domain.Services; + +/// +/// Provides index lifecycle management for demo scenarios. +/// +public interface IIndexManagementService +{ + Task CreateIndexAsync(IndexCreateRequest request, CancellationToken cancellationToken = default); + + Task DropIndexAsync(IndexDropRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/demo/XBase.Demo.Domain/Services/ISchemaDdlService.cs b/src/demo/XBase.Demo.Domain/Services/ISchemaDdlService.cs new file mode 100644 index 0000000..3ff11ba --- /dev/null +++ b/src/demo/XBase.Demo.Domain/Services/ISchemaDdlService.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using XBase.Demo.Domain.Schema; + +namespace XBase.Demo.Domain.Services; + +/// +/// Provides provider-aware schema DDL preview generation for create/alter/drop operations. +/// +public interface ISchemaDdlService +{ + Task BuildCreateTablePreviewAsync(TableSchemaDefinition schema, CancellationToken cancellationToken = default); + + Task BuildAlterTablePreviewAsync(TableAlterationDefinition alteration, CancellationToken cancellationToken = default); + + Task BuildDropTablePreviewAsync(string tableName, CancellationToken cancellationToken = default); +} diff --git a/src/demo/XBase.Demo.Domain/Services/Models/IndexRequests.cs b/src/demo/XBase.Demo.Domain/Services/Models/IndexRequests.cs new file mode 100644 index 0000000..7d647a1 --- /dev/null +++ b/src/demo/XBase.Demo.Domain/Services/Models/IndexRequests.cs @@ -0,0 +1,50 @@ +using System; + +namespace XBase.Demo.Domain.Services.Models; + +/// +/// Describes the inputs required to create an index in the demo catalog. +/// +/// Absolute path to the table (DBF) file. +/// Logical index name including extension. +/// Index key expression. +public sealed record IndexCreateRequest(string TablePath, string IndexName, string Expression) +{ + public static IndexCreateRequest Create(string tablePath, string indexName, string expression) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tablePath); + ArgumentException.ThrowIfNullOrWhiteSpace(indexName); + ArgumentException.ThrowIfNullOrWhiteSpace(expression); + return new IndexCreateRequest(tablePath, indexName, expression); + } +} + +/// +/// Describes the inputs required to drop an index from the demo catalog. +/// +/// Absolute path to the table (DBF) file. +/// Logical index name including extension. +public sealed record IndexDropRequest(string TablePath, string IndexName) +{ + public static IndexDropRequest Create(string tablePath, string indexName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tablePath); + ArgumentException.ThrowIfNullOrWhiteSpace(indexName); + return new IndexDropRequest(tablePath, indexName); + } +} + +/// +/// Represents the result of an index lifecycle operation. +/// +/// Indicates whether the operation completed successfully. +/// Human-readable status message. +/// Optional exception surfaced by the operation. +public sealed record IndexOperationResult(bool Succeeded, string Message, Exception? Exception = null) +{ + public static IndexOperationResult Success(string message) + => new(true, message, null); + + public static IndexOperationResult Failure(string message, Exception? exception = null) + => new(false, message, exception); +}; diff --git a/src/demo/XBase.Demo.Infrastructure/Indexes/FileSystemIndexManagementService.cs b/src/demo/XBase.Demo.Infrastructure/Indexes/FileSystemIndexManagementService.cs new file mode 100644 index 0000000..c35df5e --- /dev/null +++ b/src/demo/XBase.Demo.Infrastructure/Indexes/FileSystemIndexManagementService.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using XBase.Demo.Domain.Services; +using XBase.Demo.Domain.Services.Models; + +namespace XBase.Demo.Infrastructure.Indexes; + +/// +/// Minimal index lifecycle service that creates placeholder index files on disk. +/// +public sealed class FileSystemIndexManagementService : IIndexManagementService +{ + private readonly ILogger _logger; + + public FileSystemIndexManagementService(ILogger logger) + { + _logger = logger; + } + + public async Task CreateIndexAsync(IndexCreateRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var tableDirectory = ResolveTableDirectory(request.TablePath); + var indexPath = Path.Combine(tableDirectory, request.IndexName); + if (File.Exists(indexPath)) + { + return IndexOperationResult.Failure($"Index '{request.IndexName}' already exists."); + } + + Directory.CreateDirectory(tableDirectory); + await using var stream = new FileStream(indexPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + var content = Encoding.UTF8.GetBytes($"// Placeholder index for expression: {request.Expression}{Environment.NewLine}"); + await stream.WriteAsync(content, 0, content.Length, cancellationToken); + await stream.FlushAsync(cancellationToken); + + _logger.LogInformation("Created index placeholder {Index} for table {Table}", indexPath, request.TablePath); + return IndexOperationResult.Success($"Index '{request.IndexName}' created successfully."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create index {Index} for table {Table}", request.IndexName, request.TablePath); + return IndexOperationResult.Failure($"Failed to create index '{request.IndexName}'. {ex.Message}", ex); + } + } + + public Task DropIndexAsync(IndexDropRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var tableDirectory = ResolveTableDirectory(request.TablePath); + var indexPath = Path.Combine(tableDirectory, request.IndexName); + if (!File.Exists(indexPath)) + { + return Task.FromResult(IndexOperationResult.Failure($"Index '{request.IndexName}' was not found.")); + } + + File.Delete(indexPath); + _logger.LogInformation("Dropped index placeholder {Index} for table {Table}", indexPath, request.TablePath); + return Task.FromResult(IndexOperationResult.Success($"Index '{request.IndexName}' dropped successfully.")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to drop index {Index} for table {Table}", request.IndexName, request.TablePath); + return Task.FromResult(IndexOperationResult.Failure($"Failed to drop index '{request.IndexName}'. {ex.Message}", ex)); + } + } + + private static string ResolveTableDirectory(string tablePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tablePath); + var tableDirectory = Path.GetDirectoryName(tablePath); + if (string.IsNullOrWhiteSpace(tableDirectory)) + { + throw new DirectoryNotFoundException($"Unable to resolve directory for table path '{tablePath}'."); + } + + return tableDirectory; + } +} diff --git a/src/demo/XBase.Demo.Infrastructure/Schema/TemplateSchemaDdlService.cs b/src/demo/XBase.Demo.Infrastructure/Schema/TemplateSchemaDdlService.cs new file mode 100644 index 0000000..86de305 --- /dev/null +++ b/src/demo/XBase.Demo.Infrastructure/Schema/TemplateSchemaDdlService.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using XBase.Demo.Domain.Schema; +using XBase.Demo.Domain.Services; + +namespace XBase.Demo.Infrastructure.Schema; + +/// +/// Lightweight DDL generator that emits provider-neutral scripts suitable for previews. +/// +public sealed class TemplateSchemaDdlService : ISchemaDdlService +{ + private readonly ILogger _logger; + + public TemplateSchemaDdlService(ILogger logger) + { + _logger = logger; + } + + public Task BuildCreateTablePreviewAsync(TableSchemaDefinition schema, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(schema); + cancellationToken.ThrowIfCancellationRequested(); + + EnsureValidIdentifier(schema.TableName, nameof(schema.TableName)); + if (schema.Columns.Count == 0) + { + throw new InvalidOperationException("At least one column is required to build a CREATE TABLE script."); + } + + var builder = new StringBuilder(); + builder.AppendLine($"CREATE TABLE {schema.TableName} ("); + for (var i = 0; i < schema.Columns.Count; i++) + { + var column = schema.Columns[i]; + var line = FormatColumnDefinition(column); + builder.Append(" "); + builder.Append(line); + if (i < schema.Columns.Count - 1) + { + builder.Append(','); + } + + builder.AppendLine(); + } + + builder.AppendLine(");"); + + var downStatements = new List + { + $"DROP TABLE {schema.TableName};" + }; + + var preview = new DdlPreview("CreateTable", new[] { builder.ToString().TrimEnd() }, downStatements); + _logger.LogInformation("Generated CREATE TABLE preview for {Table}", schema.TableName); + return Task.FromResult(preview); + } + + public Task BuildAlterTablePreviewAsync(TableAlterationDefinition alteration, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(alteration); + cancellationToken.ThrowIfCancellationRequested(); + + EnsureValidIdentifier(alteration.TableName, nameof(alteration.TableName)); + if (alteration.ColumnChanges.Count == 0) + { + throw new InvalidOperationException("At least one column change is required to build an ALTER TABLE script."); + } + + var upStatements = new List(); + var downStatements = new List(); + + foreach (var change in alteration.ColumnChanges) + { + cancellationToken.ThrowIfCancellationRequested(); + upStatements.Add(BuildAlterStatement(alteration.TableName, change)); + downStatements.Insert(0, BuildRollbackStatement(alteration.TableName, change)); + } + + var preview = new DdlPreview("AlterTable", upStatements, downStatements); + _logger.LogInformation("Generated ALTER TABLE preview for {Table} with {ChangeCount} changes", alteration.TableName, alteration.ColumnChanges.Count); + return Task.FromResult(preview); + } + + public Task BuildDropTablePreviewAsync(string tableName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + cancellationToken.ThrowIfCancellationRequested(); + + var statements = new[] { $"DROP TABLE {tableName};" }; + var rollback = new[] { $"-- Recreate table {tableName} using the captured CREATE script." }; + var preview = new DdlPreview("DropTable", statements, rollback); + _logger.LogInformation("Generated DROP TABLE preview for {Table}", tableName); + return Task.FromResult(preview); + } + + private static string FormatColumnDefinition(TableColumnDefinition column) + { + ArgumentNullException.ThrowIfNull(column); + EnsureValidIdentifier(column.Name, nameof(column.Name)); + ArgumentException.ThrowIfNullOrWhiteSpace(column.DataType); + + var typeToken = column.DataType.ToUpperInvariant(); + if (column.Length.HasValue) + { + typeToken += column.Scale.HasValue + ? $"({column.Length.Value.ToString(CultureInfo.InvariantCulture)},{column.Scale.Value.ToString(CultureInfo.InvariantCulture)})" + : $"({column.Length.Value.ToString(CultureInfo.InvariantCulture)})"; + } + + var nullability = column.AllowNulls ? "NULL" : "NOT NULL"; + return $"{column.Name} {typeToken} {nullability}"; + } + + private static string BuildAlterStatement(string tableName, ColumnChangeDefinition change) + { + return change.Operation switch + { + ColumnChangeOperation.Add => BuildAddColumnStatement(tableName, change), + ColumnChangeOperation.Alter => BuildAlterColumnStatement(tableName, change), + ColumnChangeOperation.Drop => BuildDropColumnStatement(tableName, change), + ColumnChangeOperation.Rename => BuildRenameColumnStatement(tableName, change), + _ => throw new ArgumentOutOfRangeException(nameof(change.Operation), change.Operation, "Unsupported column change operation.") + }; + } + + private static string BuildRollbackStatement(string tableName, ColumnChangeDefinition change) + { + return change.Operation switch + { + ColumnChangeOperation.Add => $"ALTER TABLE {tableName} DROP COLUMN {change.ColumnName};", + ColumnChangeOperation.Alter => $"-- Review manual rollback for column {change.ColumnName}.", + ColumnChangeOperation.Drop => change.ColumnDefinition is not null + ? $"ALTER TABLE {tableName} ADD COLUMN {FormatColumnDefinition(change.ColumnDefinition)};" + : $"-- Unable to restore dropped column {change.ColumnName} without definition.", + ColumnChangeOperation.Rename => change.NewColumnName is not null + ? $"ALTER TABLE {tableName} RENAME COLUMN {change.NewColumnName} TO {change.ColumnName};" + : $"-- Rename rollback unavailable for column {change.ColumnName}.", + _ => throw new ArgumentOutOfRangeException(nameof(change.Operation), change.Operation, "Unsupported column change operation.") + }; + } + + private static string BuildAddColumnStatement(string tableName, ColumnChangeDefinition change) + { + if (change.ColumnDefinition is null) + { + throw new InvalidOperationException($"Column definition is required when adding column '{change.ColumnName}'."); + } + + return $"ALTER TABLE {tableName} ADD COLUMN {FormatColumnDefinition(change.ColumnDefinition)};"; + } + + private static string BuildAlterColumnStatement(string tableName, ColumnChangeDefinition change) + { + if (change.ColumnDefinition is null) + { + throw new InvalidOperationException($"Column definition is required when altering column '{change.ColumnName}'."); + } + + return $"ALTER TABLE {tableName} ALTER COLUMN {FormatColumnDefinition(change.ColumnDefinition)};"; + } + + private static string BuildDropColumnStatement(string tableName, ColumnChangeDefinition change) + { + EnsureValidIdentifier(change.ColumnName, nameof(change.ColumnName)); + return $"ALTER TABLE {tableName} DROP COLUMN {change.ColumnName};"; + } + + private static string BuildRenameColumnStatement(string tableName, ColumnChangeDefinition change) + { + EnsureValidIdentifier(change.ColumnName, nameof(change.ColumnName)); + ArgumentException.ThrowIfNullOrWhiteSpace(change.NewColumnName); + EnsureValidIdentifier(change.NewColumnName!, nameof(change.NewColumnName)); + return $"ALTER TABLE {tableName} RENAME COLUMN {change.ColumnName} TO {change.NewColumnName};"; + } + + private static void EnsureValidIdentifier(string identifier, string paramName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(identifier, paramName); + if (identifier.Any(char.IsWhiteSpace)) + { + throw new ArgumentException($"Identifier '{identifier}' cannot contain whitespace.", paramName); + } + } +} diff --git a/src/demo/XBase.Demo.Infrastructure/ServiceCollectionExtensions.cs b/src/demo/XBase.Demo.Infrastructure/ServiceCollectionExtensions.cs index 9e3c586..c3f6634 100644 --- a/src/demo/XBase.Demo.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/demo/XBase.Demo.Infrastructure/ServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using Microsoft.Extensions.Logging; using XBase.Demo.Domain.Services; using XBase.Demo.Infrastructure.Catalog; +using XBase.Demo.Infrastructure.Indexes; +using XBase.Demo.Infrastructure.Schema; namespace XBase.Demo.Infrastructure; @@ -21,6 +23,8 @@ public static IServiceCollection AddXBaseDemoInfrastructure(this IServiceCollect services.AddLogging(builder => builder.AddDebug()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/demo/tasks.md b/src/demo/tasks.md index 6e991c8..0837f1b 100644 --- a/src/demo/tasks.md +++ b/src/demo/tasks.md @@ -10,9 +10,9 @@ Source blueprint: [docs/demo/avalonia-reactiveui-demo-plan.md](../../docs/demo/a - [x] Add integration smoke harness once basic navigation is ready. ## Milestone M2 – DDL & Index Basics -- [ ] Generate DDL preview scripts for create/alter/drop operations. -- [ ] Implement index create/drop services with error surfacing. -- [ ] Extend ViewModels with command handlers for schema updates. +- [x] Generate DDL preview scripts for create/alter/drop operations. +- [x] Implement index create/drop services with error surfacing. +- [x] Extend ViewModels with command handlers for schema updates. ## Milestone M3 – Rebuild & Diagnostics - [ ] Add side-by-side index rebuild orchestration with progress observables. diff --git a/tests/XBase.Demo.App.Tests/DemoHostFactory.cs b/tests/XBase.Demo.App.Tests/DemoHostFactory.cs new file mode 100644 index 0000000..8f0f72f --- /dev/null +++ b/tests/XBase.Demo.App.Tests/DemoHostFactory.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using XBase.Demo.App.DependencyInjection; + +namespace XBase.Demo.App.Tests; + +internal static class DemoHostFactory +{ + public static IHost CreateHost() + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddDemoApp(); + return builder.Build(); + } +} diff --git a/tests/XBase.Demo.App.Tests/IndexManagerViewModelTests.cs b/tests/XBase.Demo.App.Tests/IndexManagerViewModelTests.cs new file mode 100644 index 0000000..939e043 --- /dev/null +++ b/tests/XBase.Demo.App.Tests/IndexManagerViewModelTests.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using XBase.Demo.App.ViewModels; +using XBase.Demo.Domain.Catalog; +using XBase.Demo.Domain.Services; + +namespace XBase.Demo.App.Tests; + +public sealed class IndexManagerViewModelTests +{ + [Fact] + public async Task CreateAndDropIndexCommand_ManagesPlaceholderFiles() + { + using var catalog = new TempCatalog(); + catalog.AddTable("PRODUCTS", addPlaceholderIndex: false); + + using var host = DemoHostFactory.CreateHost(); + await host.StartAsync(); + + try + { + var indexManager = host.Services.GetRequiredService(); + var catalogService = host.Services.GetRequiredService(); + var catalogModel = await catalogService.LoadCatalogAsync(catalog.Path); + var tableModel = Assert.Single(catalogModel.Tables); + var tableViewModel = new TableListItemViewModel(tableModel); + + indexManager.SetTargetTable(tableViewModel); + indexManager.IndexName = "PRODUCTS.NTX"; + indexManager.IndexExpression = "UPPER(NAME)"; + + var createResult = await indexManager.CreateIndexCommand.Execute().ToTask(); + + Assert.True(createResult.Succeeded, createResult.Message); + Assert.True(File.Exists(Path.Combine(catalog.Path, "PRODUCTS.NTX"))); + Assert.Null(indexManager.ErrorMessage); + Assert.NotNull(indexManager.StatusMessage); + + var indexViewModel = new IndexListItemViewModel(new IndexModel("PRODUCTS.NTX", "NTX")); + var dropResult = await indexManager.DropIndexCommand.Execute(indexViewModel).ToTask(); + + Assert.True(dropResult.Succeeded, dropResult.Message); + Assert.False(File.Exists(Path.Combine(catalog.Path, "PRODUCTS.NTX"))); + } + finally + { + await host.StopAsync(); + } + } + + [Fact] + public async Task DropIndexCommand_ForMissingIndex_SurfacesError() + { + using var catalog = new TempCatalog(); + catalog.AddTable("CUSTOMERS", addPlaceholderIndex: false); + + using var host = DemoHostFactory.CreateHost(); + await host.StartAsync(); + + try + { + var indexManager = host.Services.GetRequiredService(); + var catalogService = host.Services.GetRequiredService(); + var catalogModel = await catalogService.LoadCatalogAsync(catalog.Path); + var tableModel = Assert.Single(catalogModel.Tables); + var tableViewModel = new TableListItemViewModel(tableModel); + + indexManager.SetTargetTable(tableViewModel); + + var indexViewModel = new IndexListItemViewModel(new IndexModel("CUSTOMERS.NTX", "NTX")); + var dropResult = await indexManager.DropIndexCommand.Execute(indexViewModel).ToTask(); + + Assert.False(dropResult.Succeeded); + Assert.NotNull(indexManager.ErrorMessage); + Assert.Contains("not found", indexManager.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + finally + { + await host.StopAsync(); + } + } +} diff --git a/tests/XBase.Demo.App.Tests/SchemaDesignerViewModelTests.cs b/tests/XBase.Demo.App.Tests/SchemaDesignerViewModelTests.cs new file mode 100644 index 0000000..5360a80 --- /dev/null +++ b/tests/XBase.Demo.App.Tests/SchemaDesignerViewModelTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Reactive.Threading.Tasks; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using XBase.Demo.App.ViewModels; +using XBase.Demo.Domain.Schema; + +namespace XBase.Demo.App.Tests; + +public sealed class SchemaDesignerViewModelTests +{ + [Fact] + public async Task GenerateCreatePreviewCommand_WithColumns_ProducesScript() + { + using var host = DemoHostFactory.CreateHost(); + await host.StartAsync(); + + try + { + var viewModel = host.Services.GetRequiredService(); + viewModel.TableName = "PRODUCTS"; + viewModel.SetColumns( + new SchemaColumnViewModel("ID", "NUMERIC", allowNulls: false, length: 8, scale: 0), + new SchemaColumnViewModel("NAME", "CHAR", length: 50)); + + var preview = await viewModel.GenerateCreatePreviewCommand.Execute().ToTask(); + + Assert.Equal("CreateTable", preview.Operation); + Assert.Contains("CREATE TABLE PRODUCTS", viewModel.PreviewUpScript, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DROP TABLE PRODUCTS", viewModel.PreviewDownScript, StringComparison.OrdinalIgnoreCase); + Assert.Null(viewModel.ErrorMessage); + } + finally + { + await host.StopAsync(); + } + } + + [Fact] + public async Task GenerateAlterPreviewCommand_WithAddChange_ProducesScript() + { + using var host = DemoHostFactory.CreateHost(); + await host.StartAsync(); + + try + { + var viewModel = host.Services.GetRequiredService(); + viewModel.TableName = "CUSTOMERS"; + viewModel.SetAlterations( + new SchemaColumnChangeViewModel( + ColumnChangeOperation.Add, + "EMAIL", + new SchemaColumnViewModel("EMAIL", "CHAR", length: 60))); + + var preview = await viewModel.GenerateAlterPreviewCommand.Execute().ToTask(); + + Assert.Equal("AlterTable", preview.Operation); + Assert.Contains("ADD COLUMN EMAIL", viewModel.PreviewUpScript, StringComparison.OrdinalIgnoreCase); + Assert.Contains("DROP COLUMN EMAIL", viewModel.PreviewDownScript, StringComparison.OrdinalIgnoreCase); + Assert.Null(viewModel.ErrorMessage); + } + finally + { + await host.StopAsync(); + } + } + + [Fact] + public async Task GenerateDropPreviewCommand_WithTable_SetsPreview() + { + using var host = DemoHostFactory.CreateHost(); + await host.StartAsync(); + + try + { + var viewModel = host.Services.GetRequiredService(); + viewModel.TableName = "ORDERS"; + + var preview = await viewModel.GenerateDropPreviewCommand.Execute().ToTask(); + + Assert.Equal("DropTable", preview.Operation); + Assert.Contains("DROP TABLE ORDERS", viewModel.PreviewUpScript, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CREATE", viewModel.PreviewDownScript, StringComparison.OrdinalIgnoreCase); + } + finally + { + await host.StopAsync(); + } + } +} diff --git a/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs b/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs index 6f0a750..54781a8 100644 --- a/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs +++ b/tests/XBase.Demo.App.Tests/ShellViewModelTests.cs @@ -1,12 +1,9 @@ using System; -using System.IO; using System.Linq; using System.Reactive.Threading.Tasks; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using Xunit; -using XBase.Demo.App.DependencyInjection; using XBase.Demo.App.ViewModels; namespace XBase.Demo.App.Tests; @@ -19,7 +16,7 @@ public async Task OpenCatalogCommand_LoadsTablesAndTelemetry() using var catalog = new TempCatalog(); catalog.AddTable("CUSTOMERS"); - using var host = CreateHost(); + using var host = DemoHostFactory.CreateHost(); await host.StartAsync(); try @@ -48,45 +45,4 @@ public async Task OpenCatalogCommand_LoadsTablesAndTelemetry() } } - private static IHost CreateHost() - { - var builder = Host.CreateApplicationBuilder(); - builder.Services.AddDemoApp(); - return builder.Build(); - } - - private sealed class TempCatalog : IDisposable - { - private readonly DirectoryInfo _directory; - - public TempCatalog() - { - _directory = Directory.CreateTempSubdirectory("xbase-demo-"); - } - - public string Path => _directory.FullName; - - public void AddTable(string tableName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(tableName); - - var tablePath = System.IO.Path.Combine(Path, tableName + ".dbf"); - File.WriteAllBytes(tablePath, Array.Empty()); - - var indexPath = System.IO.Path.Combine(Path, tableName + ".ntx"); - File.WriteAllBytes(indexPath, Array.Empty()); - } - - public void Dispose() - { - try - { - _directory.Delete(true); - } - catch - { - // best effort cleanup - } - } - } } diff --git a/tests/XBase.Demo.App.Tests/TempCatalog.cs b/tests/XBase.Demo.App.Tests/TempCatalog.cs new file mode 100644 index 0000000..5af2cd7 --- /dev/null +++ b/tests/XBase.Demo.App.Tests/TempCatalog.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; + +namespace XBase.Demo.App.Tests; + +internal sealed class TempCatalog : IDisposable +{ + private readonly DirectoryInfo _directory; + + public TempCatalog() + { + _directory = Directory.CreateTempSubdirectory("xbase-demo-"); + } + + public string Path => _directory.FullName; + + public void AddTable(string tableName, bool addPlaceholderIndex = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + + var tablePath = System.IO.Path.Combine(Path, tableName + ".dbf"); + File.WriteAllBytes(tablePath, Array.Empty()); + + if (addPlaceholderIndex) + { + AddIndex(tableName, tableName + ".ntx"); + } + } + + public void AddIndex(string tableName, string indexName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + ArgumentException.ThrowIfNullOrWhiteSpace(indexName); + + var indexPath = System.IO.Path.Combine(Path, indexName); + File.WriteAllBytes(indexPath, Array.Empty()); + } + + public void Dispose() + { + try + { + _directory.Delete(true); + } + catch + { + // best effort cleanup + } + } +}