diff --git a/.gitignore b/.gitignore index 44174d824d..199f032d09 100644 --- a/.gitignore +++ b/.gitignore @@ -183,4 +183,6 @@ Source/Csla.Xaml.Uwp/project.lock.json .vscode .idea Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor/PTracker.db +Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor/PTracker.db-shm +Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor/PTracker.db-wal .claude/settings.local.json diff --git a/Samples/ProjectTracker/Directory.Packages.props b/Samples/ProjectTracker/Directory.Packages.props index 321db05d01..8edecb1955 100644 --- a/Samples/ProjectTracker/Directory.Packages.props +++ b/Samples/ProjectTracker/Directory.Packages.props @@ -1,7 +1,7 @@ true - 10.0.0-beta-0022-gbd53196668 + 10.1.0-g660c07ebcb diff --git a/Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor.Client/Pages/EditProject.razor b/Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor.Client/Pages/EditProject.razor index 4eb146f8b9..8fdc666f5f 100644 --- a/Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor.Client/Pages/EditProject.razor +++ b/Samples/ProjectTracker/ProjectTracker.Blazor/ProjectTracker.Blazor.Client/Pages/EditProject.razor @@ -126,7 +126,7 @@ else - @foreach (var item in vm.Model.Resources) + @foreach (var item in vm.Model) { @item.FirstName @@ -195,7 +195,7 @@ else viewMode = SubViewModes.Select; var portal = ApplicationContext.GetRequiredService>(); _resourceList = (await portal.FetchAsync()) - .Where(r => !vm.Model.Resources.Contains(r.Id)).ToList(); + .Where(r => !vm.Model.Contains(r.Id)).ToList(); StateHasChanged(); } @@ -219,14 +219,14 @@ else private void AddResource() { selectedResource.ApplyEdit(); - if (!vm.Model.Resources.Contains(selectedResource.ResourceId)) - vm.Model.Resources.Add(selectedResource); + if (!vm.Model.Contains(selectedResource.ResourceId)) + vm.Model.Add(selectedResource); ShowDefaultView(); } private void EditResource(int resourceId) { - selectedResource = vm.Model.Resources + selectedResource = vm.Model .Where(r => r.ResourceId == resourceId).FirstOrDefault(); if (selectedResource != null) { @@ -237,6 +237,6 @@ else private void RemoveResource(int resourceId) { - vm.Model.Resources.Remove(resourceId); + vm.Model.Remove(resourceId); } } \ No newline at end of file diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditList.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditList.cs index a51fdec4d1..9c54ad8df1 100644 --- a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditList.cs +++ b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditList.cs @@ -10,7 +10,7 @@ namespace Admin /// Used to maintain the list of roles /// in the system. /// - public class RoleEditList : BusinessListBase + public class RoleEditList : CslaBaseTypes.BusinessDocumentBase { /// /// Remove a role based on the role's @@ -65,11 +65,8 @@ private void Fetch([Inject] IRoleDal dal, [Inject] IChildDataPortal ro [Update] private void Update() { - using (LoadListMode) - { - Child_Update(); - } + Child_Update(); } } } -} \ No newline at end of file +} diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditManager.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditManager.cs index 5cd8166ef9..280901b1f2 100644 --- a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditManager.cs +++ b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/Admin/RoleEditManager.cs @@ -49,7 +49,7 @@ private void SaveRoleEdit() list.Add(RoleEdit); else item.Name = RoleEdit.Name; - list.Save(); + list = (RoleEditList)list.Save(); } } } \ No newline at end of file diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/CslaBaseTypes/BusinessDocumentBase.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/CslaBaseTypes/BusinessDocumentBase.cs new file mode 100644 index 0000000000..33bf2b9e7f --- /dev/null +++ b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/CslaBaseTypes/BusinessDocumentBase.cs @@ -0,0 +1,12 @@ +using System; + +namespace ProjectTracker.Library.CslaBaseTypes +{ + [Serializable] + public abstract class BusinessDocumentBase : Csla.BusinessDocumentBase + where T : Csla.BusinessDocumentBase + where C : notnull, Csla.Core.IEditableBusinessObject + { + + } +} diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectEdit.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectEdit.cs index 08295f36b2..182353234d 100644 --- a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectEdit.cs +++ b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectEdit.cs @@ -8,7 +8,7 @@ namespace ProjectTracker.Library { [CslaImplementProperties] - public partial class ProjectEdit : CslaBaseTypes.BusinessBase + public partial class ProjectEdit : CslaBaseTypes.BusinessDocumentBase { [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] @@ -28,30 +28,53 @@ public partial class ProjectEdit : CslaBaseTypes.BusinessBase public partial string Description { get; set; } - public partial ProjectResources Resources { get; private set; } - public override string ToString() { return Id.ToString(); } + public async Task AssignAsync(int resourceId) + { + var portal = ApplicationContext.GetRequiredService>(); + var resourceCreator = await portal.FetchAsync(resourceId); + var resourceEdit = resourceCreator.ProjectResource; + this.Add(resourceEdit); + return resourceEdit; + } + + public void Remove(int resourceId) + { + var item = this.FirstOrDefault(r => r.ResourceId == resourceId); + if (item != null) + Remove(item); + } + + public bool Contains(int resourceId) + { + return this.Any(r => r.ResourceId == resourceId); + } + + public bool ContainsDeleted(int resourceId) + { + return DeletedList.Any(r => r.ResourceId == resourceId); + } + protected override void AddBusinessRules() { base.AddBusinessRules(); - //BusinessRules.AddRule(new Csla.Rules.CommonRules.Required(NameProperty)); BusinessRules.AddRule( - new StartDateGTEndDate { - PrimaryProperty = StartedProperty, + new StartDateGTEndDate { + PrimaryProperty = StartedProperty, AffectedProperties = { EndedProperty } }); BusinessRules.AddRule( - new StartDateGTEndDate { - PrimaryProperty = EndedProperty, + new StartDateGTEndDate { + PrimaryProperty = EndedProperty, AffectedProperties = { StartedProperty } }); BusinessRules.AddRule( new Csla.Rules.CommonRules.IsInRole( - Csla.Rules.AuthorizationActions.WriteProperty, - NameProperty, + Csla.Rules.AuthorizationActions.WriteProperty, + NameProperty, "ProjectManager")); BusinessRules.AddRule(new Csla.Rules.CommonRules.IsInRole( Csla.Rules.AuthorizationActions.WriteProperty, StartedProperty, Security.Roles.ProjectManager)); @@ -59,7 +82,6 @@ protected override void AddBusinessRules() Csla.Rules.AuthorizationActions.WriteProperty, EndedProperty, Security.Roles.ProjectManager)); BusinessRules.AddRule(new Csla.Rules.CommonRules.IsInRole( Csla.Rules.AuthorizationActions.WriteProperty, DescriptionProperty, Security.Roles.ProjectManager)); - BusinessRules.AddRule(new NoDuplicateResource { PrimaryProperty = ResourcesProperty }); } [EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] @@ -67,54 +89,16 @@ protected override void AddBusinessRules() public static void AddObjectAuthorizationRules() { Csla.Rules.BusinessRules.AddRule( - typeof(ProjectEdit), + typeof(ProjectEdit), new Csla.Rules.CommonRules.IsInRole( - Csla.Rules.AuthorizationActions.CreateObject, + Csla.Rules.AuthorizationActions.CreateObject, "ProjectManager")); - Csla.Rules.BusinessRules.AddRule(typeof(ProjectEdit), + Csla.Rules.BusinessRules.AddRule(typeof(ProjectEdit), new Csla.Rules.CommonRules.IsInRole(Csla.Rules.AuthorizationActions.EditObject, Security.Roles.ProjectManager)); - Csla.Rules.BusinessRules.AddRule(typeof(ProjectEdit), + Csla.Rules.BusinessRules.AddRule(typeof(ProjectEdit), new Csla.Rules.CommonRules.IsInRole(Csla.Rules.AuthorizationActions.DeleteObject, Security.Roles.ProjectManager, Security.Roles.Administrator)); } - protected override void OnChildChanged(Csla.Core.ChildChangedEventArgs e) - { - if (e.ChildObject is ProjectResources) - { - BusinessRules.CheckRules(ResourcesProperty); - OnPropertyChanged(ResourcesProperty); - } - base.OnChildChanged(e); - } - - private class NoDuplicateResource : Csla.Rules.BusinessRule - { - protected override void Execute(Csla.Rules.IRuleContext context) - { - if (context.Target is not ProjectEdit target) - return; - try - { - var resources = target.Resources; - if (resources is null) - return; - foreach (var item in resources) - { - var count = resources.Count(r => r.ResourceId == item.ResourceId); - if (count > 1) - { - context.AddErrorResult("Duplicate resources not allowed"); - return; - } - } - } - catch - { - // Resources may not be loaded yet - } - } - } - private class StartDateGTEndDate : Csla.Rules.BusinessRule { protected override void Execute(Csla.Rules.IRuleContext context) @@ -131,14 +115,13 @@ protected override void Execute(Csla.Rules.IRuleContext context) [Create] [RunLocal] - private void Create([Inject]IChildDataPortal portal) + private void Create() { - LoadProperty(ResourcesProperty, portal!.CreateChild()!); BusinessRules.CheckRules(); } [Fetch] - private void Fetch(int id, [Inject] IProjectDal dal, [Inject] IChildDataPortal portal) + private void Fetch(int id, [Inject] IProjectDal dal, [Inject] IAssignmentDal assignmentDal, [Inject] IChildDataPortal childPortal) { var data = dal.Fetch(id) ?? throw new DataNotFoundException("Project"); using (BypassPropertyChecks) @@ -149,7 +132,12 @@ private void Fetch(int id, [Inject] IProjectDal dal, [Inject] IChildDataPortal

(); - Resources = portal!.FetchChild(id)!; + } + using (LoadListMode) + { + var assignments = assignmentDal.FetchForProject(id); + foreach (var item in assignments) + Add(childPortal.FetchChild(item)); } } @@ -170,6 +158,7 @@ private void Insert([Inject] IProjectDal dal) TimeStamp = item.LastChanged; } FieldManager.UpdateChildren(this); + Child_Update(this); } [Update] @@ -190,6 +179,7 @@ private void Update([Inject] IProjectDal dal) TimeStamp = item.LastChanged; } FieldManager.UpdateChildren(this.Id); + Child_Update(this.Id); } [DeleteSelf] @@ -197,8 +187,8 @@ private void DeleteSelf([Inject] IProjectDal dal) { using (BypassPropertyChecks) { - Resources?.Clear(); - FieldManager.UpdateChildren(this); + Clear(); + Child_Update(this); Delete(this.Id, dal); } } @@ -209,4 +199,4 @@ private void Delete(int id, [Inject] IProjectDal dal) dal.Delete(id); } } -} \ No newline at end of file +} diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectResources.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectResources.cs deleted file mode 100644 index a029ce2167..0000000000 --- a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectResources.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using Csla; -using ProjectTracker.Dal; - -namespace ProjectTracker.Library -{ - public class ProjectResources : BusinessListBase - { - public async Task AssignAsync(int resourceId) - { - var portal = ApplicationContext.GetRequiredService>(); - var resourceCreator = await portal.FetchAsync(resourceId); - var resourceEdit = resourceCreator.ProjectResource; - this.Add(resourceEdit); - return resourceEdit; - } - - public void Remove(int resourceId) - { - var item = (from r in this - where r.ResourceId == resourceId - select r).FirstOrDefault(); - if (item != null) - Remove(item); - } - - public bool Contains(int resourceId) - { - var item = (from r in this - where r.ResourceId == resourceId - select r).Count(); - return item > 0; - } - - public bool ContainsDeleted(int resourceId) - { - var item = (from r in DeletedList - where r.ResourceId == resourceId - select r).Count(); - return item > 0; - } - - [FetchChild] - private void Fetch(int projectId, [Inject] IAssignmentDal dal, [Inject] IChildDataPortal portal) - { - var data = dal.FetchForProject(projectId); - using (LoadListMode) - { - foreach (var item in data) - Add(portal.FetchChild(item)); - } - } - } -} \ No newline at end of file diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceAssignments.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceAssignments.cs deleted file mode 100644 index b55bae1761..0000000000 --- a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceAssignments.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Linq; -using System.Threading.Tasks; -using Csla; -using ProjectTracker.Dal; - -namespace ProjectTracker.Library -{ - public class ResourceAssignments : BusinessListBase - { - public async Task AssignToAsync(int projectId) - { - if (!(Contains(projectId))) - { - var creator = ApplicationContext.GetRequiredService>(); - var project = await creator.FetchAsync(projectId); - this.Add(project.Result); - return project.Result; - } - else - { - throw new InvalidOperationException("Resource already assigned to project"); - } - } - - public void Remove(int projectId) - { - var item = (from r in this - where r.ProjectId == projectId - select r).FirstOrDefault(); - if (item != null) - Remove(item); - } - - public bool Contains(int projectId) - { - var count = (from r in this - where r.ProjectId == projectId - select r).Count(); - return count > 0; - } - - public bool ContainsDeleted(int projectId) - { - var count = (from r in DeletedList - where r.ProjectId == projectId - select r).Count(); - return count > 0; - } - - [FetchChild] - private void Fetch(int resourceId, [Inject] IAssignmentDal dal, [Inject] IChildDataPortal portal) - { - using (LoadListMode) - { - var data = dal.FetchForResource(resourceId); - foreach (var item in data) - Add(portal.FetchChild(item)); - } - } - } -} \ No newline at end of file diff --git a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceEdit.cs b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceEdit.cs index 109c1e42ac..cd1d928e2a 100644 --- a/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceEdit.cs +++ b/Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceEdit.cs @@ -8,7 +8,7 @@ namespace ProjectTracker.Library { [CslaImplementProperties] - public partial class ResourceEdit : CslaBaseTypes.BusinessBase + public partial class ResourceEdit : CslaBaseTypes.BusinessDocumentBase { [Browsable(false)] [EditorBrowsable(EditorBrowsableState.Never)] @@ -33,19 +33,48 @@ public string FullName get { return LastName + ", " + FirstName; } } - public partial ResourceAssignments Assignments { get; private set; } - public override string ToString() { return Id.ToString(); } + public async Task AssignToAsync(int projectId) + { + if (!Contains(projectId)) + { + var creator = ApplicationContext.GetRequiredService>(); + var project = await creator.FetchAsync(projectId); + this.Add(project.Result); + return project.Result; + } + else + { + throw new InvalidOperationException("Resource already assigned to project"); + } + } + + public void Remove(int projectId) + { + var item = this.FirstOrDefault(r => r.ProjectId == projectId); + if (item != null) + Remove(item); + } + + public bool Contains(int projectId) + { + return this.Any(r => r.ProjectId == projectId); + } + + public bool ContainsDeleted(int projectId) + { + return DeletedList.Any(r => r.ProjectId == projectId); + } + protected override void AddBusinessRules() { base.AddBusinessRules(); BusinessRules.AddRule(new Csla.Rules.CommonRules.IsInRole(Csla.Rules.AuthorizationActions.WriteProperty, LastNameProperty, Security.Roles.ProjectManager)); BusinessRules.AddRule(new Csla.Rules.CommonRules.IsInRole(Csla.Rules.AuthorizationActions.WriteProperty, FirstNameProperty, Security.Roles.ProjectManager)); - BusinessRules.AddRule(new NoDuplicateProject { PrimaryProperty = AssignmentsProperty }); } [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] @@ -57,54 +86,15 @@ public static void AddObjectAuthorizationRules() Csla.Rules.BusinessRules.AddRule(typeof(ResourceEdit), new Csla.Rules.CommonRules.IsInRole(Csla.Rules.AuthorizationActions.DeleteObject, Security.Roles.ProjectManager, Security.Roles.Administrator)); } - protected override void OnChildChanged(Csla.Core.ChildChangedEventArgs e) - { - if (e.ChildObject is ResourceAssignments) - { - BusinessRules.CheckRules(AssignmentsProperty); - OnPropertyChanged(AssignmentsProperty); - } - base.OnChildChanged(e); - } - - private class NoDuplicateProject : Csla.Rules.BusinessRule - { - protected override void Execute(Csla.Rules.IRuleContext context) - { - if (context.Target is not ResourceEdit target) - return; - try - { - var assignments = target.Assignments; - if (assignments is null) - return; - foreach (var item in assignments) - { - var count = assignments.Count(r => r.ProjectId == item.ProjectId); - if (count > 1) - { - context.AddErrorResult("Duplicate projects not allowed"); - return; - } - } - } - catch - { - // Assignments may not be loaded yet - } - } - } - [RunLocal] [Create] - private void Create([Inject] IChildDataPortal portal) + private void Create() { - LoadProperty(AssignmentsProperty, portal.CreateChild()); BusinessRules.CheckRules(); } [Fetch] - private void Fetch(int id, [Inject] IResourceDal dal, [Inject] IChildDataPortal portal) + private void Fetch(int id, [Inject] IResourceDal dal, [Inject] IAssignmentDal assignmentDal, [Inject] IChildDataPortal childPortal) { var data = dal.Fetch(id) ?? throw new DataNotFoundException("Resource"); using (BypassPropertyChecks) @@ -113,7 +103,12 @@ private void Fetch(int id, [Inject] IResourceDal dal, [Inject] IChildDataPortal< FirstName = data.FirstName ?? string.Empty; LastName = data.LastName ?? string.Empty; TimeStamp = data.LastChanged ?? []; - Assignments = portal.FetchChild(id); + } + using (LoadListMode) + { + var assignments = assignmentDal.FetchForResource(id); + foreach (var item in assignments) + Add(childPortal.FetchChild(item)); } } @@ -132,6 +127,7 @@ private void Insert([Inject] IResourceDal dal) TimeStamp = item.LastChanged ?? []; } FieldManager.UpdateChildren(this); + Child_Update(this); } [Update] @@ -150,6 +146,7 @@ private void Update([Inject] IResourceDal dal) TimeStamp = item.LastChanged ?? []; } FieldManager.UpdateChildren(this); + Child_Update(this); } [DeleteSelf] @@ -157,8 +154,8 @@ private void DeleteSelf([Inject] IResourceDal dal) { using (BypassPropertyChecks) { - Assignments?.Clear(); - FieldManager.UpdateChildren(this); + Clear(); + Child_Update(this); Delete(this.Id, dal); } } diff --git a/Source/Csla.Channels.Wcf/Client/WcfProxy.cs b/Source/Csla.Channels.Wcf/Client/WcfProxy.cs index d9c28c3e66..5f3e92d9b0 100644 --- a/Source/Csla.Channels.Wcf/Client/WcfProxy.cs +++ b/Source/Csla.Channels.Wcf/Client/WcfProxy.cs @@ -29,7 +29,13 @@ namespace Csla.Channels.Wcf.Client /// public class WcfProxy(ApplicationContext applicationContext, WcfProxyOptions wcfProxyOptions, DataPortalOptions dataPortalOptions) : DataPortalProxy(applicationContext) { + ///

+ /// Options used to configure the WCF data portal proxy. + /// protected WcfProxyOptions _options = wcfProxyOptions ?? throw new ArgumentNullException(nameof(wcfProxyOptions)); + /// + /// Version routing tag from the data portal options. + /// protected string? _versionRoutingTag = dataPortalOptions.VersionRoutingTag; /// @@ -37,6 +43,7 @@ public class WcfProxy(ApplicationContext applicationContext, WcfProxyOptions wcf /// public override string DataPortalUrl => _options.DataPortalUrl; + /// protected override async Task CallDataPortalServer(byte[] serialized, string operation, string? routingToken, bool isSync) { var client = new WcfPortalClient(_options.Binding, new EndpointAddress(_options.DataPortalUrl)); diff --git a/Source/Csla.Channels.Wcf/Csla.Channels.Wcf.csproj b/Source/Csla.Channels.Wcf/Csla.Channels.Wcf.csproj index a1df404297..a3774d519b 100644 --- a/Source/Csla.Channels.Wcf/Csla.Channels.Wcf.csproj +++ b/Source/Csla.Channels.Wcf/Csla.Channels.Wcf.csproj @@ -1,10 +1,17 @@ - + + net462;net472;net48;net8.0;net9.0;net10.0 Latest enable enable + CSLA .NET WCF Channel + WCF data portal channel for CSLA .NET. + true + ..\..\Bin + CSLA .NET WCF data portal channel + CSLA;WCF @@ -22,7 +29,7 @@ - diff --git a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/SerializationPartialBuilder.cs b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/SerializationPartialBuilder.cs index 7b09e9a7ea..4a18d767a0 100644 --- a/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/SerializationPartialBuilder.cs +++ b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/SerializationPartialBuilder.cs @@ -184,6 +184,10 @@ private void AppendSerializeChildFragment(IndentedTextWriter textWriter, Extract private string GetGetterMethod(ExtractedTypeDefinition typeDefinition) { + if (typeDefinition.BaseClassTypeName.Contains("BusinessDocumentBase")) + { + return "GetProperty"; + } if (typeDefinition.BaseClassTypeName.Contains("BusinessBase")) { return "GetProperty"; @@ -200,6 +204,10 @@ private string GetGetterMethod(ExtractedTypeDefinition typeDefinition) } private string GetSetterMethod(ExtractedTypeDefinition typeDefinition) { + if (typeDefinition.BaseClassTypeName.Contains("BusinessDocumentBase")) + { + return "SetProperty"; + } if (typeDefinition.BaseClassTypeName.Contains("BusinessBase")) { return "SetProperty"; diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs new file mode 100644 index 0000000000..e70d2c343f --- /dev/null +++ b/Source/Csla/BusinessDocumentBase.cs @@ -0,0 +1,1146 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Base class combining BusinessBase and BusinessListBase capabilities. +//----------------------------------------------------------------------- + +#if NET8_0_OR_GREATER +using System.Collections; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; +using Csla.Core; +using Csla.Core.FieldManager; +using Csla.Properties; +using Csla.Reflection; +using Csla.Serialization.Mobile; +using Csla.Server; + +namespace Csla +{ + /// + /// Base class for an editable business object that has its own + /// properties AND is a collection of child items. + /// Combines the capabilities of both + /// and . + /// + /// Type of the business object being defined. + /// Type of the child objects contained in the collection. + [Serializable] + public abstract class BusinessDocumentBase< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] C> : + BusinessBase, + IEditableCollection, + IContainsDeletedList, + IObservableBindingList, + INotifyCollectionChanged, + IList, + IBusinessDocumentBase + where T : BusinessDocumentBase + where C : notnull, IEditableBusinessObject + { + #region Collection Storage + + [NotUndoable] + [NonSerialized] + private bool _completelyRemoveChild; + + [NotUndoable] + private MobileList _items = new(); + + [NotUndoable] + private MobileList? _deletedList; + + /// + /// A collection containing all child objects marked + /// for deletion. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected MobileList DeletedList + { + get + { + _deletedList ??= new MobileList(); + return _deletedList; + } + } + + #endregion + + #region IContainsDeletedList + + IEnumerable IContainsDeletedList.DeletedList + => (IEnumerable?)_deletedList ?? Enumerable.Empty(); + + /// + /// Returns true if the internal deleted list + /// contains the specified child object. + /// + /// Child object to check. + /// is . + [EditorBrowsable(EditorBrowsableState.Advanced)] + public bool ContainsDeleted(C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + return DeletedList.Contains(item); + } + + #endregion + + #region Delete and Undelete child + + private void DeleteChild(C child) + { + // reset the child edit level + UndoableBase.ResetChildEditLevel(child, EditLevel, false); + // mark the object as deleted + child.DeleteChild(); + // add to deleted collection for storage + DeletedList.Add(child); + } + + private void UnDeleteChild(C child) + { + // remove from deleted collection + DeletedList.Remove(child); + + // reverse the DeleteChild marking + child.UnDeleteChild(); + + // preserve EditLevelAdded value + int saveLevel = child.EditLevelAdded; + + // insert into active list + _items.Add(child); + OnAddEventHooks((IBusinessObject)child); + + // restore EditLevelAdded + child.EditLevelAdded = saveLevel; + } + + #endregion + + #region LoadListMode + + [NotUndoable] + [NonSerialized] + private Stack? _oldRLCE; + + [NotUndoable] + [NonSerialized] + private bool _raiseListChangedEvents = true; + + /// + /// Gets or sets a value indicating whether list changed + /// events should be raised. + /// + public bool RaiseListChangedEvents + { + get => _raiseListChangedEvents; + protected set => _raiseListChangedEvents = value; + } + + /// + /// Use this object to suppress list changed events + /// during bulk operations. This is the public API equivalent + /// of the protected property. + /// + public IDisposable SuppressListChangedEvents => LoadListMode; + + /// + /// Use this object to suppress list changed events + /// during bulk operations. + /// + protected IDisposable LoadListMode => new LoadListModeObject(this); + + private sealed class LoadListModeObject : IDisposable + { + private readonly BusinessDocumentBase _parent; + + internal LoadListModeObject(BusinessDocumentBase parent) + { + _parent = parent; + _parent._oldRLCE ??= new Stack(); + _parent._oldRLCE.Push(_parent._raiseListChangedEvents); + _parent._raiseListChangedEvents = false; + } + + public void Dispose() + { + if (_parent._oldRLCE?.Count > 0) + _parent._raiseListChangedEvents = _parent._oldRLCE.Pop(); + GC.SuppressFinalize(this); + } + } + + #endregion + + #region IList Implementation + + /// + /// Gets the number of child items in the collection. + /// + public int Count => _items.Count; + + /// + /// Gets a value indicating whether the collection is read-only. + /// + bool ICollection.IsReadOnly => false; + + /// + /// Gets or sets the child item at the specified index. + /// + /// The zero-based index. + public C this[int index] + { + get => _items[index]; + set => SetItem(index, value); + } + + /// + /// Adds an item to the collection. + /// + /// The child object to add. + /// is . + public void Add(C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + InsertItem(_items.Count, item); + } + + /// + /// Inserts an item at the specified index. + /// + /// Zero-based index. + /// The child object to insert. + /// is . + public void Insert(int index, C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + InsertItem(index, item); + } + + /// + /// Removes the first occurrence of a specific item from the collection. + /// + /// The child object to remove. + /// True if the item was found and removed. + /// is . + public bool Remove(C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + int index = _items.IndexOf(item); + if (index < 0) + return false; + RemoveItem(index); + return true; + } + + /// + /// Removes the item at the specified index. + /// + /// The zero-based index of the item to remove. + public void RemoveAt(int index) + { + RemoveItem(index); + } + + /// + /// Removes all items from the collection. + /// + public void Clear() + { + ClearItems(); + } + + /// + /// Determines whether the collection contains a specific item. + /// + /// The item to locate. + /// is . + public bool Contains(C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + return _items.Contains(item); + } + + /// + /// Determines the index of a specific item in the collection. + /// + /// The item to locate. + /// is . + public int IndexOf(C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + return _items.IndexOf(item); + } + + /// + /// Copies the elements to an array starting at the specified index. + /// + /// Destination array. + /// Start index in array. + /// is . + public void CopyTo(C[] array, int arrayIndex) + { + if (array is null) + throw new ArgumentNullException(nameof(array)); + + _items.CopyTo(array, arrayIndex); + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + + #endregion + + #region Insert, Remove, Set, Clear + + /// + /// Sets the edit level of the child object as it is added. + /// + /// Index of the item to insert. + /// Item to insert. + /// is . + /// The item is not marked as a child object. + protected virtual void InsertItem(int index, C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + if (item.IsChild) + { + // if the object is already in the deleted list, restore it + if (_deletedList != null && _deletedList.Contains(item)) + { + UnDeleteChild(item); + return; + } + + IdentityManager.EnsureNextIdentityValueIsUnique(this, item); + + // set parent reference + item.SetParent(this); + // ensure child uses same context as parent + if (item is IUseApplicationContext iuac) + iuac.ApplicationContext = ApplicationContext; + // set child edit level + UndoableBase.ResetChildEditLevel(item, EditLevel, false); + // when an object is inserted we assume it is + // a new object and so the edit level when it was + // added must be set + item.EditLevelAdded = EditLevel; + _items.Insert(index, item); + OnAddEventHooks((IBusinessObject)item); + var collectionArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index); + if (RaiseListChangedEvents) + OnCollectionChanged(collectionArgs); + OnChildChanged(new Core.ChildChangedEventArgs(item, null, collectionArgs)); + } + else + { + // item must be marked as a child object + throw new InvalidOperationException(Resources.ListItemNotAChildException); + } + } + + /// + /// Marks the child object for deletion and moves it to + /// the collection of deleted objects. + /// + /// Index of the item to remove. + protected virtual void RemoveItem(int index) + { + C child = _items[index]; + OnRemoveEventHooks((IBusinessObject)child); + using (LoadListMode) + { + _items.RemoveAt(index); + } + if (!_completelyRemoveChild) + { + DeleteChild(child); + } + var collectionArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, child, index); + if (RaiseListChangedEvents) + OnCollectionChanged(collectionArgs); + OnChildChanged(new Core.ChildChangedEventArgs(child, null, collectionArgs)); + } + + /// + /// Replaces the item at the specified index with + /// the specified item, first moving the original + /// item to the deleted list. + /// + /// The zero-based index of the item to replace. + /// The new value for the item at the specified index. + /// is . + /// The item is not marked as a child object. + protected virtual void SetItem(int index, C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + if (!item.IsChild) + throw new InvalidOperationException(Resources.ListItemNotAChildException); + + C? child = default; + if (!ReferenceEquals(_items[index], item)) + child = _items[index]; + + // suppress events while deleting old item to avoid + // intermediate state notifications + using (LoadListMode) + { + if (child != null) + { + OnRemoveEventHooks((IBusinessObject)child); + DeleteChild(child); + } + } + + // set parent reference + item.SetParent(this); + // ensure child uses same context as parent + if (item is IUseApplicationContext iuac) + iuac.ApplicationContext = ApplicationContext; + // set child edit level + UndoableBase.ResetChildEditLevel(item, EditLevel, false); + // reset EditLevelAdded + item.EditLevelAdded = EditLevel; + _items[index] = item; + OnAddEventHooks((IBusinessObject)item); + var collectionArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, item, (object?)child, index); + if (RaiseListChangedEvents) + OnCollectionChanged(collectionArgs); + OnChildChanged(new Core.ChildChangedEventArgs(item, null, collectionArgs)); + } + + /// + /// Clears the collection, moving all active + /// items to the deleted list. + /// + protected virtual void ClearItems() + { + while (_items.Count > 0) + RemoveItem(0); + } + + /// + /// Override this method to create a new object that is added + /// to the collection. + /// + protected virtual C AddNewCore() + { + var dp = ApplicationContext.CreateInstanceDI>(); + var item = dp.CreateChild(); + Add(item); + return item; + } + + /// + /// Override this method to create a new object that is added + /// to the collection. + /// + protected virtual async Task AddNewCoreAsync() + { + var dp = ApplicationContext.CreateInstanceDI>(); + var item = await dp.CreateChildAsync(); + Add(item); + return item; + } + + #endregion + + #region AddNew + + /// + /// Creates and adds a new child item to the collection. + /// + public C AddNew() => AddNewCore(); + + /// + /// Asynchronously creates and adds a new child item to the collection. + /// + public Task AddNewAsync() => AddNewCoreAsync(); + + #endregion + + #region IObservableBindingList + + /// + /// Creates and adds a new item to the collection. + /// + object IObservableBindingList.AddNew() + { + return AddNewCore(); + } + + /// + /// Creates and adds a new item to the collection. + /// + async Task IObservableBindingList.AddNewAsync() + { + return await AddNewCoreAsync(); + } + + [NonSerialized] + [NotUndoable] + private EventHandler? _removingItemHandler; + + /// + /// Event indicating that an item is being removed from the list. + /// + event EventHandler? IObservableBindingList.RemovingItem + { + add => _removingItemHandler = (EventHandler?)Delegate.Combine(_removingItemHandler, value); + remove => _removingItemHandler = (EventHandler?)Delegate.Remove(_removingItemHandler, value); + } + + /// + /// Raises the RemovingItem event. + /// + /// The item being removed. + protected void OnRemovingItem(C removingItem) + { + _removingItemHandler?.Invoke(this, new RemovingItemEventArgs(removingItem)); + } + + #endregion + + #region INotifyCollectionChanged + + [NonSerialized] + [NotUndoable] + private NotifyCollectionChangedEventHandler? _collectionChanged; + + /// + /// Event raised when the collection changes. + /// + public event NotifyCollectionChangedEventHandler? CollectionChanged + { + add => _collectionChanged = (NotifyCollectionChangedEventHandler?)Delegate.Combine(_collectionChanged, value); + remove => _collectionChanged = (NotifyCollectionChangedEventHandler?)Delegate.Remove(_collectionChanged, value); + } + + /// + /// Raises the CollectionChanged event. + /// + /// Event args. + protected virtual void OnCollectionChanged(NotifyCollectionChangedEventArgs e) + { + if (RaiseListChangedEvents) + _collectionChanged?.Invoke(this, e); + } + + #endregion + + #region IEditableCollection + + void IEditableCollection.RemoveChild(IEditableBusinessObject child) + { + if (child is null) + throw new ArgumentNullException(nameof(child)); + + Remove((C)child); + } + + object IEditableCollection.GetDeletedList() + { + return DeletedList; + } + + void IEditableCollection.SetParent(IParent? parent) + { + SetParent(parent); + } + + #endregion + + #region IParent override + + /// + /// This method is called by a child object when it + /// wants to be removed from the collection. + /// + /// The child object to remove. + Task IParent.RemoveChild(IEditableBusinessObject child) + { + if (child is null) + throw new ArgumentNullException(nameof(child)); + + // Try to remove from the collection + if (child is C typedChild) + { + int index = _items.IndexOf(typedChild); + if (index >= 0) + { + Remove(typedChild); + return Task.CompletedTask; + } + } + + // Fall back to FieldManager for property-based children + var info = FieldManager.FindProperty(child); + if (info is not null) + { + FieldManager.RemoveField(info); + } + + return Task.CompletedTask; + } + + #endregion + + #region Status Property Overrides + + /// + public override bool IsDirty + { + get + { + // Check object's own dirty state (properties + FieldManager children) + if (base.IsDirty) + return true; + + // Any non-new deletions make us dirty + foreach (C item in DeletedList) + if (!item.IsNew) + return true; + + // Check all collection children + foreach (C child in _items) + if (child.IsDirty) + return true; + + return false; + } + } + + /// + /// Gets a value indicating whether this object is currently in + /// a valid state. Aggregates the object's own validity with the + /// validity of all collection children. + /// + public override bool IsValid + { + get + { + // Check object's own validity (rules + FieldManager children) + if (!base.IsValid) + return false; + + // Check all collection children + foreach (C child in _items) + if (!child.IsValid) + return false; + + return true; + } + } + + /// + /// Gets a value indicating if this object or its child objects + /// are busy. + /// + public override bool IsBusy + { + get + { + // Check object's own busy state + if (base.IsBusy) + return true; + + // Check deleted children + foreach (C item in DeletedList) + if (item.IsBusy) + return true; + + // Check active collection children + foreach (C child in _items) + if (child.IsBusy) + return true; + + return false; + } + } + + #endregion + + #region N-Level Undo + + /// + /// Cascades CopyState to collection children after the + /// object's own state has been copied. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected override void CopyStateComplete() + { + base.CopyStateComplete(); + + // Cascade to all active children + for (int x = 0; x < _items.Count; x++) + { + C child = _items[x]; + child.CopyState(EditLevel, false); + } + + // Cascade to all deleted children + foreach (C child in DeletedList) + child.CopyState(EditLevel, false); + } + + /// + /// Cascades UndoChanges to collection children after the + /// object's own state has been restored. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected override void UndoChangesComplete() + { + base.UndoChangesComplete(); + + C child; + + using (LoadListMode) + { + try + { + // Cancel edit on all current items (reverse order) + for (int index = _items.Count - 1; index >= 0; index--) + { + child = _items[index]; + child.UndoChanges(EditLevel, false); + + // If item was added after this edit level, remove it completely + if (child.EditLevelAdded > EditLevel) + { + try + { + _completelyRemoveChild = true; + OnRemoveEventHooks((IBusinessObject)child); + _items.RemoveAt(index); + } + finally + { + _completelyRemoveChild = false; + } + } + } + + // Cancel edit on all deleted items (reverse order) + for (int index = DeletedList.Count - 1; index >= 0; index--) + { + child = DeletedList[index]; + child.UndoChanges(EditLevel, false); + if (child.EditLevelAdded > EditLevel) + { + // If item is below its point of addition, remove + DeletedList.RemoveAt(index); + } + else + { + // If item is no longer deleted, move back to main list + if (!child.IsDeleted) + UnDeleteChild(child); + } + } + } + finally + { + if (RaiseListChangedEvents) + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + } + } + + /// + /// Cascades AcceptChanges to collection children after the + /// object's own changes have been accepted. + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected override void AcceptChangesComplete() + { + base.AcceptChangesComplete(); + + // Cascade to all active children + foreach (C child in _items) + { + child.AcceptChanges(EditLevel, false); + // If item is below its point of addition, lower point of addition + if (child.EditLevelAdded > EditLevel) + child.EditLevelAdded = EditLevel; + } + + // Cascade to deleted children (reverse order) + for (int index = DeletedList.Count - 1; index >= 0; index--) + { + C child = DeletedList[index]; + child.AcceptChanges(EditLevel, false); + // If item is below its point of addition, remove + if (child.EditLevelAdded > EditLevel) + DeletedList.RemoveAt(index); + } + } + + #endregion + + #region Mobile Object Overrides + + /// + /// Override to serialize collection children. + /// + /// Serialization info. + /// Formatter reference. + /// or is . + protected override void OnGetChildren(SerializationInfo info, MobileFormatter formatter) + { + if (info is null) + throw new ArgumentNullException(nameof(info)); + if (formatter is null) + throw new ArgumentNullException(nameof(formatter)); + + base.OnGetChildren(info, formatter); + + var itemsInfo = formatter.SerializeObject(_items); + info.AddChild("_bdb_items", itemsInfo.ReferenceId); + + if (_deletedList != null) + { + var deletedInfo = formatter.SerializeObject(_deletedList); + info.AddChild("_bdb_deletedList", deletedInfo.ReferenceId); + } + } + + /// + /// Override to deserialize collection children. + /// + /// Serialization info. + /// Formatter reference. + /// or is . + protected override void OnSetChildren(SerializationInfo info, MobileFormatter formatter) + { + if (info is null) + throw new ArgumentNullException(nameof(info)); + if (formatter is null) + throw new ArgumentNullException(nameof(formatter)); + + if (info.Children.TryGetValue("_bdb_items", out var itemsChild)) + { + _items = (MobileList)formatter.GetObject(itemsChild.ReferenceId)!; + } + + if (info.Children.TryGetValue("_bdb_deletedList", out var deletedChild)) + { + _deletedList = (MobileList?)formatter.GetObject(deletedChild.ReferenceId); + } + + base.OnSetChildren(info, formatter); + } + + /// + /// Override to re-establish parent references and event hooks + /// for collection items after deserialization. + /// + protected override void Deserialized() + { + base.Deserialized(); + + // Re-establish parent references and event hooks for active items + foreach (var item in _items) + { + item.SetParent(this); + OnAddEventHooks((IBusinessObject)item); + } + + // Re-establish parent references for deleted items + foreach (var item in DeletedList) + { + item.SetParent(this); + } + } + + #endregion + + #region Child Data Access + + /// + /// Saves all items in the list, automatically + /// performing insert, update or delete operations + /// as necessary. + /// + /// + /// Optional parameters passed to child update methods. + /// + /// is . + [EditorBrowsable(EditorBrowsableState.Advanced)] + protected virtual void Child_Update(params object?[] parameters) + { + if (parameters is null) + throw new ArgumentNullException(nameof(parameters)); + + using (LoadListMode) + { + var dp = ApplicationContext.CreateInstanceDI>(); + foreach (var child in DeletedList) + dp.UpdateChild(child, parameters); + DeletedList.Clear(); + + foreach (var child in _items) + if (child.IsDirty) + dp.UpdateChild(child, parameters); + } + } + + /// + /// Asynchronously saves all items in the list, automatically + /// performing insert, update or delete operations as necessary. + /// + /// + /// Optional parameters passed to child update methods. + /// + /// is . + [EditorBrowsable(EditorBrowsableState.Advanced)] + [UpdateChild] + protected virtual async Task Child_UpdateAsync(params object?[] parameters) + { + if (parameters is null) + throw new ArgumentNullException(nameof(parameters)); + + using (LoadListMode) + { + var dp = ApplicationContext.CreateInstanceDI>(); + foreach (var child in DeletedList) + await dp.UpdateChildAsync(child, parameters).ConfigureAwait(false); + DeletedList.Clear(); + + foreach (var child in _items) + if (child.IsDirty) + await dp.UpdateChildAsync(child, parameters).ConfigureAwait(false); + } + } + + #endregion + + #region Register Properties + + /// + /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property name from nameof(). + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Property name must not be null or empty.", nameof(propertyName)); + + return RegisterProperty(Core.FieldManager.PropertyInfoFactory.Factory.Create

(typeof(T), propertyName)); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property expression. + /// is . + protected new static PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(Expression> propertyLambdaExpression) + { + if (propertyLambdaExpression is null) + throw new ArgumentNullException(nameof(propertyLambdaExpression)); + + System.Reflection.PropertyInfo reflectedPropertyInfo = Reflect.GetProperty(propertyLambdaExpression); + return RegisterProperty

(reflectedPropertyInfo.Name); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property expression. + /// Relationship with property value. + /// is . + protected new static PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(Expression> propertyLambdaExpression, RelationshipTypes relationship) + { + if (propertyLambdaExpression is null) + throw new ArgumentNullException(nameof(propertyLambdaExpression)); + + System.Reflection.PropertyInfo reflectedPropertyInfo = Reflect.GetProperty(propertyLambdaExpression); + return RegisterProperty

(reflectedPropertyInfo.Name, relationship); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property expression. + /// Friendly description for a property to be used in databinding. + /// is . + protected new static PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(Expression> propertyLambdaExpression, string? friendlyName) + { + if (propertyLambdaExpression is null) + throw new ArgumentNullException(nameof(propertyLambdaExpression)); + + System.Reflection.PropertyInfo reflectedPropertyInfo = Reflect.GetProperty(propertyLambdaExpression); + return RegisterProperty

(reflectedPropertyInfo.Name, friendlyName); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property expression. + /// Friendly description for a property to be used in databinding. + /// Default value for the property. + /// is . + protected new static PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(Expression> propertyLambdaExpression, string? friendlyName, P? defaultValue) + { + if (propertyLambdaExpression is null) + throw new ArgumentNullException(nameof(propertyLambdaExpression)); + + System.Reflection.PropertyInfo reflectedPropertyInfo = Reflect.GetProperty(propertyLambdaExpression); + return RegisterProperty

(reflectedPropertyInfo.Name, friendlyName, defaultValue); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property expression. + /// Friendly description for a property to be used in databinding. + /// Default value for the property. + /// Relationship with property value. + /// is . + protected new static PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(Expression> propertyLambdaExpression, string? friendlyName, P? defaultValue, RelationshipTypes relationship) + { + if (propertyLambdaExpression is null) + throw new ArgumentNullException(nameof(propertyLambdaExpression)); + + System.Reflection.PropertyInfo reflectedPropertyInfo = Reflect.GetProperty(propertyLambdaExpression); + return RegisterProperty

(reflectedPropertyInfo.Name, friendlyName, defaultValue, relationship); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property name from nameof(). + /// Relationship with property value. + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, RelationshipTypes relationship) + { + if (string.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Property name must not be null or empty.", nameof(propertyName)); + + return RegisterProperty(Core.FieldManager.PropertyInfoFactory.Factory.Create

(typeof(T), propertyName, string.Empty, relationship)); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property name from nameof(). + /// Friendly description for a property to be used in databinding. + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, string? friendlyName) + { + if (string.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Property name must not be null or empty.", nameof(propertyName)); + + return RegisterProperty(Core.FieldManager.PropertyInfoFactory.Factory.Create

(typeof(T), propertyName, friendlyName)); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property name from nameof(). + /// Friendly description for a property to be used in databinding. + /// Default value for the property. + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, string? friendlyName, P? defaultValue) + { + if (string.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Property name must not be null or empty.", nameof(propertyName)); + + return RegisterProperty(Core.FieldManager.PropertyInfoFactory.Factory.Create

