diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs new file mode 100644 index 0000000..8ef51a2 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ISiteRepository.cs @@ -0,0 +1,35 @@ +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Sites; + +namespace ScadaLink.Commons.Interfaces.Repositories; + +/// +/// Repository interface for site and data connection management. +/// +public interface ISiteRepository +{ + // Sites + Task GetSiteByIdAsync(int id, CancellationToken cancellationToken = default); + Task GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default); + Task> GetAllSitesAsync(CancellationToken cancellationToken = default); + Task AddSiteAsync(Site site, CancellationToken cancellationToken = default); + Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default); + Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default); + + // Data Connections + Task GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default); + Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); + Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); + Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default); + Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default); + + // Site-Connection Assignments + Task GetSiteDataConnectionAssignmentAsync(int siteId, int dataConnectionId, CancellationToken cancellationToken = default); + Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default); + Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default); + + // Instances (for deletion constraint checks) + Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs index ceedde9..f11c2a2 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/ITemplateEngineRepository.cs @@ -1,4 +1,5 @@ using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; namespace ScadaLink.Commons.Interfaces.Repositories; @@ -70,5 +71,13 @@ public interface ITemplateEngineRepository Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default); Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default); + // SharedScript + Task GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default); + Task GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default); + Task> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default); + Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default); + Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default); + Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs b/src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs new file mode 100644 index 0000000..af9a68d --- /dev/null +++ b/src/ScadaLink.Commons/Types/Flattening/ConfigurationDiff.cs @@ -0,0 +1,43 @@ +namespace ScadaLink.Commons.Types.Flattening; + +/// +/// Represents the difference between two FlattenedConfigurations (typically deployed vs current). +/// Used for incremental deployment decisions and change review. +/// +public sealed record ConfigurationDiff +{ + public string InstanceUniqueName { get; init; } = string.Empty; + public string? OldRevisionHash { get; init; } + public string? NewRevisionHash { get; init; } + public bool HasChanges => AttributeChanges.Count > 0 || AlarmChanges.Count > 0 || ScriptChanges.Count > 0; + + public IReadOnlyList> AttributeChanges { get; init; } = []; + public IReadOnlyList> AlarmChanges { get; init; } = []; + public IReadOnlyList> ScriptChanges { get; init; } = []; +} + +/// +/// A single diff entry showing what changed for a named entity. +/// +public sealed record DiffEntry +{ + public string CanonicalName { get; init; } = string.Empty; + public DiffChangeType ChangeType { get; init; } + + /// + /// The previous value (null for Added entries). + /// + public T? OldValue { get; init; } + + /// + /// The new value (null for Removed entries). + /// + public T? NewValue { get; init; } +} + +public enum DiffChangeType +{ + Added, + Removed, + Changed +} diff --git a/src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs b/src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs new file mode 100644 index 0000000..1f1898f --- /dev/null +++ b/src/ScadaLink.Commons/Types/Flattening/DeploymentPackage.cs @@ -0,0 +1,61 @@ +namespace ScadaLink.Commons.Types.Flattening; + +/// +/// The complete deployment package for an instance, transmitted to site clusters and stored in SQLite. +/// Contains the flattened configuration, revision hash, and deployment metadata. +/// +/// JSON serialization format: +/// { +/// "instanceUniqueName": "PumpStation1", +/// "deploymentId": "dep-abc123", +/// "revisionHash": "sha256:...", +/// "deployedBy": "admin@company.com", +/// "deployedAtUtc": "2026-03-16T12:00:00Z", +/// "configuration": { /* FlattenedConfiguration */ }, +/// "diff": { /* ConfigurationDiff, null for first deployment */ }, +/// "previousRevisionHash": null +/// } +/// +public sealed record DeploymentPackage +{ + /// + /// The unique name of the instance being deployed. + /// + public string InstanceUniqueName { get; init; } = string.Empty; + + /// + /// Unique deployment ID for idempotency (deployment ID + revision hash). + /// + public string DeploymentId { get; init; } = string.Empty; + + /// + /// SHA-256 hash of the flattened configuration for staleness detection. + /// + public string RevisionHash { get; init; } = string.Empty; + + /// + /// The user who initiated the deployment. + /// + public string DeployedBy { get; init; } = string.Empty; + + /// + /// UTC timestamp when the deployment was created. + /// + public DateTimeOffset DeployedAtUtc { get; init; } + + /// + /// The fully resolved configuration to deploy. + /// + public FlattenedConfiguration Configuration { get; init; } = new(); + + /// + /// Diff against the previously deployed configuration. Null for first-time deployments. + /// + public ConfigurationDiff? Diff { get; init; } + + /// + /// The revision hash of the previously deployed configuration, if any. + /// Used for optimistic concurrency on deployment status records. + /// + public string? PreviousRevisionHash { get; init; } +} diff --git a/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs b/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs new file mode 100644 index 0000000..c68e499 --- /dev/null +++ b/src/ScadaLink.Commons/Types/Flattening/FlattenedConfiguration.cs @@ -0,0 +1,111 @@ +using System.Text.Json.Serialization; + +namespace ScadaLink.Commons.Types.Flattening; + +/// +/// The fully resolved configuration for a single instance, produced by the flattening algorithm. +/// All inheritance, composition, overrides, and connection bindings have been applied. +/// This is the canonical representation sent to sites for deployment. +/// +public sealed record FlattenedConfiguration +{ + public string InstanceUniqueName { get; init; } = string.Empty; + public int TemplateId { get; init; } + public int SiteId { get; init; } + public int? AreaId { get; init; } + public IReadOnlyList Attributes { get; init; } = []; + public IReadOnlyList Alarms { get; init; } = []; + public IReadOnlyList Scripts { get; init; } = []; + public DateTimeOffset GeneratedAtUtc { get; init; } = DateTimeOffset.UtcNow; +} + +/// +/// A fully resolved attribute with its canonical name, value, data type, and optional data source binding. +/// +public sealed record ResolvedAttribute +{ + /// + /// Path-qualified canonical name. For composed modules: "[ModuleInstanceName].[MemberName]". + /// For direct attributes: just the attribute name. + /// + public string CanonicalName { get; init; } = string.Empty; + + public string? Value { get; init; } + public string DataType { get; init; } = string.Empty; + public bool IsLocked { get; init; } + public string? Description { get; init; } + + /// + /// If data-sourced: the relative tag path within the connection. + /// + public string? DataSourceReference { get; init; } + + /// + /// If data-sourced: the resolved connection ID from the instance binding. + /// + public int? BoundDataConnectionId { get; init; } + + /// + /// If data-sourced: the resolved connection name. + /// + public string? BoundDataConnectionName { get; init; } + + /// + /// If data-sourced: the connection protocol (e.g. "OpcUa"). + /// + public string? BoundDataConnectionProtocol { get; init; } + + /// + /// The source of this attribute value: "Template", "Inherited", "Composed", "Override". + /// + public string Source { get; init; } = "Template"; +} + +/// +/// A fully resolved alarm with trigger definition containing resolved attribute references. +/// +public sealed record ResolvedAlarm +{ + public string CanonicalName { get; init; } = string.Empty; + public string? Description { get; init; } + public int PriorityLevel { get; init; } + public bool IsLocked { get; init; } + public string TriggerType { get; init; } = string.Empty; + + /// + /// JSON trigger configuration with resolved attribute references (canonical names). + /// + public string? TriggerConfiguration { get; init; } + + /// + /// Canonical name of the on-trigger script, if any. + /// + public string? OnTriggerScriptCanonicalName { get; init; } + + public string Source { get; init; } = "Template"; +} + +/// +/// A fully resolved script with code, trigger config, parameters, and return definition. +/// +public sealed record ResolvedScript +{ + public string CanonicalName { get; init; } = string.Empty; + public string Code { get; init; } = string.Empty; + public bool IsLocked { get; init; } + public string? TriggerType { get; init; } + public string? TriggerConfiguration { get; init; } + + /// + /// JSON-serialized parameter definitions. + /// + public string? ParameterDefinitions { get; init; } + + /// + /// JSON-serialized return type definition. + /// + public string? ReturnDefinition { get; init; } + + public TimeSpan? MinTimeBetweenRuns { get; init; } + public string Source { get; init; } = "Template"; +} diff --git a/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs b/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs new file mode 100644 index 0000000..1414407 --- /dev/null +++ b/src/ScadaLink.Commons/Types/Flattening/ValidationResult.cs @@ -0,0 +1,64 @@ +namespace ScadaLink.Commons.Types.Flattening; + +/// +/// Result of pre-deployment or on-demand validation with categorized errors and warnings. +/// +public sealed record ValidationResult +{ + public bool IsValid => Errors.Count == 0; + public IReadOnlyList Errors { get; init; } = []; + public IReadOnlyList Warnings { get; init; } = []; + + public static ValidationResult Success() => new(); + + public static ValidationResult FromErrors(params ValidationEntry[] errors) => + new() { Errors = errors }; + + public static ValidationResult Merge(params ValidationResult[] results) + { + var errors = new List(); + var warnings = new List(); + foreach (var r in results) + { + errors.AddRange(r.Errors); + warnings.AddRange(r.Warnings); + } + return new ValidationResult { Errors = errors, Warnings = warnings }; + } +} + +/// +/// A single validation error or warning with category and context. +/// +public sealed record ValidationEntry +{ + public ValidationCategory Category { get; init; } + public string Message { get; init; } = string.Empty; + + /// + /// The canonical name of the entity that caused the validation issue, if applicable. + /// + public string? EntityName { get; init; } + + public static ValidationEntry Error(ValidationCategory category, string message, string? entityName = null) => + new() { Category = category, Message = message, EntityName = entityName }; + + public static ValidationEntry Warning(ValidationCategory category, string message, string? entityName = null) => + new() { Category = category, Message = message, EntityName = entityName }; +} + +public enum ValidationCategory +{ + FlatteningFailure, + NamingCollision, + ScriptCompilation, + AlarmTriggerReference, + ScriptTriggerReference, + ConnectionBinding, + CallTargetNotFound, + ParameterMismatch, + ReturnTypeMismatch, + TriggerOperandType, + OnTriggerScriptNotFound, + CrossCallViolation +} diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs new file mode 100644 index 0000000..d9aaf3c --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Deployment; +using ScadaLink.Commons.Interfaces.Repositories; + +namespace ScadaLink.ConfigurationDatabase.Repositories; + +/// +/// EF Core implementation of IDeploymentManagerRepository. +/// Provides storage/query of deployed configuration snapshots per instance, +/// current deployment status, and optimistic concurrency on deployment status records. +/// +/// WP-24: Stub level sufficient for diff/staleness support. +/// +public class DeploymentManagerRepository : IDeploymentManagerRepository +{ + private readonly ScadaLinkDbContext _dbContext; + + public DeploymentManagerRepository(ScadaLinkDbContext dbContext) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + // --- DeploymentRecord --- + + public async Task GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken); + } + + public async Task> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.DeploymentRecords + .OrderByDescending(d => d.DeployedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) + { + return await _dbContext.DeploymentRecords + .Where(d => d.InstanceId == instanceId) + .OrderByDescending(d => d.DeployedAt) + .ToListAsync(cancellationToken); + } + + /// + /// Gets the most recent deployment record for an instance (current deployment status). + /// Used for staleness detection by comparing revision hashes. + /// + public async Task GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default) + { + return await _dbContext.DeploymentRecords + .Where(d => d.InstanceId == instanceId) + .OrderByDescending(d => d.DeployedAt) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default) + { + return await _dbContext.DeploymentRecords + .FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken); + } + + public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default) + { + await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken); + } + + /// + /// Updates a deployment record. Uses optimistic concurrency on deployment status records — + /// EF Core's change tracking will detect concurrent modifications via the row version/concurrency token + /// configured in the entity configuration. + /// + public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default) + { + _dbContext.DeploymentRecords.Update(record); + return Task.CompletedTask; + } + + public Task DeleteDeploymentRecordAsync(int id, CancellationToken cancellationToken = default) + { + var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id); + if (record != null) + { + _dbContext.DeploymentRecords.Remove(record); + } + else + { + var stub = new DeploymentRecord("stub", "stub") { Id = id }; + _dbContext.DeploymentRecords.Attach(stub); + _dbContext.DeploymentRecords.Remove(stub); + } + return Task.CompletedTask; + } + + // --- SystemArtifactDeploymentRecord --- + + public async Task GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken); + } + + public async Task> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.SystemArtifactDeploymentRecords + .OrderByDescending(d => d.DeployedAt) + .ToListAsync(cancellationToken); + } + + public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default) + { + await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken); + } + + public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default) + { + _dbContext.SystemArtifactDeploymentRecords.Update(record); + return Task.CompletedTask; + } + + public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default) + { + var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id); + if (record != null) + { + _dbContext.SystemArtifactDeploymentRecords.Remove(record); + } + else + { + var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id }; + _dbContext.SystemArtifactDeploymentRecords.Attach(stub); + _dbContext.SystemArtifactDeploymentRecords.Remove(stub); + } + return Task.CompletedTask; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs new file mode 100644 index 0000000..93bdb56 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/SiteRepository.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Sites; +using ScadaLink.Commons.Interfaces.Repositories; + +namespace ScadaLink.ConfigurationDatabase.Repositories; + +/// +/// EF Core implementation of ISiteRepository for site and data connection management. +/// +public class SiteRepository : ISiteRepository +{ + private readonly ScadaLinkDbContext _dbContext; + + public SiteRepository(ScadaLinkDbContext dbContext) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + // --- Sites --- + + public async Task GetSiteByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _dbContext.Sites.FindAsync([id], cancellationToken); + } + + public async Task GetSiteByIdentifierAsync(string siteIdentifier, CancellationToken cancellationToken = default) + { + return await _dbContext.Sites + .FirstOrDefaultAsync(s => s.SiteIdentifier == siteIdentifier, cancellationToken); + } + + public async Task> GetAllSitesAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.Sites.OrderBy(s => s.Name).ToListAsync(cancellationToken); + } + + public async Task AddSiteAsync(Site site, CancellationToken cancellationToken = default) + { + await _dbContext.Sites.AddAsync(site, cancellationToken); + } + + public Task UpdateSiteAsync(Site site, CancellationToken cancellationToken = default) + { + _dbContext.Sites.Update(site); + return Task.CompletedTask; + } + + public Task DeleteSiteAsync(int id, CancellationToken cancellationToken = default) + { + var entity = _dbContext.Sites.Local.FirstOrDefault(s => s.Id == id); + if (entity != null) + { + _dbContext.Sites.Remove(entity); + } + else + { + var stub = new Site("stub", "stub") { Id = id }; + _dbContext.Sites.Attach(stub); + _dbContext.Sites.Remove(stub); + } + return Task.CompletedTask; + } + + // --- Data Connections --- + + public async Task GetDataConnectionByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _dbContext.DataConnections.FindAsync([id], cancellationToken); + } + + public async Task> GetDataConnectionsBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + var connectionIds = await _dbContext.SiteDataConnectionAssignments + .Where(a => a.SiteId == siteId) + .Select(a => a.DataConnectionId) + .ToListAsync(cancellationToken); + + return await _dbContext.DataConnections + .Where(c => connectionIds.Contains(c.Id)) + .OrderBy(c => c.Name) + .ToListAsync(cancellationToken); + } + + public async Task AddDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default) + { + await _dbContext.DataConnections.AddAsync(connection, cancellationToken); + } + + public Task UpdateDataConnectionAsync(DataConnection connection, CancellationToken cancellationToken = default) + { + _dbContext.DataConnections.Update(connection); + return Task.CompletedTask; + } + + public Task DeleteDataConnectionAsync(int id, CancellationToken cancellationToken = default) + { + var entity = _dbContext.DataConnections.Local.FirstOrDefault(c => c.Id == id); + if (entity != null) + { + _dbContext.DataConnections.Remove(entity); + } + else + { + var stub = new DataConnection("stub", "stub") { Id = id }; + _dbContext.DataConnections.Attach(stub); + _dbContext.DataConnections.Remove(stub); + } + return Task.CompletedTask; + } + + // --- Site-Connection Assignments --- + + public async Task GetSiteDataConnectionAssignmentAsync( + int siteId, int dataConnectionId, CancellationToken cancellationToken = default) + { + return await _dbContext.SiteDataConnectionAssignments + .FirstOrDefaultAsync(a => a.SiteId == siteId && a.DataConnectionId == dataConnectionId, cancellationToken); + } + + public async Task AddSiteDataConnectionAssignmentAsync(SiteDataConnectionAssignment assignment, CancellationToken cancellationToken = default) + { + await _dbContext.SiteDataConnectionAssignments.AddAsync(assignment, cancellationToken); + } + + public Task DeleteSiteDataConnectionAssignmentAsync(int id, CancellationToken cancellationToken = default) + { + var entity = _dbContext.SiteDataConnectionAssignments.Local.FirstOrDefault(a => a.Id == id); + if (entity != null) + { + _dbContext.SiteDataConnectionAssignments.Remove(entity); + } + else + { + var stub = new SiteDataConnectionAssignment { Id = id }; + _dbContext.SiteDataConnectionAssignments.Attach(stub); + _dbContext.SiteDataConnectionAssignments.Remove(stub); + } + return Task.CompletedTask; + } + + // --- Instances (for deletion constraint checks) --- + + public async Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + return await _dbContext.Instances + .Where(i => i.SiteId == siteId) + .ToListAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs new file mode 100644 index 0000000..2bdf5b5 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/TemplateEngineRepository.cs @@ -0,0 +1,408 @@ +using Microsoft.EntityFrameworkCore; +using ScadaLink.Commons.Entities.Instances; +using ScadaLink.Commons.Entities.Scripts; +using ScadaLink.Commons.Entities.Templates; +using ScadaLink.Commons.Interfaces.Repositories; + +namespace ScadaLink.ConfigurationDatabase.Repositories; + +public class TemplateEngineRepository : ITemplateEngineRepository +{ + private readonly ScadaLinkDbContext _context; + + public TemplateEngineRepository(ScadaLinkDbContext context) + { + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + // Template + + public async Task GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Templates + .Include(t => t.Attributes) + .Include(t => t.Alarms) + .Include(t => t.Scripts) + .Include(t => t.Compositions) + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + public async Task GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default) + { + var template = await GetTemplateByIdAsync(id, cancellationToken); + if (template == null) return null; + + // Load all templates that have this template as parent + var children = await _context.Templates + .Where(t => t.ParentTemplateId == id) + .ToListAsync(cancellationToken); + + return template; + } + + public async Task> GetAllTemplatesAsync(CancellationToken cancellationToken = default) + { + return await _context.Templates + .Include(t => t.Attributes) + .Include(t => t.Alarms) + .Include(t => t.Scripts) + .Include(t => t.Compositions) + .ToListAsync(cancellationToken); + } + + public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default) + { + await _context.Templates.AddAsync(template, cancellationToken); + } + + public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default) + { + _context.Templates.Update(template); + return Task.CompletedTask; + } + + public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default) + { + var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken); + if (template != null) + { + _context.Templates.Remove(template); + } + } + + // TemplateAttribute + + public async Task GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default) + { + return await _context.TemplateAttributes + .Where(a => a.TemplateId == templateId) + .ToListAsync(cancellationToken); + } + + public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default) + { + await _context.TemplateAttributes.AddAsync(attribute, cancellationToken); + } + + public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default) + { + _context.TemplateAttributes.Update(attribute); + return Task.CompletedTask; + } + + public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default) + { + var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken); + if (attribute != null) + { + _context.TemplateAttributes.Remove(attribute); + } + } + + // TemplateAlarm + + public async Task GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default) + { + return await _context.TemplateAlarms + .Where(a => a.TemplateId == templateId) + .ToListAsync(cancellationToken); + } + + public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default) + { + await _context.TemplateAlarms.AddAsync(alarm, cancellationToken); + } + + public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default) + { + _context.TemplateAlarms.Update(alarm); + return Task.CompletedTask; + } + + public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default) + { + var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken); + if (alarm != null) + { + _context.TemplateAlarms.Remove(alarm); + } + } + + // TemplateScript + + public async Task GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default) + { + return await _context.TemplateScripts + .Where(s => s.TemplateId == templateId) + .ToListAsync(cancellationToken); + } + + public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default) + { + await _context.TemplateScripts.AddAsync(script, cancellationToken); + } + + public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default) + { + _context.TemplateScripts.Update(script); + return Task.CompletedTask; + } + + public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default) + { + var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken); + if (script != null) + { + _context.TemplateScripts.Remove(script); + } + } + + // TemplateComposition + + public async Task GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default) + { + return await _context.TemplateCompositions + .Where(c => c.TemplateId == templateId) + .ToListAsync(cancellationToken); + } + + public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default) + { + await _context.TemplateCompositions.AddAsync(composition, cancellationToken); + } + + public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default) + { + _context.TemplateCompositions.Update(composition); + return Task.CompletedTask; + } + + public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default) + { + var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken); + if (composition != null) + { + _context.TemplateCompositions.Remove(composition); + } + } + + // Instance + + public async Task GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Instances + .Include(i => i.AttributeOverrides) + .Include(i => i.ConnectionBindings) + .FirstOrDefaultAsync(i => i.Id == id, cancellationToken); + } + + public async Task> GetAllInstancesAsync(CancellationToken cancellationToken = default) + { + return await _context.Instances + .Include(i => i.AttributeOverrides) + .Include(i => i.ConnectionBindings) + .ToListAsync(cancellationToken); + } + + public async Task> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default) + { + return await _context.Instances + .Where(i => i.TemplateId == templateId) + .ToListAsync(cancellationToken); + } + + public async Task> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + return await _context.Instances + .Where(i => i.SiteId == siteId) + .Include(i => i.AttributeOverrides) + .Include(i => i.ConnectionBindings) + .ToListAsync(cancellationToken); + } + + public async Task GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default) + { + return await _context.Instances + .Include(i => i.AttributeOverrides) + .Include(i => i.ConnectionBindings) + .FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken); + } + + public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default) + { + await _context.Instances.AddAsync(instance, cancellationToken); + } + + public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default) + { + _context.Instances.Update(instance); + return Task.CompletedTask; + } + + public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default) + { + var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken); + if (instance != null) + { + _context.Instances.Remove(instance); + } + } + + // InstanceAttributeOverride + + public async Task> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) + { + return await _context.InstanceAttributeOverrides + .Where(o => o.InstanceId == instanceId) + .ToListAsync(cancellationToken); + } + + public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default) + { + await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken); + } + + public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default) + { + _context.InstanceAttributeOverrides.Update(attributeOverride); + return Task.CompletedTask; + } + + public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default) + { + var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken); + if (attributeOverride != null) + { + _context.InstanceAttributeOverrides.Remove(attributeOverride); + } + } + + // InstanceConnectionBinding + + public async Task> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) + { + return await _context.InstanceConnectionBindings + .Where(b => b.InstanceId == instanceId) + .ToListAsync(cancellationToken); + } + + public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default) + { + await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken); + } + + public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default) + { + _context.InstanceConnectionBindings.Update(binding); + return Task.CompletedTask; + } + + public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default) + { + var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken); + if (binding != null) + { + _context.InstanceConnectionBindings.Remove(binding); + } + } + + // Area + + public async Task GetAreaByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.Areas + .Include(a => a.Children) + .FirstOrDefaultAsync(a => a.Id == id, cancellationToken); + } + + public async Task> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) + { + return await _context.Areas + .Where(a => a.SiteId == siteId) + .Include(a => a.Children) + .ToListAsync(cancellationToken); + } + + public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default) + { + await _context.Areas.AddAsync(area, cancellationToken); + } + + public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default) + { + _context.Areas.Update(area); + return Task.CompletedTask; + } + + public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default) + { + var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken); + if (area != null) + { + _context.Areas.Remove(area); + } + } + + // SharedScript + + public async Task GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default) + { + return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default) + { + return await _context.SharedScripts + .FirstOrDefaultAsync(s => s.Name == name, cancellationToken); + } + + public async Task> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default) + { + return await _context.SharedScripts.ToListAsync(cancellationToken); + } + + public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default) + { + await _context.SharedScripts.AddAsync(sharedScript, cancellationToken); + } + + public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default) + { + _context.SharedScripts.Update(sharedScript); + return Task.CompletedTask; + } + + public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default) + { + var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken); + if (sharedScript != null) + { + _context.SharedScripts.Remove(sharedScript); + } + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return await _context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs index 30a1761..59e33d8 100644 --- a/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddDataProtection() diff --git a/src/ScadaLink.TemplateEngine/CollisionDetector.cs b/src/ScadaLink.TemplateEngine/CollisionDetector.cs new file mode 100644 index 0000000..d6bfb7c --- /dev/null +++ b/src/ScadaLink.TemplateEngine/CollisionDetector.cs @@ -0,0 +1,159 @@ +using ScadaLink.Commons.Entities.Templates; + +namespace ScadaLink.TemplateEngine; + +/// +/// Detects naming collisions across composed module members using canonical (path-qualified) names. +/// Two members from different composed modules collide if they produce the same canonical name. +/// Members from different module instance names cannot collide because the prefix differentiates them. +/// +public static class CollisionDetector +{ + /// + /// Represents a resolved member with its canonical name and origin. + /// + public sealed record ResolvedMember( + string CanonicalName, + string MemberType, // "Attribute", "Alarm", "Script" + string OriginDescription); + + /// + /// Detects naming collisions among all members (direct + composed) of a template. + /// + /// The template to check. + /// All templates in the system (for resolving composed templates). + /// List of collision descriptions. Empty if no collisions. + public static IReadOnlyList DetectCollisions( + Template template, + IReadOnlyList