(typeof(T), propertyName, friendlyName, defaultValue)); + } + + ///

+ /// Indicates that the specified property belongs + /// to the business object type. + /// + /// Type of property. + /// Property name from nameof(). + /// Friendly description for a property to be used in databinding. + /// Default value for the property. + /// Relationship with property value. + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, string? friendlyName, P? defaultValue, RelationshipTypes relationship) + { + if (string.IsNullOrWhiteSpace(propertyName)) + throw new ArgumentException("Property name must not be null or empty.", nameof(propertyName)); + + return RegisterProperty(Core.FieldManager.PropertyInfoFactory.Factory.Create

(typeof(T), propertyName, friendlyName, defaultValue, relationship)); + } + + ///

+ /// Registers a method for use in Authorization. + /// + /// Method name from nameof(). + /// is . + protected static new MethodInfo RegisterMethod(string methodName) + { + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("Method name must not be null or empty.", nameof(methodName)); + + return RegisterMethod(typeof(T), methodName); + } + + /// + /// Registers a method for use in Authorization. + /// + /// The method lambda expression. + /// is . + protected new static MethodInfo RegisterMethod(Expression> methodLambdaExpression) + { + if (methodLambdaExpression is null) + throw new ArgumentNullException(nameof(methodLambdaExpression)); + + System.Reflection.MethodInfo reflectedMethodInfo = Reflect.GetMethod(methodLambdaExpression); + return RegisterMethod(reflectedMethodInfo.Name); + } + + #endregion + } +} +#endif diff --git a/Source/Csla/Core/BusinessBase.cs b/Source/Csla/Core/BusinessBase.cs index eb5cca12a7..838da17f02 100644 --- a/Source/Csla/Core/BusinessBase.cs +++ b/Source/Csla/Core/BusinessBase.cs @@ -994,6 +994,25 @@ internal void DeleteChild() MarkDeleted(); } +#if NET8_0_OR_GREATER + /// + /// Called by a parent object to reverse a + /// previous call to , + /// restoring the child to a non-deleted state. + /// + internal void UnDeleteChild() + { + if (!IsChild) + throw new NotSupportedException(Resources.NoDeleteRootException); + + if (!IsDeleted) + return; + + IsDeleted = false; + MetaPropertyHasChanged("IsDeleted"); + } +#endif + #endregion #region Edit Level Tracking (child only) @@ -1562,6 +1581,13 @@ void IEditableBusinessObject.DeleteChild() DeleteChild(); } +#if NET8_0_OR_GREATER + void IEditableBusinessObject.UnDeleteChild() + { + UnDeleteChild(); + } +#endif + void IEditableBusinessObject.SetParent(IParent? parent) { SetParent(parent); diff --git a/Source/Csla/Core/IEditableBusinessObject.cs b/Source/Csla/Core/IEditableBusinessObject.cs index bc82e8fc9b..c5c63f0d1a 100644 --- a/Source/Csla/Core/IEditableBusinessObject.cs +++ b/Source/Csla/Core/IEditableBusinessObject.cs @@ -34,6 +34,14 @@ public interface IEditableBusinessObject : IBusinessObject, ISupportUndo, IUndoa /// for deferred deletion. /// void DeleteChild(); +#if NET8_0_OR_GREATER + /// + /// Called by a parent object to reverse a + /// previous call to , + /// restoring the child to a non-deleted state. + /// + void UnDeleteChild() => throw new NotImplementedException(); +#endif /// /// Used by BusinessListBase as a child object is /// created to tell the child object about its diff --git a/Source/Csla/IBusinessDocumentBase.cs b/Source/Csla/IBusinessDocumentBase.cs new file mode 100644 index 0000000000..3a21cf0de3 --- /dev/null +++ b/Source/Csla/IBusinessDocumentBase.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Consolidated interface for the BusinessDocumentBase type. +//----------------------------------------------------------------------- + +#if NET8_0_OR_GREATER +using Csla.Core; + +namespace Csla +{ + /// + /// Consolidated interface for the BusinessDocumentBase type, + /// which combines BusinessBase and BusinessListBase capabilities. + /// A business document has its own properties AND is + /// a collection of child items. + /// + /// Type of the child objects contained in the collection. + public interface IBusinessDocumentBase : + IBusinessBase, + IBusinessListBase + where C : IEditableBusinessObject + { + } +} +#endif diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs new file mode 100644 index 0000000000..13aaf3c30d --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -0,0 +1,198 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// +// Tests for metastate PropertyChanged events on BusinessDocumentBase. +// Mirrors BasicModernTests patterns. Requires Xaml PropertyChangedMode. +// Skipped on CI due to AsyncLocal context contamination from other tests +// in this assembly (see DefaultDataPortalActivator line 43). +// +//----------------------------------------------------------------------- + +using Csla.Configuration; +using Csla.TestHelpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Test.BusinessDocumentBase +{ + [TestClass] + public class BusinessDocumentBaseMetastateTests + { + private static TestDIContext _testDIContext = null!; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + var services = new ServiceCollection(); + services.AddCsla(o => o.Binding(bo => bo.PropertyChangedMode = ApplicationContext.PropertyChangedModes.Xaml)); + services.AddScoped(); + var serviceProvider = services.BuildServiceProvider(); + _testDIContext = new TestDIContext(serviceProvider); + } + + private MetastateDocument NewDocument() + { + var portal = _testDIContext.CreateDataPortal(); + return portal.Create(); + } + + #region MakeOld + + [TestMethod] + [TestCategory("SkipOnCIServer")] + public void MakeOldMetastateEvents() + { + var doc = NewDocument(); + var changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + doc.MakeOld(); + + Assert.IsTrue(changed.Contains("IsNew"), "IsNew should fire"); + Assert.IsTrue(changed.Contains("IsDirty"), "IsDirty should fire"); + Assert.IsTrue(changed.Contains("IsSelfDirty"), "IsSelfDirty should fire"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); + + Assert.IsFalse(changed.Contains("IsValid"), "IsValid should not fire"); + Assert.IsFalse(changed.Contains("IsSelfValid"), "IsSelfValid should not fire"); + Assert.IsFalse(changed.Contains("IsDeleted"), "IsDeleted should not fire"); + } + + #endregion + + #region MarkDeleted + + [TestMethod] + [TestCategory("SkipOnCIServer")] + public void MarkDeletedMetastateEvents() + { + var doc = NewDocument(); + doc.Name = "abc"; + doc = doc.Save(); + + var changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + doc.Delete(); + + Assert.IsTrue(changed.Contains("IsDirty"), "IsDirty should fire"); + Assert.IsTrue(changed.Contains("IsSelfDirty"), "IsSelfDirty should fire"); + Assert.IsFalse(changed.Contains("IsValid"), "IsValid should not fire"); + Assert.IsFalse(changed.Contains("IsSelfValid"), "IsSelfValid should not fire"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); + Assert.IsFalse(changed.Contains("IsNew"), "IsNew should not fire"); + Assert.IsTrue(changed.Contains("IsDeleted"), "IsDeleted should fire"); + } + + #endregion + + #region Property Changed Metastate + + [TestMethod] + [TestCategory("SkipOnCIServer")] + public void RootChangedMetastateEventsId() + { + // New doc is invalid (Name required) — setting Id (no rule) still triggers metastate events + var doc = NewDocument(); + var changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + doc.Id = 123; + + Assert.IsTrue(changed.Contains("Id"), "Id should fire"); + Assert.IsFalse(changed.Contains("IsDirty"), "IsDirty should not fire (already dirty as new)"); + Assert.IsFalse(changed.Contains("IsSelfDirty"), "IsSelfDirty should not fire"); + Assert.IsTrue(changed.Contains("IsValid"), "IsValid should fire"); + Assert.IsTrue(changed.Contains("IsSelfValid"), "IsSelfValid should fire"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); + Assert.IsFalse(changed.Contains("IsNew"), "IsNew should not fire"); + Assert.IsFalse(changed.Contains("IsDeleted"), "IsDeleted should not fire"); + } + + [TestMethod] + [TestCategory("SkipOnCIServer")] + public void RootChangedMetastateEventsName() + { + var doc = NewDocument(); + var changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + doc.Name = "abc"; + + Assert.IsTrue(changed.Contains("Name"), "Name should fire"); + Assert.IsFalse(changed.Contains("IsDirty"), "IsDirty should not fire (new, already dirty)"); + Assert.IsFalse(changed.Contains("IsSelfDirty"), "IsSelfDirty should not fire"); + Assert.IsTrue(changed.Contains("IsValid"), "IsValid should fire"); + Assert.IsTrue(changed.Contains("IsSelfValid"), "IsSelfValid should fire"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); + Assert.IsFalse(changed.Contains("IsNew"), "IsNew should not fire"); + Assert.IsFalse(changed.Contains("IsDeleted"), "IsDeleted should not fire"); + + doc = doc.Save(); + changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + Assert.IsFalse(doc.IsDirty); + + doc.Name = "def"; + + Assert.IsTrue(doc.IsDirty); + + Assert.IsTrue(changed.Contains("Name"), "Name after save"); + Assert.IsTrue(changed.Contains("IsDirty"), "IsDirty after save"); + Assert.IsTrue(changed.Contains("IsSelfDirty"), "IsSelfDirty after save"); + Assert.IsTrue(changed.Contains("IsValid"), "IsValid after save"); + Assert.IsTrue(changed.Contains("IsSelfValid"), "IsSelfValid after save"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable after save"); + Assert.IsFalse(changed.Contains("IsNew"), "IsNew after save"); + Assert.IsFalse(changed.Contains("IsDeleted"), "IsDeleted after save"); + } + + [TestMethod] + [TestCategory("SkipOnCIServer")] + public void RootChangedMetastateEventsChild() + { + var childPortal = _testDIContext.CreateChildDataPortal(); + + var doc = NewDocument(); + doc.Name = "abc"; + var changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + // Adding a child (fetched, non-dirty) propagates ChildChanged → doc fires metastate events + doc.Add(childPortal.FetchChild()); + Assert.IsTrue(doc.IsDirty, "IsDirty should be true (doc is new+dirty)"); + + Assert.IsTrue(changed.Contains("IsDirty"), "IsDirty should fire after child add"); + Assert.IsFalse(changed.Contains("IsSelfDirty"), "IsSelfDirty should not fire"); + Assert.IsTrue(changed.Contains("IsValid"), "IsValid should fire"); + Assert.IsFalse(changed.Contains("IsSelfValid"), "IsSelfValid should not fire"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); + Assert.IsFalse(changed.Contains("IsNew"), "IsNew should not fire"); + Assert.IsFalse(changed.Contains("IsDeleted"), "IsDeleted should not fire"); + + doc = doc.Save(); + changed = new List(); + doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + Assert.IsFalse(doc.IsDirty); + + doc[0].Name = "modified"; + + Assert.IsTrue(doc.IsDirty); + + Assert.IsTrue(changed.Contains("IsDirty"), "IsDirty should fire after child modify"); + Assert.IsFalse(changed.Contains("IsSelfDirty"), "IsSelfDirty should not fire"); + Assert.IsTrue(changed.Contains("IsValid"), "IsValid should fire"); + Assert.IsFalse(changed.Contains("IsSelfValid"), "IsSelfValid should not fire"); + Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); + Assert.IsFalse(changed.Contains("IsNew"), "IsNew should not fire"); + Assert.IsFalse(changed.Contains("IsDeleted"), "IsDeleted should not fire"); + } + + #endregion + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs new file mode 100644 index 0000000000..1adc0deede --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs @@ -0,0 +1,925 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Tests for BusinessDocumentBase +//----------------------------------------------------------------------- + +using Csla.Serialization; +using Csla.TestHelpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Test.BusinessDocumentBase +{ + [TestClass] + public class BusinessDocumentBaseTests + { + private static TestDIContext _testDIContext = null!; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _testDIContext = TestDIContextFactory.CreateDefaultContext(); + } + + private TestDocument CreateDocument() + { + var portal = _testDIContext.CreateDataPortal(); + return portal.Create(); + } + + private TestDocument FetchDocument(int id) + { + var portal = _testDIContext.CreateDataPortal(); + return portal.Fetch(id); + } + + private DocumentLineItem CreateLineItem() + { + var portal = _testDIContext.CreateChildDataPortal(); + return portal.CreateChild(); + } + + #region Create and Basic Operations + + [TestMethod] + public void Create_ShouldCreateEmptyDocument() + { + var doc = CreateDocument(); + Assert.IsNotNull(doc); + Assert.AreEqual(0, doc.Count); + Assert.IsTrue(doc.IsNew); + } + + [TestMethod] + public void Fetch_ShouldLoadDocumentWithProperties() + { + var doc = FetchDocument(42); + Assert.AreEqual("DOC-42", doc.DocumentNumber); + Assert.AreEqual(DateTime.Today, doc.DocumentDate); + } + + [TestMethod] + public void Fetch_ShouldLoadDocumentWithChildren() + { + var doc = FetchDocument(1); + Assert.AreEqual(3, doc.Count); + Assert.AreEqual("Item 0", doc[0].Description); + Assert.AreEqual("Item 1", doc[1].Description); + Assert.AreEqual("Item 2", doc[2].Description); + } + + #endregion + + #region Collection Operations + + [TestMethod] + public void Add_ShouldAddChildItem() + { + var doc = CreateDocument(); + var item = CreateLineItem(); + item.Description = "Test"; + + doc.Add(item); + + Assert.AreEqual(1, doc.Count); + Assert.AreEqual("Test", doc[0].Description); + } + + [TestMethod] + public void Insert_ShouldInsertAtIndex() + { + var doc = CreateDocument(); + var item1 = CreateLineItem(); + item1.Description = "First"; + var item2 = CreateLineItem(); + item2.Description = "Second"; + var item3 = CreateLineItem(); + item3.Description = "Inserted"; + + doc.Add(item1); + doc.Add(item2); + doc.Insert(1, item3); + + Assert.AreEqual(3, doc.Count); + Assert.AreEqual("First", doc[0].Description); + Assert.AreEqual("Inserted", doc[1].Description); + Assert.AreEqual("Second", doc[2].Description); + } + + [TestMethod] + public void Remove_ShouldRemoveAndTrackDeleted() + { + var doc = FetchDocument(1); + var removed = doc[1]; + + doc.Remove(removed); + + Assert.AreEqual(2, doc.Count); + Assert.IsTrue(doc.ContainsDeleted(removed)); + } + + [TestMethod] + public void RemoveAt_ShouldRemoveByIndex() + { + var doc = FetchDocument(1); + Assert.AreEqual(3, doc.Count); + + doc.RemoveAt(0); + + Assert.AreEqual(2, doc.Count); + Assert.AreEqual("Item 1", doc[0].Description); + } + + [TestMethod] + public void Clear_ShouldRemoveAllItems() + { + var doc = FetchDocument(1); + Assert.AreEqual(3, doc.Count); + + doc.Clear(); + + Assert.AreEqual(0, doc.Count); + } + + [TestMethod] + public void Contains_ShouldFindItem() + { + var doc = CreateDocument(); + var item = CreateLineItem(); + doc.Add(item); + + Assert.IsTrue(doc.Contains(item)); + } + + [TestMethod] + public void IndexOf_ShouldReturnCorrectIndex() + { + var doc = CreateDocument(); + var item1 = CreateLineItem(); + var item2 = CreateLineItem(); + doc.Add(item1); + doc.Add(item2); + + Assert.AreEqual(0, doc.IndexOf(item1)); + Assert.AreEqual(1, doc.IndexOf(item2)); + } + + [TestMethod] + public void Enumerator_ShouldIterateItems() + { + var doc = FetchDocument(1); + int count = 0; + foreach (var item in doc) + { + Assert.IsNotNull(item); + count++; + } + Assert.AreEqual(3, count); + } + + [TestMethod] + public void Indexer_Set_ShouldReplaceItem() + { + var doc = FetchDocument(1); + var newItem = CreateLineItem(); + newItem.Description = "Replacement"; + + doc[1] = newItem; + + Assert.AreEqual("Replacement", doc[1].Description); + Assert.AreEqual(3, doc.Count); + } + + #endregion + + #region Status Aggregation + + [TestMethod] + public void IsDirty_ShouldBeFalseWhenFetched() + { + var doc = FetchDocument(1); + Assert.IsFalse(doc.IsDirty); + } + + [TestMethod] + public void IsDirty_ShouldBeTrueWhenPropertyChanged() + { + var doc = FetchDocument(1); + doc.DocumentNumber = "CHANGED"; + Assert.IsTrue(doc.IsDirty); + } + + [TestMethod] + public void IsDirty_ShouldBeTrueWhenChildChanged() + { + var doc = FetchDocument(1); + doc[0].Description = "Changed"; + Assert.IsTrue(doc.IsDirty); + } + + [TestMethod] + public void IsDirty_ShouldBeTrueWhenChildAdded() + { + var doc = FetchDocument(1); + var item = CreateLineItem(); + doc.Add(item); + Assert.IsTrue(doc.IsDirty); + } + + [TestMethod] + public void IsDirty_ShouldBeTrueWhenChildRemoved() + { + var doc = FetchDocument(1); + doc.RemoveAt(0); + Assert.IsTrue(doc.IsDirty); + } + + [TestMethod] + public void IsValid_ShouldBeTrue_WhenAllValid() + { + var doc = CreateDocument(); + Assert.IsTrue(doc.IsValid); + } + + [TestMethod] + public void IsNew_ShouldBeTrueForNewDocument() + { + var doc = CreateDocument(); + Assert.IsTrue(doc.IsNew); + } + + [TestMethod] + public void IsNew_ShouldBeFalseForFetchedDocument() + { + var doc = FetchDocument(1); + Assert.IsFalse(doc.IsNew); + } + + #endregion + + #region Collection Changed Events + + [TestMethod] + public void Add_ShouldRaiseCollectionChanged() + { + var doc = CreateDocument(); + bool changed = false; + doc.CollectionChanged += (_, _) => changed = true; + + var item = CreateLineItem(); + doc.Add(item); + + Assert.IsTrue(changed); + } + + [TestMethod] + public void Remove_ShouldRaiseCollectionChanged() + { + var doc = FetchDocument(1); + bool changed = false; + doc.CollectionChanged += (_, _) => changed = true; + + doc.RemoveAt(0); + + Assert.IsTrue(changed); + } + + #endregion + + #region Clone / Serialization + + [TestMethod] + public void Clone_ShouldPreserveProperties() + { + var doc = FetchDocument(1); + var clone = doc.Clone(); + + Assert.AreEqual(doc.DocumentNumber, clone.DocumentNumber); + Assert.AreEqual(doc.DocumentDate, clone.DocumentDate); + } + + [TestMethod] + public void Clone_ShouldPreserveChildren() + { + var doc = FetchDocument(1); + var clone = doc.Clone(); + + Assert.AreEqual(doc.Count, clone.Count); + for (int i = 0; i < doc.Count; i++) + { + Assert.AreEqual(doc[i].Description, clone[i].Description); + Assert.AreEqual(doc[i].Amount, clone[i].Amount); + } + } + + [TestMethod] + public void Clone_ShouldPreserveDeletedList() + { + var doc = FetchDocument(1); + doc.RemoveAt(0); + + var clone = doc.Clone(); + + Assert.AreEqual(2, clone.Count); + Assert.IsTrue(clone.IsDirty); + } + + #endregion + + #region N-Level Undo + + [TestMethod] + public void BeginEdit_CancelEdit_ShouldRestoreProperty() + { + var doc = FetchDocument(1); + doc.BeginEdit(); + doc.DocumentNumber = "CHANGED"; + doc.CancelEdit(); + + Assert.AreEqual("DOC-1", doc.DocumentNumber); + } + + [TestMethod] + public void BeginEdit_CancelEdit_ShouldRestoreAddedChild() + { + var doc = FetchDocument(1); + Assert.AreEqual(3, doc.Count); + + doc.BeginEdit(); + var item = CreateLineItem(); + item.Description = "New"; + doc.Add(item); + Assert.AreEqual(4, doc.Count); + + doc.CancelEdit(); + Assert.AreEqual(3, doc.Count); + } + + [TestMethod] + public void BeginEdit_CancelEdit_ShouldRestoreRemovedChild() + { + var doc = FetchDocument(1); + Assert.AreEqual(3, doc.Count); + + doc.BeginEdit(); + doc.RemoveAt(0); + Assert.AreEqual(2, doc.Count); + + doc.CancelEdit(); + Assert.AreEqual(3, doc.Count); + } + + [TestMethod] + public void BeginEdit_ApplyEdit_ShouldKeepChanges() + { + var doc = FetchDocument(1); + + doc.BeginEdit(); + doc.DocumentNumber = "CHANGED"; + var item = CreateLineItem(); + item.Description = "New"; + doc.Add(item); + doc.ApplyEdit(); + + Assert.AreEqual("CHANGED", doc.DocumentNumber); + Assert.AreEqual(4, doc.Count); + } + + #endregion + + #region NotUndoable + + [TestMethod] + public void NotUndoableField_ShouldNotRestoreAfterCancelEdit() + { + var doc = FetchDocument(1); + doc.NotUndoableData = "something"; + doc.DocumentNumber = "data"; + + doc.BeginEdit(); + doc.NotUndoableData = "something else"; + doc.DocumentNumber = "new data"; + doc.CancelEdit(); + + // [NotUndoable] field is NOT restored on CancelEdit + Assert.AreEqual("something else", doc.NotUndoableData); + // Regular property IS restored + Assert.AreEqual("data", doc.DocumentNumber); + } + + #endregion + + #region AddNew + + [TestMethod] + public void AddNew_ShouldAddChildAndRaiseCollectionChanged() + { + var doc = CreateDocument(); + bool changed = false; + doc.CollectionChanged += (_, _) => changed = true; + + var child = doc.AddNew(); + + Assert.IsTrue(changed, "CollectionChanged should be raised"); + Assert.AreEqual(1, doc.Count); + Assert.AreEqual(child, doc[0]); + } + + [TestMethod] + public async Task AddNewAsync_ShouldAddChildAndRaiseCollectionChanged() + { + var doc = CreateDocument(); + bool changed = false; + doc.CollectionChanged += (_, _) => changed = true; + + var child = await doc.AddNewAsync(); + + Assert.IsTrue(changed, "CollectionChanged should be raised"); + Assert.AreEqual(1, doc.Count); + Assert.AreEqual(child, doc[0]); + } + + #endregion + + #region Advanced Undo + + [TestMethod] + public void AddRemoveAddChild_CancelRestoresOriginal() + { + var doc = CreateDocument(); + var item1 = CreateLineItem(); + item1.Description = "1"; + doc.Add(item1); + + doc.BeginEdit(); + doc.Remove(item1); + + var item2 = CreateLineItem(); + item2.Description = "2"; + doc.Add(item2); + + doc.CancelEdit(); + + Assert.AreEqual(1, doc.Count); + Assert.AreEqual("1", doc[0].Description); + } + + [TestMethod] + public void ClearItems_TracksDeletedCount() + { + var doc = FetchDocument(1); + Assert.AreEqual(3, doc.Count); + + doc.Clear(); + + Assert.AreEqual(0, doc.Count, "Count should be 0"); + Assert.AreEqual(3, doc.DeletedCount, "Deleted count should be 3"); + } + + [TestMethod] + public void NestedAddApplyEdit_AllChildrenRetained() + { + var doc = CreateDocument(); + + doc.BeginEdit(); + doc.Add(CreateLineItem()); + doc.BeginEdit(); + doc.Add(CreateLineItem()); + doc.BeginEdit(); + doc.Add(CreateLineItem()); + doc.ApplyEdit(); + doc.ApplyEdit(); + doc.ApplyEdit(); + + Assert.AreEqual(3, doc.Count); + } + + [TestMethod] + public void NestedAddDeleteApplyEdit_TracksDeletesThroughLevels() + { + var doc = CreateDocument(); + + doc.BeginEdit(); + doc.Add(CreateLineItem()); // A at level 1 + doc.BeginEdit(); + doc.Add(CreateLineItem()); // B at level 2 + doc.BeginEdit(); + doc.Add(CreateLineItem()); // C at level 3 + + var itemC = doc[2]; + Assert.IsTrue(doc.Contains(itemC), "Child should be in collection"); + + doc.RemoveAt(0); + doc.RemoveAt(0); + doc.RemoveAt(0); + + Assert.IsFalse(doc.Contains(itemC), "Child should not be in collection"); + Assert.IsTrue(doc.ContainsDeleted(itemC), "Deleted child should be in deleted collection"); + + doc.ApplyEdit(); + Assert.IsFalse(doc.ContainsDeleted(itemC), "After first ApplyEdit: C added at level 3 should be gone"); + + doc.ApplyEdit(); + Assert.IsFalse(doc.ContainsDeleted(itemC), "After second ApplyEdit"); + + doc.ApplyEdit(); + Assert.AreEqual(0, doc.Count, "No children should remain"); + Assert.IsFalse(doc.ContainsDeleted(itemC), "After third ApplyEdit"); + } + + #endregion + + #region Equality + + [TestMethod] + public void BasicEquality_DocumentEquality() + { + var doc1 = CreateDocument(); + Assert.IsTrue(doc1.Equals(doc1), "Same instance should be equal"); + Assert.IsTrue(Equals(doc1, doc1), "Same instance equal via static"); + + var doc2 = CreateDocument(); + Assert.IsFalse(doc1.Equals(doc2), "Different instances should not be equal"); + Assert.IsFalse(Equals(doc1, doc2), "Different instances not equal via static"); + + Assert.IsFalse(doc1.Equals(null), "Should not equal null"); + Assert.IsFalse(Equals(doc1, null), "Should not equal null via static"); + Assert.IsFalse(Equals(null, doc2), "null should not equal doc"); + } + + [TestMethod] + public void ChildEquality_WithinDocument() + { + var doc = CreateDocument(); + var c1 = CreateLineItem(); c1.Description = "abc"; doc.Add(c1); + var c2 = CreateLineItem(); c2.Description = "xyz"; doc.Add(c2); + var c3 = CreateLineItem(); c3.Description = "123"; doc.Add(c3); + doc.Remove(c3); + + Assert.IsTrue(c1.Equals(c1), "Same instance equal"); + Assert.IsTrue(Equals(c1, c1), "Same instance equal via static"); + + Assert.IsFalse(c1.Equals(c2), "Different instances not equal"); + Assert.IsFalse(Equals(c1, c2), "Different instances not equal via static"); + + Assert.IsFalse(c1.Equals(null), "Not equal to null"); + Assert.IsFalse(Equals(c1, null), "Not equal to null via static"); + Assert.IsFalse(Equals(null, c2), "null not equal to item"); + + Assert.IsTrue(doc.Contains(c1), "Doc should contain c1"); + Assert.IsTrue(doc.Contains(c2), "Doc should contain c2"); + Assert.IsFalse(doc.Contains(c3), "Doc should not contain removed c3"); + Assert.IsTrue(doc.ContainsDeleted(c3), "Deleted c3 should be tracked"); + } + + #endregion + + #region DeletedList Advanced + + [TestMethod] + public void DeletedListClone_PreservesDeletedItems() + { + var doc = FetchDocument(1); + doc.BeginEdit(); + doc.RemoveAt(0); + doc.RemoveAt(0); + doc.ApplyEdit(); + + var clone = doc.Clone(); + + var deletedItems = ((IContainsDeletedList)clone).DeletedList.Cast().ToList(); + Assert.AreEqual(2, deletedItems.Count, "Clone should have 2 deleted items"); + Assert.AreEqual("Item 0", deletedItems[0].Description); + Assert.AreEqual("Item 1", deletedItems[1].Description); + Assert.AreEqual(1, clone.Count); + } + + [TestMethod] + public void DeletedListCancelEdit_RestoresDeletedItems() + { + var doc = FetchDocument(1); + doc.BeginEdit(); + doc.RemoveAt(0); + doc.RemoveAt(0); + + var clone = doc.Clone(); + + var deletedInClone = ((IContainsDeletedList)clone).DeletedList.Cast().ToList(); + Assert.AreEqual(2, deletedInClone.Count, "Clone should see 2 deleted items"); + Assert.AreEqual(1, clone.Count); + + doc.CancelEdit(); + + var deletedAfterCancel = ((IContainsDeletedList)doc).DeletedList.Cast().ToList(); + Assert.AreEqual(0, deletedAfterCancel.Count, "Deleted list should be empty after CancelEdit"); + Assert.AreEqual(3, doc.Count, "All items restored after CancelEdit"); + } + + [TestMethod] + public void RemoveThenReAdd_ChildIsNotDeleted() + { + var doc = FetchDocument(1); + var child = doc[0]; + + doc.Remove(child); + Assert.IsTrue(doc.ContainsDeleted(child), "Child should be in deleted list after removal"); + Assert.AreEqual(2, doc.Count); + + doc.Add(child); + Assert.IsFalse(doc.ContainsDeleted(child), "Child should not be in deleted list after re-add"); + Assert.AreEqual(3, doc.Count); + Assert.IsFalse(child.IsDeleted, "Child should not be marked as deleted after re-add"); + } + + #endregion + + #region Event Suppression + + [TestMethod] + public void SuppressEvents_SuppressesCollectionChangedDuringBulkOps() + { + var doc = FetchDocument(1); + bool changed = false; + doc.CollectionChanged += (_, _) => changed = true; + + var item = CreateLineItem(); + item.Description = "Suppressed"; + + Assert.IsTrue(doc.RaiseListChangedEvents); + using (doc.SuppressListChangedEvents) + { + Assert.IsFalse(doc.RaiseListChangedEvents); + doc.Insert(0, item); + } + + Assert.IsFalse(changed, "CollectionChanged should not fire during suppression"); + Assert.IsTrue(doc.RaiseListChangedEvents); + Assert.AreEqual(item, doc[0]); + } + + #endregion + + #region InsertNonChildFails + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void InsertNonChild_ThrowsInvalidOperationException() + { + var doc = CreateDocument(); + var nonChild = new DocumentLineItem(); // not created via child data portal — IsChild = false + doc.Insert(0, nonChild); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void IndexerSet_NonChild_ThrowsInvalidOperationException() + { + var doc = FetchDocument(1); + var nonChild = new DocumentLineItem(); // not created via child data portal — IsChild = false + doc[0] = nonChild; + } + + #endregion + + #region Save Workflow + + [TestMethod] + public void AcceptChangesAndSaveAfterClone() + { + var doc = CreateDocument(); + doc.BeginEdit(); + doc.AddNew(); + + doc = doc.Clone(); + doc.ApplyEdit(); + + Assert.IsTrue(doc.IsDirty); + doc = doc.Save(); + Assert.IsFalse(doc.IsDirty); + } + + [TestMethod] + public void UndoAcceptAdd_SavesSuccessfully() + { + var doc = FetchDocument(1); + doc.BeginEdit(); + doc.AddNew(); + doc.ApplyEdit(); + + Assert.IsTrue(doc.IsDirty); + + doc = doc.Save(); + Assert.IsFalse(doc.IsDirty); + } + + [TestMethod] + public void UndoCancelAdd_RemovesAddedChild() + { + var doc = FetchDocument(1); + doc.BeginEdit(); + doc.AddNew(); + Assert.IsTrue(doc.IsDirty); + Assert.AreEqual(4, doc.Count); + + doc.CancelEdit(); + + Assert.IsFalse(doc.IsDirty); + Assert.AreEqual(3, doc.Count); + } + + #endregion + + #region Clone Advanced + + [TestMethod] + public void ClonePreservesChildEditLevel() + { + var doc = FetchDocument(1); + var child = doc[0]; + child.Description = "original"; + + doc.BeginEdit(); + child.Description = "modified"; + + var docEditLevel = ((Core.IUndoableObject)doc).EditLevel; + var childEditLevel = ((Core.IUndoableObject)child).EditLevel; + Assert.AreEqual(1, docEditLevel, "Doc EditLevel should be 1 before clone"); + Assert.AreEqual(1, childEditLevel, "Child EditLevel should be 1 before clone"); + + var cloned = doc.Clone(); + + Assert.AreEqual(1, ((Core.IUndoableObject)cloned).EditLevel, "Cloned doc EditLevel should be 1"); + Assert.AreEqual(1, ((Core.IUndoableObject)cloned[0]).EditLevel, "Cloned child EditLevel should be 1"); + Assert.AreEqual("modified", cloned[0].Description, "Modified value preserved in clone"); + } + + [TestMethod] + public void ClonePreservesUndoStateForCancelEdit_Advanced() + { + var doc = FetchDocument(1); + doc[0].Description = "original"; + + doc.BeginEdit(); + doc[0].Description = "modified"; + + var cloned = doc.Clone(); + cloned.CancelEdit(); + + Assert.AreEqual("original", cloned[0].Description, "CancelEdit on clone should restore original"); + Assert.AreEqual(0, ((Core.IUndoableObject)cloned).EditLevel, "EditLevel should be 0 after CancelEdit"); + } + + [TestMethod] + public void ClonePreservesUndoStateForApplyEdit_Advanced() + { + var doc = FetchDocument(1); + doc[0].Description = "original"; + + doc.BeginEdit(); + doc[0].Description = "modified"; + + var cloned = doc.Clone(); + cloned.ApplyEdit(); + + Assert.AreEqual("modified", cloned[0].Description, "ApplyEdit on clone should keep modified value"); + Assert.AreEqual(0, ((Core.IUndoableObject)cloned).EditLevel, "EditLevel should be 0 after ApplyEdit"); + + Assert.IsTrue(cloned.IsDirty); + cloned = cloned.Save(); + Assert.IsFalse(cloned.IsDirty); + } + + [TestMethod] + public void ClonePreservesMultiLevelUndo() + { + var doc = FetchDocument(1); + var child = doc[0]; + child.Description = "level0"; + + doc.BeginEdit(); + child.Description = "level1"; + + doc.BeginEdit(); + child.Description = "level2"; + + Assert.AreEqual(2, ((Core.IUndoableObject)doc).EditLevel, "Doc EditLevel should be 2"); + Assert.AreEqual(2, ((Core.IUndoableObject)child).EditLevel, "Child EditLevel should be 2"); + + var cloned = doc.Clone(); + var clonedChild = cloned[0]; + + Assert.AreEqual(2, ((Core.IUndoableObject)cloned).EditLevel, "Cloned doc EditLevel should be 2"); + Assert.AreEqual(2, ((Core.IUndoableObject)clonedChild).EditLevel, "Cloned child EditLevel should be 2"); + Assert.AreEqual("level2", clonedChild.Description); + + cloned.CancelEdit(); + Assert.AreEqual("level1", clonedChild.Description, "After first CancelEdit"); + Assert.AreEqual(1, ((Core.IUndoableObject)cloned).EditLevel); + + cloned.CancelEdit(); + Assert.AreEqual("level0", clonedChild.Description, "After second CancelEdit"); + Assert.AreEqual(0, ((Core.IUndoableObject)cloned).EditLevel); + } + + [TestMethod] + public void ClonePreservesChildAddedDuringEdit() + { + var doc = FetchDocument(1); + + doc.BeginEdit(); + var newChild = doc.AddNew(); + newChild.Description = "new child"; + + var cloned = doc.Clone(); + + Assert.AreEqual(4, cloned.Count, "Clone should have 4 children"); + Assert.AreEqual("new child", cloned[3].Description); + + cloned.CancelEdit(); + Assert.AreEqual(3, cloned.Count, "Child added during edit should be removed on CancelEdit"); + } + + [TestMethod] + public void ClonePreservesEditLevelViaBindingSource() + { + var doc = FetchDocument(1); + var child = doc[0]; + child.Description = "original"; + + ((System.ComponentModel.IEditableObject)doc).BeginEdit(); + ((System.ComponentModel.IEditableObject)child).BeginEdit(); + child.Description = "modified"; + + Assert.AreEqual(1, ((Core.IUndoableObject)doc).EditLevel, "Doc EditLevel should be 1"); + Assert.AreEqual(2, ((Core.IUndoableObject)child).EditLevel, "Child EditLevel should be 2 (both root and direct BeginEdit)"); + + var cloned = doc.Clone(); + + Assert.AreEqual(1, ((Core.IUndoableObject)cloned).EditLevel, "Cloned doc EditLevel should be 1"); + Assert.AreEqual(2, ((Core.IUndoableObject)cloned[0]).EditLevel, "Cloned child EditLevel should be 2"); + Assert.AreEqual("modified", cloned[0].Description, "Modified value preserved in clone"); + } + + #endregion + + #region Async + + [TestMethod] + public async Task WaitForIdle_SingleChildWithAsyncRule() + { + var doc = CreateDocument(); + var child = doc.AddNew(); + + child.AsyncRuleText = "trigger rule"; + + await doc.WaitForIdle(TimeSpan.FromSeconds(4)); + + Assert.IsFalse(doc.IsBusy); + } + + [TestMethod] + public async Task WaitForIdle_MultipleChildrenWithAsyncRules() + { + var doc = CreateDocument(); + var child1 = doc.AddNew(); + var child2 = doc.AddNew(); + + child1.AsyncRuleText = "trigger rule 1"; + + await Task.Delay(TimeSpan.FromMilliseconds(500)); + + child2.AsyncRuleText = "trigger rule 2"; + + await doc.WaitForIdle(TimeSpan.FromSeconds(4)); + + Assert.IsFalse(child1.IsBusy); + Assert.IsFalse(child2.IsBusy); + Assert.IsFalse(doc.IsBusy); + } + + #endregion + + #region Parent References + + [TestMethod] + public async Task SerializationRoundtrip_PreservesParentOnDeletedItems() + { + var doc = FetchDocument(1); + doc.Clear(); // moves all 3 fetched items to deleted list + + var serializer = _testDIContext.ServiceProvider.GetRequiredService(); + var transferred = (TestDocument)serializer.Deserialize(serializer.Serialize(doc)); + + var deletedItems = ((IContainsDeletedList)transferred).DeletedList.Cast().ToList(); + Assert.AreEqual(3, deletedItems.Count, "All 3 items should be in deleted list after roundtrip"); + + foreach (var deletedItem in deletedItems) + Assert.AreSame(transferred, deletedItem.Parent, "Deleted item Parent should be the document after roundtrip"); + } + + #endregion + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs b/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs new file mode 100644 index 0000000000..e597ad2c50 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs @@ -0,0 +1,76 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Test child object for BusinessDocumentBase tests +//----------------------------------------------------------------------- + +using Csla.Rules; + +namespace Csla.Test.BusinessDocumentBase +{ + [Serializable] + public class DocumentLineItem : BusinessBase + { + public static readonly PropertyInfo DescriptionProperty = RegisterProperty(nameof(Description)); + public string Description + { + get => GetProperty(DescriptionProperty); + set => SetProperty(DescriptionProperty, value); + } + + public static readonly PropertyInfo AmountProperty = RegisterProperty(nameof(Amount)); + public decimal Amount + { + get => GetProperty(AmountProperty); + set => SetProperty(AmountProperty, value); + } + + public static readonly PropertyInfo AsyncRuleTextProperty = RegisterProperty(nameof(AsyncRuleText)); + public string AsyncRuleText + { + get => GetProperty(AsyncRuleTextProperty); + set => SetProperty(AsyncRuleTextProperty, value); + } + + protected override void AddBusinessRules() + { + base.AddBusinessRules(); + BusinessRules.AddRule(new OneSecondAsyncRule(AsyncRuleTextProperty)); + } + + [CreateChild] + private void CreateChild() { } + + [FetchChild] + private void Child_Fetch(string description, decimal amount) + { + Description = description; + Amount = amount; + MarkOld(); + } + + [InsertChild] + private void Child_Insert() { } + + [UpdateChild] + private void Child_Update() { } + + [DeleteSelfChild] + private void Child_DeleteSelf() { } + + private sealed class OneSecondAsyncRule : BusinessRuleAsync + { + public OneSecondAsyncRule(Core.IPropertyInfo primaryProperty) : base(primaryProperty) + { + InputProperties.Add(primaryProperty); + } + + protected override async Task ExecuteAsync(IRuleContext context) + { + await Task.Delay(TimeSpan.FromSeconds(1)); + } + } + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs b/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs new file mode 100644 index 0000000000..1048d569b0 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Test document for metastate property change event tests. +//----------------------------------------------------------------------- + +using System.ComponentModel.DataAnnotations; + +namespace Csla.Test.BusinessDocumentBase +{ + [Serializable] + public class MetastateDocument : BusinessDocumentBase + { + public static readonly PropertyInfo IdProperty = RegisterProperty(nameof(Id)); + public int Id + { + get => GetProperty(IdProperty); + set => SetProperty(IdProperty, value); + } + + public static readonly PropertyInfo NameProperty = RegisterProperty(nameof(Name)); + [Required] + public string Name + { + get => GetProperty(NameProperty); + set => SetProperty(NameProperty, value); + } + + public void MakeOld() => MarkOld(); + + [Create] + private void DataPortal_Create() + { + BusinessRules.CheckRules(); + } + + [Insert] + private void DataPortal_Insert() + { + FieldManager.UpdateChildren(); + Child_Update(); + } + + [Update] + private void DataPortal_Update() + { + FieldManager.UpdateChildren(); + Child_Update(); + } + + [DeleteSelf] + private void DataPortal_DeleteSelf() + { + Child_Update(); + } + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/MetastateLineItem.cs b/Source/tests/Csla.test/BusinessDocumentBase/MetastateLineItem.cs new file mode 100644 index 0000000000..f115673a80 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/MetastateLineItem.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Minimal child object for MetastateDocument tests. +//----------------------------------------------------------------------- + +namespace Csla.Test.BusinessDocumentBase +{ + [Serializable] + public class MetastateLineItem : BusinessBase + { + public static readonly PropertyInfo NameProperty = RegisterProperty(nameof(Name)); + public string Name + { + get => GetProperty(NameProperty); + set => SetProperty(NameProperty, value); + } + + [CreateChild] + private void CreateChild() { } + + [FetchChild] + private void Child_Fetch() { MarkOld(); } + + [InsertChild] + private void Child_Insert() { } + + [UpdateChild] + private void Child_Update() { } + + [DeleteSelfChild] + private void Child_DeleteSelf() { } + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs b/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs new file mode 100644 index 0000000000..e1c766db07 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs @@ -0,0 +1,84 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Test document object combining properties and child collection +//----------------------------------------------------------------------- + +namespace Csla.Test.BusinessDocumentBase +{ + [Serializable] + public class TestDocument : BusinessDocumentBase + { + public static readonly PropertyInfo DocumentNumberProperty = RegisterProperty(nameof(DocumentNumber)); + public string DocumentNumber + { + get => GetProperty(DocumentNumberProperty); + set => SetProperty(DocumentNumberProperty, value); + } + + public static readonly PropertyInfo DocumentDateProperty = RegisterProperty(nameof(DocumentDate)); + public DateTime DocumentDate + { + get => GetProperty(DocumentDateProperty); + set => SetProperty(DocumentDateProperty, value); + } + + [NotUndoable] + private string _notUndoableData = string.Empty; + + public string NotUndoableData + { + get => _notUndoableData; + set => _notUndoableData = value; + } + + public int DeletedCount => DeletedList.Count; + + public void MakeOld() => MarkOld(); + + [Create] + private void DataPortal_Create() + { + BusinessRules.CheckRules(); + } + + [Fetch] + private void DataPortal_Fetch(int id, [Inject] IChildDataPortal childPortal) + { + using (LoadListMode) + { + DocumentNumber = "DOC-" + id; + DocumentDate = DateTime.Today; + // Simulate loading child items using FetchChild so they are marked old + for (int i = 0; i < 3; i++) + { + var child = childPortal.FetchChild("Item " + i, (i + 1) * 10m); + Add(child); + } + } + MarkOld(); + } + + [Insert] + private void DataPortal_Insert() + { + FieldManager.UpdateChildren(); + Child_Update(); + } + + [Update] + private void DataPortal_Update() + { + FieldManager.UpdateChildren(); + Child_Update(); + } + + [DeleteSelf] + private void DataPortal_DeleteSelf() + { + Child_Update(); + } + } +} diff --git a/Source/tests/Csla.test/Server/FakeEntity.cs b/Source/tests/Csla.test/Server/FakeEntity.cs index c86c3a1fa5..28109311da 100644 --- a/Source/tests/Csla.test/Server/FakeEntity.cs +++ b/Source/tests/Csla.test/Server/FakeEntity.cs @@ -53,6 +53,9 @@ public void CancelEdit() { } public void CopyState(int parentEditLevel, bool parentBindingEdit) { } public void Delete() { } public void DeleteChild() { } +#if NET8_0_OR_GREATER + public void UnDeleteChild() { } +#endif public void GetChildren(SerializationInfo info, MobileFormatter formatter) { } public void GetState(SerializationInfo info) { } public object Save() diff --git a/releasenotes.md b/releasenotes.md index d565d168de..0e1a7366fc 100644 --- a/releasenotes.md +++ b/releasenotes.md @@ -4,8 +4,73 @@ CSLA 10 is a substantial update to CSLA .NET, adding support for .NET 10 and inc For detailed migration guidance, see [Upgrading to CSLA 10](docs/Upgrading%20to%20CSLA%2010.md). +## CSLA .NET version 10.1.0 release + +The full list of changes in this release can be found in the [GitHub compare view](https://github.com/MarimerLLC/csla/compare/v10.0.0...v10.1.0). + +### Highlights + +**New BusinessDocumentBase type** ([#1830](https://github.com/MarimerLLC/csla/issues/1830)) + +New `BusinessDocumentBase` class that combines the features of `BusinessBase` and `BusinessListBase` in a single type. This allows you to create an editable business object that has properties _and also_ contains a list of child objects, without needing a separate class for the child list. The `[CslaImplementProperties]` source generator has been updated to support this new base type. The ProjectTracker sample has been updated to demonstrate the new pattern. + +**New WCF data portal channel** ([#4851](https://github.com/MarimerLLC/csla/pull/4851)) + +New `Csla.Channels.Wcf` NuGet package providing a WCF data portal channel. Uses classic WCF (`System.ServiceModel`) for .NET Framework applications and CoreWCF for modern .NET applications. Supports both client and server side, including routing tag-based operation dispatch. Similar in structure to the existing HTTP, gRPC, and RabbitMQ channels. + +**Data portal source generator** ([#4359](https://github.com/MarimerLLC/csla/issues/4359), [#4816](https://github.com/MarimerLLC/csla/pull/4816)) + +New source generator for data portal operation dispatch, improving performance and trimming support. + +### Changes + +**Data Portal** + +* [#4616](https://github.com/MarimerLLC/csla/issues/4616) Make legacy `DataPortal_XYZ` method resolution optional ([#4829](https://github.com/MarimerLLC/csla/pull/4829)) +* [#4817](https://github.com/MarimerLLC/csla/pull/4817) Flow operation names from client to server +* [#4823](https://github.com/MarimerLLC/csla/pull/4823) Support keyed DI services in data portal parameter injection +* [#4825](https://github.com/MarimerLLC/csla/pull/4825) Refactor portal error handling, add integration tests +* [#4849](https://github.com/MarimerLLC/csla/pull/4849) Update gRPC and RabbitMQ deserialization +* [#4844](https://github.com/MarimerLLC/csla/pull/4844) Remove `DataPortalAsyncRequest`/`Result` nested classes + +**Rules Engine** + +* [#4649](https://github.com/MarimerLLC/csla/issues/4649) Thread-safe broken rules collection ([#4819](https://github.com/MarimerLLC/csla/pull/4819)) + +**Blazor** + +* [#4565](https://github.com/MarimerLLC/csla/issues/4565) Abstract out session store from `SessionManager` ([#4832](https://github.com/MarimerLLC/csla/pull/4832)) + +**Bug Fixes** + +* [#4852](https://github.com/MarimerLLC/csla/issues/4852) Fix task conversion for data portal operations ([#4854](https://github.com/MarimerLLC/csla/pull/4854)) +* [#4841](https://github.com/MarimerLLC/csla/issues/4841) Fix `DataPortalServerRoutingTagAttribute` ([#4847](https://github.com/MarimerLLC/csla/pull/4847)) +* [#4821](https://github.com/MarimerLLC/csla/pull/4821) Fix `EditLevelMismatch` when cloning object graphs during edit sessions + +**Code Quality** + +* [#3214](https://github.com/MarimerLLC/csla/issues/3214) Add `[StringSyntax]` attribute to `RegExMatch` for IDE regex highlighting ([#4827](https://github.com/MarimerLLC/csla/pull/4827)) +* [#4842](https://github.com/MarimerLLC/csla/pull/4842) Replace duplicate dictionary lookups with `TryGetValue` and cached locals +* [#4843](https://github.com/MarimerLLC/csla/pull/4843) Null-coalescing assignment cleanup +* [#4845](https://github.com/MarimerLLC/csla/pull/4845) Update Polyfill and use char-based string join +* [#4812](https://github.com/MarimerLLC/csla/pull/4812) Fix documentation warnings and improve portability +* [#4822](https://github.com/MarimerLLC/csla/pull/4822) Clarify `MobileFormatter` collection type limitations in docs +* [#4820](https://github.com/MarimerLLC/csla/pull/4820) Migrate database tests from SQL Server to SQLite + +### Contributors + +* [@rockfordlhotka](https://github.com/rockfordlhotka) +* [@StefanOssendorf](https://github.com/StefanOssendorf) +* [@SimonCropp](https://github.com/SimonCropp) +* [@Bowman74](https://github.com/Bowman74) +* [@b-higginbotham](https://github.com/b-higginbotham) +* [@joshhanson314](https://github.com/joshhanson314) +* [@luizfbicalho](https://github.com/luizfbicalho) + ## CSLA .NET version 10.0.0 release +CSLA 10 is a substantial update to CSLA .NET, adding support for .NET 10 and including many enhancements and bug fixes. + ### Highlights **Platform Updates**