From d85925dec76201a6a2e19be8e96e563cc7ea8415 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Thu, 5 Feb 2026 11:58:21 -0600 Subject: [PATCH 01/19] Add BusinessDocumentBase implementation plan Document the design for a new class that combines BusinessBase and BusinessListBase capabilities using composition. This enables the "Document Pattern" where a business object has its own properties AND contains a collection of child items. The plan includes: - Complete interface analysis from both base classes - Conflict resolution strategies for shared interfaces - 10-phase implementation plan with specific tasks - Risk assessment and success criteria Co-Authored-By: Claude Opus 4.5 --- Source/Csla/BusinessDocumentBase-PLAN.md | 670 +++++++++++++++++++++++ 1 file changed, 670 insertions(+) create mode 100644 Source/Csla/BusinessDocumentBase-PLAN.md diff --git a/Source/Csla/BusinessDocumentBase-PLAN.md b/Source/Csla/BusinessDocumentBase-PLAN.md new file mode 100644 index 0000000000..c745937c83 --- /dev/null +++ b/Source/Csla/BusinessDocumentBase-PLAN.md @@ -0,0 +1,670 @@ +# BusinessDocumentBase Implementation Plan + +## Overview + +This document outlines the plan to create a new `BusinessDocumentBase` class that combines the capabilities of both `BusinessBase` and `BusinessListBase`. This enables the "Document Pattern" where a single business object has its own properties AND contains a collection of child items. + +### Use Case Example + +```csharp +// An Invoice that has its own properties AND contains LineItems +public class Invoice : BusinessDocumentBase +{ + // Invoice properties (InvoiceNumber, Date, Customer, etc.) + public static readonly PropertyInfo InvoiceNumberProperty = RegisterProperty(nameof(InvoiceNumber)); + public string InvoiceNumber + { + get => GetProperty(InvoiceNumberProperty); + set => SetProperty(InvoiceNumberProperty, value); + } + + // LineItems are managed by the base class + // Access via: invoice.Items, invoice[0], invoice.Add(lineItem), etc. +} +``` + +## Requirements + +1. **Full BusinessBase functionality**: Properties, business rules, authorization, validation, n-level undo +2. **Full BusinessListBase functionality**: Child collection management, deleted list tracking, collection change notifications +3. **Interface compatibility**: Must be substitutable for both `BusinessBase` and `BusinessListBase` where appropriate +4. **Serialization**: Must serialize both object properties AND child items +5. **Coordinated state**: `IsDirty`, `IsValid`, `IsBusy` must aggregate both object and children state + +--- + +## Interface Analysis + +### Interfaces Implemented by BusinessBase + +#### From Inheritance Hierarchy +| Class | Interfaces | +|-------|------------| +| `MobileObject` | `IMobileObject`, `IMobileObjectMetastate` | +| `BindableBase` | `INotifyPropertyChanged`, `INotifyPropertyChanging` | +| `UndoableBase` | `IUndoableObject`, `IUseApplicationContext` | +| `Core.BusinessBase` | `IEditableBusinessObject`, `IEditableObject`, `ICloneable`, `IAuthorizeReadWrite`, `IParent`, `IDataPortalTarget`, `IManageProperties`, `IHostRules`, `ICheckRules`, `INotifyChildChanged`, `ISerializationNotification`, `IDataErrorInfo`, `INotifyDataErrorInfo`, `IUseFieldManager`, `IUseBusinessRules` | +| `BusinessBase` | `ISavable`, `ISavable`, `IBusinessBase` | + +### Interfaces Implemented by BusinessListBase + +#### From Inheritance Hierarchy +| Class | Interfaces | +|-------|------------| +| `ObservableCollection` | `IList`, `ICollection`, `IEnumerable`, `INotifyCollectionChanged`, `INotifyPropertyChanged` | +| `MobileObservableCollection` | `IMobileList`, `IMobileObject`, `IMobileObjectMetastate` | +| `ObservableBindingList` | `IObservableBindingList`, `INotifyBusy`, `INotifyChildChanged`, `ISerializationNotification` | +| `BusinessListBase` | `IContainsDeletedList`, `ISavable`, `IDataPortalTarget`, `IBusinessListBase`, `IUseApplicationContext` | + +#### IBusinessListBase Consolidates +- `IEditableCollection` (which includes `IBusinessObject`, `ISupportUndo`, `ITrackStatus`) +- `IUndoableObject` +- `ICloneable` +- `ISavable` +- `IParent` +- `IObservableBindingList` +- `INotifyChildChanged` +- `ISerializationNotification` +- `IMobileObject` +- `INotifyCollectionChanged` +- `INotifyPropertyChanged` +- `IList` + +--- + +## Interface Conflicts & Resolutions + +### Conflict 1: ITrackStatus (IsNew, IsDeleted, IsDirty, IsValid) + +| Property | BusinessBase Behavior | BusinessListBase Behavior | Resolution | +|----------|----------------------|--------------------------|------------| +| `IsNew` | True if object not yet persisted | Always `false` | **Use BusinessBase behavior** - the document itself can be new | +| `IsDeleted` | True if marked for deletion | Always `false` | **Use BusinessBase behavior** - the document itself can be deleted | +| `IsDirty` | True if object properties changed | True if any child is dirty or non-new deleted items exist | **Aggregate**: `ObjectIsDirty || ChildrenAreDirty || DeletedItemsExist` | +| `IsSelfDirty` | True if object's own properties changed | Same as `IsDirty` | **Use BusinessBase behavior** for object's own state | +| `IsValid` | True if no broken rules | True if all children valid | **Aggregate**: `ObjectIsValid && AllChildrenValid` | +| `IsSelfValid` | True if object's own rules pass | Same as `IsValid` | **Use BusinessBase behavior** for object's own state | +| `IsSavable` | Dirty && Valid && !Busy && Authorized | Dirty && Valid && !Busy && Authorized | **Same logic**, but using aggregated values | +| `IsBusy` | True if object is busy | True if any child is busy | **Aggregate**: `ObjectIsBusy || AnyChildIsBusy` | + +### Conflict 2: IUndoableObject (EditLevel, CopyState, UndoChanges, AcceptChanges) + +| Member | BusinessBase Behavior | BusinessListBase Behavior | Resolution | +|--------|----------------------|--------------------------|------------| +| `EditLevel` | Tracks object's edit depth | Tracks collection's edit depth | **Single EditLevel** - they must be synchronized | +| `CopyState` | Snapshots object properties | Cascades to all children + deleted | **Do both**: Snapshot object state AND cascade to children | +| `UndoChanges` | Restores object properties | Restores children, handles deletions | **Do both**: Restore object state AND cascade to children | +| `AcceptChanges` | Commits object properties | Commits children, clears deleted below level | **Do both**: Commit object state AND cascade to children | + +### Conflict 3: ISavable / ISavable + +| Member | BusinessBase Behavior | BusinessListBase Behavior | Resolution | +|--------|----------------------|--------------------------|------------| +| `Save()` | Saves object via DataPortal.Update | Saves all children via DataPortal.Update | **Combined**: Save object (which triggers child saves in DataPortal methods) | +| `SaveAsync()` | Async version | Async version | Same | +| `SaveAndMergeAsync()` | Saves and merges result | Saves and merges result | Same | +| `Saved` event | Raised after save | Raised after save | Same | + +### Conflict 4: IDataPortalTarget + +| Member | BusinessBase Behavior | BusinessListBase Behavior | Resolution | +|--------|----------------------|--------------------------|------------| +| `MarkAsChild()` | Marks object as child | Marks collection as child | **Same** | +| `MarkNew()` | Sets `IsNew = true` | No-op | **Use BusinessBase behavior** | +| `MarkOld()` | Sets `IsNew = false` | No-op | **Use BusinessBase behavior** | +| `CheckRules()` | Executes business rules | No-op | **Use BusinessBase behavior** | +| `CheckRulesAsync()` | Async version | No-op (returns completed task) | **Use BusinessBase behavior** | + +### Conflict 5: IEditableBusinessObject vs IEditableCollection + +These are **different interfaces** for different purposes: +- `IEditableBusinessObject`: For single objects that can be children in a collection +- `IEditableCollection`: For collections that contain children + +**Resolution**: `BusinessDocumentBase` implements **both** because it IS a business object that CAN be a child, AND it IS a collection that CONTAINS children. + +### Conflict 6: IBusinessBase vs IBusinessListBase + +**Resolution**: Create a new consolidated interface `IBusinessDocumentBase` that extends both: + +```csharp +public interface IBusinessDocumentBase : IBusinessBase, IBusinessListBase + where C : IEditableBusinessObject +{ + // Any additional members specific to document pattern +} +``` + +--- + +## Complete Interface List for BusinessDocumentBase + +### Must Implement (Union of Both) + +```csharp +public abstract class BusinessDocumentBase : BusinessBase, + // From BusinessBase (inherited) + // - IEditableBusinessObject (includes IBusinessObject, ISupportUndo, IUndoableObject, ITrackStatus) + // - IEditableObject + // - ICloneable + // - IAuthorizeReadWrite + // - IParent + // - IDataPortalTarget + // - IManageProperties + // - IHostRules + // - ICheckRules + // - INotifyChildChanged + // - ISerializationNotification + // - IDataErrorInfo + // - INotifyDataErrorInfo + // - IUseFieldManager + // - IUseBusinessRules + // - ISavable, ISavable + // - IBusinessBase + // - IMobileObject, IMobileObjectMetastate + // - INotifyPropertyChanged, INotifyPropertyChanging + // - IUndoableObject + // - IUseApplicationContext + + // Additional from BusinessListBase (must implement explicitly) + IEditableCollection, // Collection-specific editing + IContainsDeletedList, // Deleted items tracking + IObservableBindingList, // AddNew, AllowEdit, AllowRemove + INotifyCollectionChanged, // Collection change notifications + IList, // Full list interface + IBusinessDocumentBase // New consolidated interface + + where T : BusinessDocumentBase + where C : IEditableBusinessObject +``` + +--- + +## Architecture Design + +### Class Hierarchy + +``` +System.Object +└── MobileObject (IMobileObject, IMobileObjectMetastate) + └── BindableBase (INotifyPropertyChanged, INotifyPropertyChanging) + └── UndoableBase (IUndoableObject, IUseApplicationContext) + └── Core.BusinessBase (IEditableBusinessObject, IEditableObject, ICloneable, + │ IAuthorizeReadWrite, IParent, IDataPortalTarget, + │ IManageProperties, IHostRules, ICheckRules, + │ INotifyChildChanged, ISerializationNotification, + │ IDataErrorInfo, INotifyDataErrorInfo, + │ IUseFieldManager, IUseBusinessRules) + └── BusinessBase (ISavable, ISavable, IBusinessBase) + └── BusinessDocumentBase (IEditableCollection, IContainsDeletedList, + IObservableBindingList, INotifyCollectionChanged, + IList, IBusinessDocumentBase) +``` + +### Internal Components + +``` +BusinessDocumentBase +├── Inherited from BusinessBase: +│ ├── FieldManager (property storage) +│ ├── BusinessRules (validation engine) +│ ├── Authorization cache +│ └── State tracking (IsNew, IsDeleted, _isDirty) +│ +└── New components for collection support: + ├── _items : MobileBindingList // Active child items + ├── _deletedItems : MobileList // Deleted child items + ├── _allowNew : bool // IObservableBindingList + ├── _allowEdit : bool // IObservableBindingList + ├── _allowRemove : bool // IObservableBindingList + └── _raiseListChangedEvents : bool // Notification control +``` + +--- + +## Implementation Plan + +### Phase 1: Interface Definitions + +#### Task 1.1: Create IBusinessDocumentBase Interface +**File**: `Source/Csla/IBusinessDocumentBase.cs` + +```csharp +public interface IBusinessDocumentBase : + IBusinessBase, // All single-object interfaces + IBusinessListBase // All collection interfaces + where C : IEditableBusinessObject +{ + // Document-specific members (if any) +} +``` + +#### Task 1.2: Review/Update IEditableCollection +Ensure it has all necessary members for collection editing support. + +### Phase 2: Core Class Structure + +#### Task 2.1: Create BusinessDocumentBase Class Shell +**File**: `Source/Csla/BusinessDocumentBase.cs` + +- Class declaration with all interfaces +- Generic constraints +- Constructor + +#### Task 2.2: Implement Child Collection Storage +- `_items` field (active children) +- `_deletedItems` field (deleted children) +- `Items` property (public access) +- `DeletedList` property (protected access) + +### Phase 3: Collection Interface Implementation + +#### Task 3.1: IList Implementation +- `Count`, `IsReadOnly` +- `this[int index]` indexer +- `Add`, `Insert`, `Remove`, `RemoveAt`, `Clear` +- `Contains`, `IndexOf`, `CopyTo` +- `GetEnumerator` + +#### Task 3.2: INotifyCollectionChanged Implementation +- `CollectionChanged` event +- `OnCollectionChanged` method +- Raise events from Add/Remove/Clear/Set operations + +#### Task 3.3: IObservableBindingList Implementation +- `AllowNew`, `AllowEdit`, `AllowRemove` properties +- `AddNew()`, `AddNewAsync()` methods +- `AddedNew` event + +#### Task 3.4: IContainsDeletedList Implementation +- `DeletedList` property (IEnumerable) +- `ContainsDeleted(C item)` method + +#### Task 3.5: IEditableCollection Implementation +- `RemoveChild(IEditableBusinessObject child)` +- `GetDeletedList()` +- `SetParent(IParent parent)` + +### Phase 4: Child Item Management + +#### Task 4.1: InsertItem Logic +- Validate item is marked as child +- Set parent reference to `this` +- Set ApplicationContext +- Set EditLevelAdded +- Ensure unique identity +- Hook child events +- Raise CollectionChanged + +#### Task 4.2: RemoveItem Logic +- Move to deleted list (unless completely removing) +- Unhook child events +- Reset child edit level +- Mark child as deleted +- Raise CollectionChanged + +#### Task 4.3: SetItem Logic +- Delete old item +- Insert new item +- Handle events appropriately + +#### Task 4.4: ClearItems Logic +- Move all items to deleted list +- Unhook all events +- Raise Reset notification + +### Phase 5: Status Property Overrides + +#### Task 5.1: Override IsDirty +```csharp +public override bool IsDirty +{ + get + { + // Object's own dirty state + if (base.IsDirty) + return true; + + // Check deleted items (non-new deletions are dirty) + foreach (var item in _deletedItems) + if (!item.IsNew) + return true; + + // Check active children + foreach (var child in _items) + if (child.IsDirty) + return true; + + return false; + } +} +``` + +#### Task 5.2: Override IsValid +```csharp +public override bool IsValid +{ + get + { + // Object's own validation + if (!base.IsValid) + return false; + + // Check all children + foreach (var child in _items) + if (!child.IsValid) + return false; + + return true; + } +} +``` + +#### Task 5.3: Override IsBusy +```csharp +public override bool IsBusy +{ + get + { + if (base.IsBusy) + return true; + + foreach (var item in _deletedItems) + if (item.IsBusy) + return true; + + foreach (var child in _items) + if (child.IsBusy) + return true; + + return false; + } +} +``` + +### Phase 6: N-Level Undo Support + +#### Task 6.1: Override CopyState +```csharp +protected override void CopyState(int parentEditLevel, bool parentBindingEdit) +{ + // Copy object's own state + base.CopyState(parentEditLevel, parentBindingEdit); + + // Cascade to all active children + foreach (var child in _items) + child.CopyState(EditLevel, false); + + // Cascade to deleted children + foreach (var child in _deletedItems) + child.CopyState(EditLevel, false); +} +``` + +#### Task 6.2: Override UndoChanges +- Restore object's own state +- Undo changes in all children +- Remove children added below current edit level +- Restore children deleted above current edit level + +#### Task 6.3: Override AcceptChanges +- Accept object's own state +- Accept changes in all children +- Remove deleted children below current edit level +- Update EditLevelAdded for children + +### Phase 7: Serialization Support + +#### Task 7.1: Override OnGetState +```csharp +protected override void OnGetState(SerializationInfo info) +{ + base.OnGetState(info); + // Add collection-specific state + info.AddValue("_allowNew", _allowNew); + info.AddValue("_allowEdit", _allowEdit); + info.AddValue("_allowRemove", _allowRemove); +} +``` + +#### Task 7.2: Override OnSetState +```csharp +protected override void OnSetState(SerializationInfo info) +{ + base.OnSetState(info); + _allowNew = info.GetValue("_allowNew"); + _allowEdit = info.GetValue("_allowEdit"); + _allowRemove = info.GetValue("_allowRemove"); +} +``` + +#### Task 7.3: Override OnGetChildren +```csharp +protected override void OnGetChildren(SerializationInfo info, MobileFormatter formatter) +{ + base.OnGetChildren(info, formatter); + + // Serialize items collection + var itemsInfo = formatter.SerializeObject(_items); + info.AddChild("_items", itemsInfo.ReferenceId); + + // Serialize deleted items + if (_deletedItems != null && _deletedItems.Count > 0) + { + var deletedInfo = formatter.SerializeObject(_deletedItems); + info.AddChild("_deletedItems", deletedInfo.ReferenceId); + } +} +``` + +#### Task 7.4: Override OnSetChildren +```csharp +protected override void OnSetChildren(SerializationInfo info, MobileFormatter formatter) +{ + base.OnSetChildren(info, formatter); + + if (info.Children.TryGetValue("_items", out var itemsChild)) + _items = (MobileBindingList)formatter.GetObject(itemsChild.ReferenceId); + + if (info.Children.TryGetValue("_deletedItems", out var deletedChild)) + _deletedItems = (MobileList)formatter.GetObject(deletedChild.ReferenceId); +} +``` + +#### Task 7.5: Override OnGetMetastate / OnSetMetastate +Handle binary metastate for collection-specific flags. + +#### Task 7.6: Override Deserialized +- Call base +- Re-establish parent references for all children +- Hook child events + +### Phase 8: Data Portal Integration + +#### Task 8.1: Child_Update Implementation +```csharp +[EditorBrowsable(EditorBrowsableState.Advanced)] +protected virtual void Child_Update(params object[] parameters) +{ + var dp = ApplicationContext.CreateInstanceDI>(); + + // Update deleted items first + foreach (var child in _deletedItems) + dp.UpdateChild(child, parameters); + _deletedItems.Clear(); + + // Update dirty active items + foreach (var child in _items) + if (child.IsDirty) + dp.UpdateChild(child, parameters); +} +``` + +#### Task 8.2: Child_UpdateAsync Implementation +Async version of above. + +#### Task 8.3: DataPortal_XYZ Event Handlers +Implement all DataPortal event handlers following same pattern as BusinessListBase. + +### Phase 9: Additional Features + +#### Task 9.1: Clone Support +Override `GetClone()` to ensure proper deep cloning of children. + +#### Task 9.2: Child Event Handling +- Hook `PropertyChanged` on children +- Hook `BusyChanged` on children +- Hook `ChildChanged` on children +- Bubble events appropriately + +#### Task 9.3: LoadListMode Support +Implement `LoadListMode` property/pattern for bulk loading without events. + +#### Task 9.4: WaitForIdle Support +Override to wait for both object and all children. + +### Phase 10: Testing & Documentation + +#### Task 10.1: Unit Tests +- Status aggregation tests +- Serialization round-trip tests +- N-level undo tests +- Collection operation tests +- Data portal integration tests + +#### Task 10.2: Integration Tests +- Test with actual child business objects +- Test serialization across data portal +- Test with UI data binding + +#### Task 10.3: Documentation +- XML documentation on all public members +- Usage examples +- Migration guide for existing code + +--- + +## File Locations + +| File | Purpose | +|------|---------| +| `Source/Csla/IBusinessDocumentBase.cs` | New consolidated interface | +| `Source/Csla/BusinessDocumentBase.cs` | Main implementation class | +| `Source/Csla/Core/MobileBindingList.cs` | Internal collection class (if needed) | +| `Source/Csla.Tests/BusinessDocumentBaseTests.cs` | Unit tests | + +--- + +## Risk Assessment + +### High Risk Areas + +1. **Serialization Coordination**: Must ensure both object state and children serialize correctly in all scenarios (clone, data portal, undo state) + +2. **Edit Level Synchronization**: Object and children must maintain consistent edit levels through all operations + +3. **Parent Reference Management**: Children must always have correct parent reference, especially after deserialization + +4. **Event Cascade**: Child changes must properly bubble up without causing infinite loops + +### Mitigation Strategies + +1. **Comprehensive Testing**: Test all serialization paths (MobileFormatter, clone, data portal) + +2. **Follow Existing Patterns**: Mirror BusinessListBase's edit level management exactly + +3. **Deserialized Override**: Always re-establish parent references after deserialization + +4. **Event Suppression**: Use flags to prevent event cascade during internal operations + +--- + +## Success Criteria + +1. ✅ Can create a document-style business object with properties AND children +2. ✅ `IsDirty`/`IsValid`/`IsBusy` correctly aggregate object and children state +3. ✅ N-level undo works correctly for both object properties and children +4. ✅ Serialization preserves both object state and children +5. ✅ Data portal save correctly persists object and children +6. ✅ Can be used anywhere a `BusinessBase` is expected (for single-object operations) +7. ✅ Can be used anywhere a `BusinessListBase` is expected (for collection operations) +8. ✅ Child changes bubble up through `ChildChanged` event +9. ✅ Collection changes raise `CollectionChanged` event +10. ✅ Works with UI data binding (Blazor, WPF, etc.) + +--- + +## Appendix A: Interface Member Inventory + +### IEditableCollection Members +```csharp +void RemoveChild(IEditableBusinessObject child); +object GetDeletedList(); +void SetParent(IParent parent); +``` + +### IObservableBindingList Members +```csharp +bool AllowNew { get; set; } +bool AllowEdit { get; set; } +bool AllowRemove { get; set; } +object AddNew(); +Task AddNewAsync(); +event EventHandler AddedNew; +``` + +### IContainsDeletedList Members +```csharp +IEnumerable DeletedList { get; } +``` + +### IList Members +```csharp +C this[int index] { get; set; } +int Count { get; } +bool IsReadOnly { get; } +void Add(C item); +void Insert(int index, C item); +bool Remove(C item); +void RemoveAt(int index); +void Clear(); +bool Contains(C item); +int IndexOf(C item); +void CopyTo(C[] array, int arrayIndex); +IEnumerator GetEnumerator(); +``` + +### INotifyCollectionChanged Members +```csharp +event NotifyCollectionChangedEventHandler CollectionChanged; +``` + +--- + +## Appendix B: Method Override Summary + +| Method | Source | Override Required | Reason | +|--------|--------|-------------------|--------| +| `IsDirty` | BusinessBase | Yes | Aggregate children | +| `IsValid` | BusinessBase | Yes | Aggregate children | +| `IsBusy` | BusinessBase | Yes | Aggregate children | +| `CopyState` | UndoableBase | Yes | Cascade to children | +| `UndoChanges` | UndoableBase | Yes | Cascade to children | +| `AcceptChanges` | UndoableBase | Yes | Cascade to children | +| `OnGetState` | MobileObject | Yes | Add collection state | +| `OnSetState` | MobileObject | Yes | Restore collection state | +| `OnGetChildren` | MobileObject | Yes | Serialize children | +| `OnSetChildren` | MobileObject | Yes | Deserialize children | +| `OnGetMetastate` | MobileObject | Yes | Add collection metastate | +| `OnSetMetastate` | MobileObject | Yes | Restore collection metastate | +| `Deserialized` | Various | Yes | Re-establish parent refs | +| `GetClone` | BusinessBase | Yes | Clone children | +| `WaitForIdle` | BusinessBase | Yes | Wait for children | + +--- + +## Revision History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2024-XX-XX | TBD | Initial plan | From ebcaafefe8831b403db57efa63fa2c5b3696b8ee Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Fri, 6 Feb 2026 01:29:24 -0600 Subject: [PATCH 02/19] feat: implement BusinessDocumentBase combining BusinessBase and BusinessListBase Adds BusinessDocumentBase, a new base class that inherits from BusinessBase and adds full BusinessListBase collection capabilities. This enables the "document pattern" where a single business object has its own managed properties, validation rules, and authorization AND contains a collection of child items. Key features: - Full IList collection support via composition (MobileList) - Deleted child tracking (IContainsDeletedList) - N-level undo cascading to collection children - IsDirty/IsValid/IsBusy aggregation across properties and children - MobileFormatter serialization of collection items - CollectionChanged/PropertyChanged events - LoadListMode for bulk loading without events - Child data access (Child_Update/Child_UpdateAsync) Includes IBusinessDocumentBase interface and 29 unit tests covering create/fetch, collection operations, status aggregation, events, clone, and n-level undo. Implements #1830 Co-Authored-By: Claude Opus 4.6 --- Source/Csla/BusinessDocumentBase.cs | 1017 +++++++++++++++++ Source/Csla/IBusinessDocumentBase.cs | 26 + .../BusinessDocumentBaseTests.cs | 391 +++++++ .../BusinessDocumentBase/DocumentLineItem.cs | 48 + .../BusinessDocumentBase/TestDocument.cs | 65 ++ 5 files changed, 1547 insertions(+) create mode 100644 Source/Csla/BusinessDocumentBase.cs create mode 100644 Source/Csla/IBusinessDocumentBase.cs create mode 100644 Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs create mode 100644 Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs create mode 100644 Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs new file mode 100644 index 0000000000..26461fd732 --- /dev/null +++ b/Source/Csla/BusinessDocumentBase.cs @@ -0,0 +1,1017 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Base class combining BusinessBase and BusinessListBase capabilities. +//----------------------------------------------------------------------- + +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 contains 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 : 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; + + /// + /// 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); + + // 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. + /// + protected bool RaiseListChangedEvents + { + get => _raiseListChangedEvents; + set => _raiseListChangedEvents = value; + } + + /// + /// 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. + public void Add(C item) + { + InsertItem(_items.Count, item); + } + + /// + /// Inserts an item at the specified index. + /// + /// Zero-based index. + /// The child object to insert. + public void Insert(int index, C 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. + public bool Remove(C 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. + public bool Contains(C item) => _items.Contains(item); + + /// + /// Determines the index of a specific item in the collection. + /// + /// The item to locate. + public int IndexOf(C item) => _items.IndexOf(item); + + /// + /// Copies the elements to an array starting at the specified index. + /// + /// Destination array. + /// Start index in array. + public void CopyTo(C[] array, int arrayIndex) => _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 . + protected virtual void InsertItem(int index, C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + if (item.IsChild) + { + 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); + if (RaiseListChangedEvents) + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); + } + 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); + } + if (RaiseListChangedEvents) + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, child, index)); + } + + /// + /// 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 . + protected virtual void SetItem(int index, C item) + { + if (item is null) + throw new ArgumentNullException(nameof(item)); + + C? child = default; + if (!ReferenceEquals(_items[index], item)) + child = _items[index]; + + // delete old item + 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); + if (RaiseListChangedEvents) + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, item, (object?)child, index)); + } + + /// + /// 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 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 + + /// + /// Gets a value indicating whether this object's data has been changed. + /// Aggregates the object's own dirty state with the dirty state + /// of all collection children. + /// + 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. + /// PropertyInfo object for the property. + /// The provided IPropertyInfo object. + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(PropertyInfo

info) + { + if (info is null) + throw new ArgumentNullException(nameof(info)); + + return Core.FieldManager.PropertyInfoManager.RegisterProperty

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

+ /// 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 (propertyName is null) + throw new ArgumentNullException(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 name from nameof(). + /// Relationship with property value. + /// is . + protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, RelationshipTypes relationship) + { + if (propertyName is null) + throw new ArgumentNullException(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 (propertyName is null) + throw new ArgumentNullException(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 (propertyName is null) + throw new ArgumentNullException(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 (propertyName is null) + throw new ArgumentNullException(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 (methodName is null) + throw new ArgumentNullException(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 + } +} diff --git a/Source/Csla/IBusinessDocumentBase.cs b/Source/Csla/IBusinessDocumentBase.cs new file mode 100644 index 0000000000..bf2276730e --- /dev/null +++ b/Source/Csla/IBusinessDocumentBase.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Consolidated interface for the BusinessDocumentBase type. +//----------------------------------------------------------------------- + +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 contains + /// a collection of child items. + /// + /// Type of the child objects contained in the collection. + public interface IBusinessDocumentBase : + IBusinessBase, + IBusinessListBase + where C : IEditableBusinessObject + { + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs new file mode 100644 index 0000000000..ebc05ae93f --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs @@ -0,0 +1,391 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Tests for BusinessDocumentBase +//----------------------------------------------------------------------- + +using Csla.TestHelpers; +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 + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs b/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs new file mode 100644 index 0000000000..bf1a55d2b9 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs @@ -0,0 +1,48 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Test child object for BusinessDocumentBase tests +//----------------------------------------------------------------------- + +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); + } + + [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() { } + } +} diff --git a/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs b/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs new file mode 100644 index 0000000000..010e4aaf92 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs @@ -0,0 +1,65 @@ +//----------------------------------------------------------------------- +// +// 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); + } + + [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(); + } + } +} From 8d95faa8e188e01091d0f9aaa7d5ec6f9a7a5fa5 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Tue, 17 Feb 2026 21:14:28 -0600 Subject: [PATCH 03/19] #1830 Expand BusinessDocumentBase test coverage and fix API gaps - Add 30 new tests covering: NotUndoable, AddNew/Async, advanced undo (nested BeginEdit/ApplyEdit, deleted list tracking), equality, event suppression, save workflow, advanced clone/undo state, WaitForIdle, and serialization roundtrip parent reference preservation - Add metastate PropertyChanged event tests (Xaml mode, SkipOnCIServer) mirroring BasicModernTests: MakeOld, MarkDeleted, property/child changes - Expose public API gaps: AddNew(), AddNewAsync(), SuppressListChangedEvents, RaiseListChangedEvents (public read) - Fix InsertItem/RemoveItem to call OnChildChanged so IsDirty/IsValid/ IsSavable PropertyChanged fires when collection items are added/removed - Add test infrastructure: NotUndoableData, MakeOld(), DataPortal_DeleteSelf, DeletedCount on TestDocument; AsyncRuleText+rule on DocumentLineItem; MetastateDocument and MetastateLineItem for metastate tests - Delete BusinessDocumentBase-PLAN.md (planning artifact, not for shipping) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Source/Csla/BusinessDocumentBase-PLAN.md | 670 ------------------ Source/Csla/BusinessDocumentBase.cs | 32 +- .../BusinessDocumentBaseMetastateTests.cs | 197 +++++ .../BusinessDocumentBaseTests.cs | 509 +++++++++++++ .../BusinessDocumentBase/DocumentLineItem.cs | 28 + .../BusinessDocumentBase/MetastateDocument.cs | 59 ++ .../BusinessDocumentBase/MetastateLineItem.cs | 36 + .../BusinessDocumentBase/TestDocument.cs | 19 + 8 files changed, 876 insertions(+), 674 deletions(-) delete mode 100644 Source/Csla/BusinessDocumentBase-PLAN.md create mode 100644 Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs create mode 100644 Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs create mode 100644 Source/tests/Csla.test/BusinessDocumentBase/MetastateLineItem.cs diff --git a/Source/Csla/BusinessDocumentBase-PLAN.md b/Source/Csla/BusinessDocumentBase-PLAN.md deleted file mode 100644 index c745937c83..0000000000 --- a/Source/Csla/BusinessDocumentBase-PLAN.md +++ /dev/null @@ -1,670 +0,0 @@ -# BusinessDocumentBase Implementation Plan - -## Overview - -This document outlines the plan to create a new `BusinessDocumentBase` class that combines the capabilities of both `BusinessBase` and `BusinessListBase`. This enables the "Document Pattern" where a single business object has its own properties AND contains a collection of child items. - -### Use Case Example - -```csharp -// An Invoice that has its own properties AND contains LineItems -public class Invoice : BusinessDocumentBase -{ - // Invoice properties (InvoiceNumber, Date, Customer, etc.) - public static readonly PropertyInfo InvoiceNumberProperty = RegisterProperty(nameof(InvoiceNumber)); - public string InvoiceNumber - { - get => GetProperty(InvoiceNumberProperty); - set => SetProperty(InvoiceNumberProperty, value); - } - - // LineItems are managed by the base class - // Access via: invoice.Items, invoice[0], invoice.Add(lineItem), etc. -} -``` - -## Requirements - -1. **Full BusinessBase functionality**: Properties, business rules, authorization, validation, n-level undo -2. **Full BusinessListBase functionality**: Child collection management, deleted list tracking, collection change notifications -3. **Interface compatibility**: Must be substitutable for both `BusinessBase` and `BusinessListBase` where appropriate -4. **Serialization**: Must serialize both object properties AND child items -5. **Coordinated state**: `IsDirty`, `IsValid`, `IsBusy` must aggregate both object and children state - ---- - -## Interface Analysis - -### Interfaces Implemented by BusinessBase - -#### From Inheritance Hierarchy -| Class | Interfaces | -|-------|------------| -| `MobileObject` | `IMobileObject`, `IMobileObjectMetastate` | -| `BindableBase` | `INotifyPropertyChanged`, `INotifyPropertyChanging` | -| `UndoableBase` | `IUndoableObject`, `IUseApplicationContext` | -| `Core.BusinessBase` | `IEditableBusinessObject`, `IEditableObject`, `ICloneable`, `IAuthorizeReadWrite`, `IParent`, `IDataPortalTarget`, `IManageProperties`, `IHostRules`, `ICheckRules`, `INotifyChildChanged`, `ISerializationNotification`, `IDataErrorInfo`, `INotifyDataErrorInfo`, `IUseFieldManager`, `IUseBusinessRules` | -| `BusinessBase` | `ISavable`, `ISavable`, `IBusinessBase` | - -### Interfaces Implemented by BusinessListBase - -#### From Inheritance Hierarchy -| Class | Interfaces | -|-------|------------| -| `ObservableCollection` | `IList`, `ICollection`, `IEnumerable`, `INotifyCollectionChanged`, `INotifyPropertyChanged` | -| `MobileObservableCollection` | `IMobileList`, `IMobileObject`, `IMobileObjectMetastate` | -| `ObservableBindingList` | `IObservableBindingList`, `INotifyBusy`, `INotifyChildChanged`, `ISerializationNotification` | -| `BusinessListBase` | `IContainsDeletedList`, `ISavable`, `IDataPortalTarget`, `IBusinessListBase`, `IUseApplicationContext` | - -#### IBusinessListBase Consolidates -- `IEditableCollection` (which includes `IBusinessObject`, `ISupportUndo`, `ITrackStatus`) -- `IUndoableObject` -- `ICloneable` -- `ISavable` -- `IParent` -- `IObservableBindingList` -- `INotifyChildChanged` -- `ISerializationNotification` -- `IMobileObject` -- `INotifyCollectionChanged` -- `INotifyPropertyChanged` -- `IList` - ---- - -## Interface Conflicts & Resolutions - -### Conflict 1: ITrackStatus (IsNew, IsDeleted, IsDirty, IsValid) - -| Property | BusinessBase Behavior | BusinessListBase Behavior | Resolution | -|----------|----------------------|--------------------------|------------| -| `IsNew` | True if object not yet persisted | Always `false` | **Use BusinessBase behavior** - the document itself can be new | -| `IsDeleted` | True if marked for deletion | Always `false` | **Use BusinessBase behavior** - the document itself can be deleted | -| `IsDirty` | True if object properties changed | True if any child is dirty or non-new deleted items exist | **Aggregate**: `ObjectIsDirty || ChildrenAreDirty || DeletedItemsExist` | -| `IsSelfDirty` | True if object's own properties changed | Same as `IsDirty` | **Use BusinessBase behavior** for object's own state | -| `IsValid` | True if no broken rules | True if all children valid | **Aggregate**: `ObjectIsValid && AllChildrenValid` | -| `IsSelfValid` | True if object's own rules pass | Same as `IsValid` | **Use BusinessBase behavior** for object's own state | -| `IsSavable` | Dirty && Valid && !Busy && Authorized | Dirty && Valid && !Busy && Authorized | **Same logic**, but using aggregated values | -| `IsBusy` | True if object is busy | True if any child is busy | **Aggregate**: `ObjectIsBusy || AnyChildIsBusy` | - -### Conflict 2: IUndoableObject (EditLevel, CopyState, UndoChanges, AcceptChanges) - -| Member | BusinessBase Behavior | BusinessListBase Behavior | Resolution | -|--------|----------------------|--------------------------|------------| -| `EditLevel` | Tracks object's edit depth | Tracks collection's edit depth | **Single EditLevel** - they must be synchronized | -| `CopyState` | Snapshots object properties | Cascades to all children + deleted | **Do both**: Snapshot object state AND cascade to children | -| `UndoChanges` | Restores object properties | Restores children, handles deletions | **Do both**: Restore object state AND cascade to children | -| `AcceptChanges` | Commits object properties | Commits children, clears deleted below level | **Do both**: Commit object state AND cascade to children | - -### Conflict 3: ISavable / ISavable - -| Member | BusinessBase Behavior | BusinessListBase Behavior | Resolution | -|--------|----------------------|--------------------------|------------| -| `Save()` | Saves object via DataPortal.Update | Saves all children via DataPortal.Update | **Combined**: Save object (which triggers child saves in DataPortal methods) | -| `SaveAsync()` | Async version | Async version | Same | -| `SaveAndMergeAsync()` | Saves and merges result | Saves and merges result | Same | -| `Saved` event | Raised after save | Raised after save | Same | - -### Conflict 4: IDataPortalTarget - -| Member | BusinessBase Behavior | BusinessListBase Behavior | Resolution | -|--------|----------------------|--------------------------|------------| -| `MarkAsChild()` | Marks object as child | Marks collection as child | **Same** | -| `MarkNew()` | Sets `IsNew = true` | No-op | **Use BusinessBase behavior** | -| `MarkOld()` | Sets `IsNew = false` | No-op | **Use BusinessBase behavior** | -| `CheckRules()` | Executes business rules | No-op | **Use BusinessBase behavior** | -| `CheckRulesAsync()` | Async version | No-op (returns completed task) | **Use BusinessBase behavior** | - -### Conflict 5: IEditableBusinessObject vs IEditableCollection - -These are **different interfaces** for different purposes: -- `IEditableBusinessObject`: For single objects that can be children in a collection -- `IEditableCollection`: For collections that contain children - -**Resolution**: `BusinessDocumentBase` implements **both** because it IS a business object that CAN be a child, AND it IS a collection that CONTAINS children. - -### Conflict 6: IBusinessBase vs IBusinessListBase - -**Resolution**: Create a new consolidated interface `IBusinessDocumentBase` that extends both: - -```csharp -public interface IBusinessDocumentBase : IBusinessBase, IBusinessListBase - where C : IEditableBusinessObject -{ - // Any additional members specific to document pattern -} -``` - ---- - -## Complete Interface List for BusinessDocumentBase - -### Must Implement (Union of Both) - -```csharp -public abstract class BusinessDocumentBase : BusinessBase, - // From BusinessBase (inherited) - // - IEditableBusinessObject (includes IBusinessObject, ISupportUndo, IUndoableObject, ITrackStatus) - // - IEditableObject - // - ICloneable - // - IAuthorizeReadWrite - // - IParent - // - IDataPortalTarget - // - IManageProperties - // - IHostRules - // - ICheckRules - // - INotifyChildChanged - // - ISerializationNotification - // - IDataErrorInfo - // - INotifyDataErrorInfo - // - IUseFieldManager - // - IUseBusinessRules - // - ISavable, ISavable - // - IBusinessBase - // - IMobileObject, IMobileObjectMetastate - // - INotifyPropertyChanged, INotifyPropertyChanging - // - IUndoableObject - // - IUseApplicationContext - - // Additional from BusinessListBase (must implement explicitly) - IEditableCollection, // Collection-specific editing - IContainsDeletedList, // Deleted items tracking - IObservableBindingList, // AddNew, AllowEdit, AllowRemove - INotifyCollectionChanged, // Collection change notifications - IList, // Full list interface - IBusinessDocumentBase // New consolidated interface - - where T : BusinessDocumentBase - where C : IEditableBusinessObject -``` - ---- - -## Architecture Design - -### Class Hierarchy - -``` -System.Object -└── MobileObject (IMobileObject, IMobileObjectMetastate) - └── BindableBase (INotifyPropertyChanged, INotifyPropertyChanging) - └── UndoableBase (IUndoableObject, IUseApplicationContext) - └── Core.BusinessBase (IEditableBusinessObject, IEditableObject, ICloneable, - │ IAuthorizeReadWrite, IParent, IDataPortalTarget, - │ IManageProperties, IHostRules, ICheckRules, - │ INotifyChildChanged, ISerializationNotification, - │ IDataErrorInfo, INotifyDataErrorInfo, - │ IUseFieldManager, IUseBusinessRules) - └── BusinessBase (ISavable, ISavable, IBusinessBase) - └── BusinessDocumentBase (IEditableCollection, IContainsDeletedList, - IObservableBindingList, INotifyCollectionChanged, - IList, IBusinessDocumentBase) -``` - -### Internal Components - -``` -BusinessDocumentBase -├── Inherited from BusinessBase: -│ ├── FieldManager (property storage) -│ ├── BusinessRules (validation engine) -│ ├── Authorization cache -│ └── State tracking (IsNew, IsDeleted, _isDirty) -│ -└── New components for collection support: - ├── _items : MobileBindingList // Active child items - ├── _deletedItems : MobileList // Deleted child items - ├── _allowNew : bool // IObservableBindingList - ├── _allowEdit : bool // IObservableBindingList - ├── _allowRemove : bool // IObservableBindingList - └── _raiseListChangedEvents : bool // Notification control -``` - ---- - -## Implementation Plan - -### Phase 1: Interface Definitions - -#### Task 1.1: Create IBusinessDocumentBase Interface -**File**: `Source/Csla/IBusinessDocumentBase.cs` - -```csharp -public interface IBusinessDocumentBase : - IBusinessBase, // All single-object interfaces - IBusinessListBase // All collection interfaces - where C : IEditableBusinessObject -{ - // Document-specific members (if any) -} -``` - -#### Task 1.2: Review/Update IEditableCollection -Ensure it has all necessary members for collection editing support. - -### Phase 2: Core Class Structure - -#### Task 2.1: Create BusinessDocumentBase Class Shell -**File**: `Source/Csla/BusinessDocumentBase.cs` - -- Class declaration with all interfaces -- Generic constraints -- Constructor - -#### Task 2.2: Implement Child Collection Storage -- `_items` field (active children) -- `_deletedItems` field (deleted children) -- `Items` property (public access) -- `DeletedList` property (protected access) - -### Phase 3: Collection Interface Implementation - -#### Task 3.1: IList Implementation -- `Count`, `IsReadOnly` -- `this[int index]` indexer -- `Add`, `Insert`, `Remove`, `RemoveAt`, `Clear` -- `Contains`, `IndexOf`, `CopyTo` -- `GetEnumerator` - -#### Task 3.2: INotifyCollectionChanged Implementation -- `CollectionChanged` event -- `OnCollectionChanged` method -- Raise events from Add/Remove/Clear/Set operations - -#### Task 3.3: IObservableBindingList Implementation -- `AllowNew`, `AllowEdit`, `AllowRemove` properties -- `AddNew()`, `AddNewAsync()` methods -- `AddedNew` event - -#### Task 3.4: IContainsDeletedList Implementation -- `DeletedList` property (IEnumerable) -- `ContainsDeleted(C item)` method - -#### Task 3.5: IEditableCollection Implementation -- `RemoveChild(IEditableBusinessObject child)` -- `GetDeletedList()` -- `SetParent(IParent parent)` - -### Phase 4: Child Item Management - -#### Task 4.1: InsertItem Logic -- Validate item is marked as child -- Set parent reference to `this` -- Set ApplicationContext -- Set EditLevelAdded -- Ensure unique identity -- Hook child events -- Raise CollectionChanged - -#### Task 4.2: RemoveItem Logic -- Move to deleted list (unless completely removing) -- Unhook child events -- Reset child edit level -- Mark child as deleted -- Raise CollectionChanged - -#### Task 4.3: SetItem Logic -- Delete old item -- Insert new item -- Handle events appropriately - -#### Task 4.4: ClearItems Logic -- Move all items to deleted list -- Unhook all events -- Raise Reset notification - -### Phase 5: Status Property Overrides - -#### Task 5.1: Override IsDirty -```csharp -public override bool IsDirty -{ - get - { - // Object's own dirty state - if (base.IsDirty) - return true; - - // Check deleted items (non-new deletions are dirty) - foreach (var item in _deletedItems) - if (!item.IsNew) - return true; - - // Check active children - foreach (var child in _items) - if (child.IsDirty) - return true; - - return false; - } -} -``` - -#### Task 5.2: Override IsValid -```csharp -public override bool IsValid -{ - get - { - // Object's own validation - if (!base.IsValid) - return false; - - // Check all children - foreach (var child in _items) - if (!child.IsValid) - return false; - - return true; - } -} -``` - -#### Task 5.3: Override IsBusy -```csharp -public override bool IsBusy -{ - get - { - if (base.IsBusy) - return true; - - foreach (var item in _deletedItems) - if (item.IsBusy) - return true; - - foreach (var child in _items) - if (child.IsBusy) - return true; - - return false; - } -} -``` - -### Phase 6: N-Level Undo Support - -#### Task 6.1: Override CopyState -```csharp -protected override void CopyState(int parentEditLevel, bool parentBindingEdit) -{ - // Copy object's own state - base.CopyState(parentEditLevel, parentBindingEdit); - - // Cascade to all active children - foreach (var child in _items) - child.CopyState(EditLevel, false); - - // Cascade to deleted children - foreach (var child in _deletedItems) - child.CopyState(EditLevel, false); -} -``` - -#### Task 6.2: Override UndoChanges -- Restore object's own state -- Undo changes in all children -- Remove children added below current edit level -- Restore children deleted above current edit level - -#### Task 6.3: Override AcceptChanges -- Accept object's own state -- Accept changes in all children -- Remove deleted children below current edit level -- Update EditLevelAdded for children - -### Phase 7: Serialization Support - -#### Task 7.1: Override OnGetState -```csharp -protected override void OnGetState(SerializationInfo info) -{ - base.OnGetState(info); - // Add collection-specific state - info.AddValue("_allowNew", _allowNew); - info.AddValue("_allowEdit", _allowEdit); - info.AddValue("_allowRemove", _allowRemove); -} -``` - -#### Task 7.2: Override OnSetState -```csharp -protected override void OnSetState(SerializationInfo info) -{ - base.OnSetState(info); - _allowNew = info.GetValue("_allowNew"); - _allowEdit = info.GetValue("_allowEdit"); - _allowRemove = info.GetValue("_allowRemove"); -} -``` - -#### Task 7.3: Override OnGetChildren -```csharp -protected override void OnGetChildren(SerializationInfo info, MobileFormatter formatter) -{ - base.OnGetChildren(info, formatter); - - // Serialize items collection - var itemsInfo = formatter.SerializeObject(_items); - info.AddChild("_items", itemsInfo.ReferenceId); - - // Serialize deleted items - if (_deletedItems != null && _deletedItems.Count > 0) - { - var deletedInfo = formatter.SerializeObject(_deletedItems); - info.AddChild("_deletedItems", deletedInfo.ReferenceId); - } -} -``` - -#### Task 7.4: Override OnSetChildren -```csharp -protected override void OnSetChildren(SerializationInfo info, MobileFormatter formatter) -{ - base.OnSetChildren(info, formatter); - - if (info.Children.TryGetValue("_items", out var itemsChild)) - _items = (MobileBindingList)formatter.GetObject(itemsChild.ReferenceId); - - if (info.Children.TryGetValue("_deletedItems", out var deletedChild)) - _deletedItems = (MobileList)formatter.GetObject(deletedChild.ReferenceId); -} -``` - -#### Task 7.5: Override OnGetMetastate / OnSetMetastate -Handle binary metastate for collection-specific flags. - -#### Task 7.6: Override Deserialized -- Call base -- Re-establish parent references for all children -- Hook child events - -### Phase 8: Data Portal Integration - -#### Task 8.1: Child_Update Implementation -```csharp -[EditorBrowsable(EditorBrowsableState.Advanced)] -protected virtual void Child_Update(params object[] parameters) -{ - var dp = ApplicationContext.CreateInstanceDI>(); - - // Update deleted items first - foreach (var child in _deletedItems) - dp.UpdateChild(child, parameters); - _deletedItems.Clear(); - - // Update dirty active items - foreach (var child in _items) - if (child.IsDirty) - dp.UpdateChild(child, parameters); -} -``` - -#### Task 8.2: Child_UpdateAsync Implementation -Async version of above. - -#### Task 8.3: DataPortal_XYZ Event Handlers -Implement all DataPortal event handlers following same pattern as BusinessListBase. - -### Phase 9: Additional Features - -#### Task 9.1: Clone Support -Override `GetClone()` to ensure proper deep cloning of children. - -#### Task 9.2: Child Event Handling -- Hook `PropertyChanged` on children -- Hook `BusyChanged` on children -- Hook `ChildChanged` on children -- Bubble events appropriately - -#### Task 9.3: LoadListMode Support -Implement `LoadListMode` property/pattern for bulk loading without events. - -#### Task 9.4: WaitForIdle Support -Override to wait for both object and all children. - -### Phase 10: Testing & Documentation - -#### Task 10.1: Unit Tests -- Status aggregation tests -- Serialization round-trip tests -- N-level undo tests -- Collection operation tests -- Data portal integration tests - -#### Task 10.2: Integration Tests -- Test with actual child business objects -- Test serialization across data portal -- Test with UI data binding - -#### Task 10.3: Documentation -- XML documentation on all public members -- Usage examples -- Migration guide for existing code - ---- - -## File Locations - -| File | Purpose | -|------|---------| -| `Source/Csla/IBusinessDocumentBase.cs` | New consolidated interface | -| `Source/Csla/BusinessDocumentBase.cs` | Main implementation class | -| `Source/Csla/Core/MobileBindingList.cs` | Internal collection class (if needed) | -| `Source/Csla.Tests/BusinessDocumentBaseTests.cs` | Unit tests | - ---- - -## Risk Assessment - -### High Risk Areas - -1. **Serialization Coordination**: Must ensure both object state and children serialize correctly in all scenarios (clone, data portal, undo state) - -2. **Edit Level Synchronization**: Object and children must maintain consistent edit levels through all operations - -3. **Parent Reference Management**: Children must always have correct parent reference, especially after deserialization - -4. **Event Cascade**: Child changes must properly bubble up without causing infinite loops - -### Mitigation Strategies - -1. **Comprehensive Testing**: Test all serialization paths (MobileFormatter, clone, data portal) - -2. **Follow Existing Patterns**: Mirror BusinessListBase's edit level management exactly - -3. **Deserialized Override**: Always re-establish parent references after deserialization - -4. **Event Suppression**: Use flags to prevent event cascade during internal operations - ---- - -## Success Criteria - -1. ✅ Can create a document-style business object with properties AND children -2. ✅ `IsDirty`/`IsValid`/`IsBusy` correctly aggregate object and children state -3. ✅ N-level undo works correctly for both object properties and children -4. ✅ Serialization preserves both object state and children -5. ✅ Data portal save correctly persists object and children -6. ✅ Can be used anywhere a `BusinessBase` is expected (for single-object operations) -7. ✅ Can be used anywhere a `BusinessListBase` is expected (for collection operations) -8. ✅ Child changes bubble up through `ChildChanged` event -9. ✅ Collection changes raise `CollectionChanged` event -10. ✅ Works with UI data binding (Blazor, WPF, etc.) - ---- - -## Appendix A: Interface Member Inventory - -### IEditableCollection Members -```csharp -void RemoveChild(IEditableBusinessObject child); -object GetDeletedList(); -void SetParent(IParent parent); -``` - -### IObservableBindingList Members -```csharp -bool AllowNew { get; set; } -bool AllowEdit { get; set; } -bool AllowRemove { get; set; } -object AddNew(); -Task AddNewAsync(); -event EventHandler AddedNew; -``` - -### IContainsDeletedList Members -```csharp -IEnumerable DeletedList { get; } -``` - -### IList Members -```csharp -C this[int index] { get; set; } -int Count { get; } -bool IsReadOnly { get; } -void Add(C item); -void Insert(int index, C item); -bool Remove(C item); -void RemoveAt(int index); -void Clear(); -bool Contains(C item); -int IndexOf(C item); -void CopyTo(C[] array, int arrayIndex); -IEnumerator GetEnumerator(); -``` - -### INotifyCollectionChanged Members -```csharp -event NotifyCollectionChangedEventHandler CollectionChanged; -``` - ---- - -## Appendix B: Method Override Summary - -| Method | Source | Override Required | Reason | -|--------|--------|-------------------|--------| -| `IsDirty` | BusinessBase | Yes | Aggregate children | -| `IsValid` | BusinessBase | Yes | Aggregate children | -| `IsBusy` | BusinessBase | Yes | Aggregate children | -| `CopyState` | UndoableBase | Yes | Cascade to children | -| `UndoChanges` | UndoableBase | Yes | Cascade to children | -| `AcceptChanges` | UndoableBase | Yes | Cascade to children | -| `OnGetState` | MobileObject | Yes | Add collection state | -| `OnSetState` | MobileObject | Yes | Restore collection state | -| `OnGetChildren` | MobileObject | Yes | Serialize children | -| `OnSetChildren` | MobileObject | Yes | Deserialize children | -| `OnGetMetastate` | MobileObject | Yes | Add collection metastate | -| `OnSetMetastate` | MobileObject | Yes | Restore collection metastate | -| `Deserialized` | Various | Yes | Re-establish parent refs | -| `GetClone` | BusinessBase | Yes | Clone children | -| `WaitForIdle` | BusinessBase | Yes | Wait for children | - ---- - -## Revision History - -| Version | Date | Author | Changes | -|---------|------|--------|---------| -| 1.0 | 2024-XX-XX | TBD | Initial plan | diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs index 26461fd732..eca1a5c076 100644 --- a/Source/Csla/BusinessDocumentBase.cs +++ b/Source/Csla/BusinessDocumentBase.cs @@ -137,12 +137,18 @@ private void UnDeleteChild(C child) /// Gets or sets a value indicating whether list changed /// events should be raised. /// - protected bool RaiseListChangedEvents + public bool RaiseListChangedEvents { get => _raiseListChangedEvents; - set => _raiseListChangedEvents = value; + protected set => _raiseListChangedEvents = value; } + /// + /// Use this object to suppress list changed events + /// during bulk operations. Equivalent to . + /// + public IDisposable SuppressListChangedEvents => LoadListMode; + /// /// Use this object to suppress list changed events /// during bulk operations. @@ -301,8 +307,10 @@ protected virtual void InsertItem(int index, C item) item.EditLevelAdded = EditLevel; _items.Insert(index, item); OnAddEventHooks((IBusinessObject)item); + var collectionArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index); if (RaiseListChangedEvents) - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); + OnCollectionChanged(collectionArgs); + OnChildChanged(new Core.ChildChangedEventArgs(item, null, collectionArgs)); } else { @@ -328,8 +336,10 @@ protected virtual void RemoveItem(int index) { DeleteChild(child); } + var collectionArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, child, index); if (RaiseListChangedEvents) - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, child, index)); + OnCollectionChanged(collectionArgs); + OnChildChanged(new Core.ChildChangedEventArgs(child, null, collectionArgs)); } /// @@ -410,6 +420,20 @@ protected virtual async Task AddNewCoreAsync() #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 /// diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs new file mode 100644 index 0000000000..a342f032b6 --- /dev/null +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -0,0 +1,197 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// +// Tests for metastate PropertyChanged events on BusinessDocumentBase. +// Mirrors BasicModernTests patterns. Requires Xaml PropertyChangedMode +// and is skipped on CI server due to timing sensitivity. +// +//----------------------------------------------------------------------- + +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 index ebc05ae93f..555f850587 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs @@ -6,7 +6,9 @@ // Tests for BusinessDocumentBase //----------------------------------------------------------------------- +using Csla.Serialization; using Csla.TestHelpers; +using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Csla.Test.BusinessDocumentBase @@ -387,5 +389,512 @@ public void BeginEdit_ApplyEdit_ShouldKeepChanges() } #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"); + } + + #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); + } + + #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 index bf1a55d2b9..e597ad2c50 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/DocumentLineItem.cs @@ -6,6 +6,8 @@ // Test child object for BusinessDocumentBase tests //----------------------------------------------------------------------- +using Csla.Rules; + namespace Csla.Test.BusinessDocumentBase { [Serializable] @@ -25,6 +27,19 @@ public decimal Amount 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() { } @@ -44,5 +59,18 @@ 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 index 010e4aaf92..e1c766db07 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/TestDocument.cs @@ -25,6 +25,19 @@ public DateTime DocumentDate 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() { @@ -61,5 +74,11 @@ private void DataPortal_Update() FieldManager.UpdateChildren(); Child_Update(); } + + [DeleteSelf] + private void DataPortal_DeleteSelf() + { + Child_Update(); + } } } From 9b31d68136dcc9b1f2c8873cd272ed50913058a8 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Tue, 17 Feb 2026 21:24:40 -0600 Subject: [PATCH 04/19] #1830 Address Copilot code review comments - SetItem: add IsChild guard (throws InvalidOperationException for non-child items) to match InsertItem's enforcement of the child constraint; previously the indexer setter accepted any object - RegisterProperty: add four missing lambda-expression overloads (relationship, friendlyName, friendlyName+default, friendlyName+default+relationship) to match the full set present on BusinessBase Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Source/Csla/BusinessDocumentBase.cs | 74 +++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs index eca1a5c076..24c639050c 100644 --- a/Source/Csla/BusinessDocumentBase.cs +++ b/Source/Csla/BusinessDocumentBase.cs @@ -355,6 +355,9 @@ 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]; @@ -942,6 +945,77 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) 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. From 820fee7b79576d9a115ce7a4c62bed78d652e273 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Tue, 17 Feb 2026 21:27:14 -0600 Subject: [PATCH 05/19] #1830 Add test for indexer setter non-child guard Adds IndexerSet_NonChild_ThrowsInvalidOperationException to verify that doc[i] = nonChild throws InvalidOperationException, mirroring the existing InsertNonChild test and covering the SetItem fix from the Copilot review. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../BusinessDocumentBase/BusinessDocumentBaseTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs index 555f850587..0884061a9c 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs @@ -657,6 +657,15 @@ public void InsertNonChild_ThrowsInvalidOperationException() 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 From 29566f356d51f4fe3c75700ba0689e96e3c7a065 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Tue, 3 Mar 2026 23:00:49 -0600 Subject: [PATCH 06/19] #1830 Address PR review feedback on BusinessDocumentBase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix doc comment: "contains" → "is" a collection - Add notnull constraint to C type parameter - Use Enumerable.Empty for IContainsDeletedList to avoid lazy allocation - Add UnDeleteChild logic in InsertItem for re-added deleted children - Add null guards on public collection methods (Add, Insert, Remove, Contains, IndexOf, CopyTo) - Remove null-forgiving operator on IEditableCollection.SetParent - Use string.IsNullOrWhiteSpace for RegisterProperty/RegisterMethod name validation Co-Authored-By: Claude Opus 4.6 --- Source/Csla/BusinessDocumentBase.cs | 72 ++++++++++++++++++++-------- Source/Csla/IBusinessDocumentBase.cs | 2 +- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs index 24c639050c..486eec9f87 100644 --- a/Source/Csla/BusinessDocumentBase.cs +++ b/Source/Csla/BusinessDocumentBase.cs @@ -23,7 +23,7 @@ namespace Csla { /// /// Base class for an editable business object that has its own - /// properties AND contains a collection of child items. + /// properties AND is a collection of child items. /// Combines the capabilities of both /// and . /// @@ -41,7 +41,7 @@ public abstract class BusinessDocumentBase< IList, IBusinessDocumentBase where T : BusinessDocumentBase - where C : IEditableBusinessObject + where C : notnull, IEditableBusinessObject { #region Collection Storage @@ -74,7 +74,7 @@ protected MobileList DeletedList #region IContainsDeletedList IEnumerable IContainsDeletedList.DeletedList - => (IEnumerable)DeletedList; + => (IEnumerable?)_deletedList ?? Enumerable.Empty(); /// /// Returns true if the internal deleted list @@ -205,6 +205,9 @@ public C this[int index] /// The child object to add. public void Add(C item) { + if (item is null) + throw new ArgumentNullException(nameof(item)); + InsertItem(_items.Count, item); } @@ -215,6 +218,9 @@ public void Add(C item) /// The child object to insert. public void Insert(int index, C item) { + if (item is null) + throw new ArgumentNullException(nameof(item)); + InsertItem(index, item); } @@ -225,6 +231,9 @@ public void Insert(int index, C item) /// True if the item was found and removed. public bool Remove(C item) { + if (item is null) + throw new ArgumentNullException(nameof(item)); + int index = _items.IndexOf(item); if (index < 0) return false; @@ -253,20 +262,38 @@ public void Clear() /// Determines whether the collection contains a specific item. /// /// The item to locate. - public bool Contains(C item) => _items.Contains(item); + 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. - public int IndexOf(C item) => _items.IndexOf(item); + 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. - public void CopyTo(C[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + 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. @@ -292,6 +319,13 @@ protected virtual void InsertItem(int index, C 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 @@ -523,7 +557,7 @@ object IEditableCollection.GetDeletedList() void IEditableCollection.SetParent(IParent? parent) { - SetParent(parent!); + SetParent(parent); } #endregion @@ -923,8 +957,8 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) /// is . protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName) { - if (propertyName is null) - throw new ArgumentNullException(nameof(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)); } @@ -1026,8 +1060,8 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) /// is . protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, RelationshipTypes relationship) { - if (propertyName is null) - throw new ArgumentNullException(nameof(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, string.Empty, relationship)); } @@ -1042,8 +1076,8 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) /// is . protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, string? friendlyName) { - if (propertyName is null) - throw new ArgumentNullException(nameof(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, friendlyName)); } @@ -1059,8 +1093,8 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) /// is . protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, string? friendlyName, P? defaultValue) { - if (propertyName is null) - throw new ArgumentNullException(nameof(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, friendlyName, defaultValue)); } @@ -1077,8 +1111,8 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) /// is . protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(string propertyName, string? friendlyName, P? defaultValue, RelationshipTypes relationship) { - if (propertyName is null) - throw new ArgumentNullException(nameof(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, friendlyName, defaultValue, relationship)); } @@ -1090,8 +1124,8 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) /// is . protected static new MethodInfo RegisterMethod(string methodName) { - if (methodName is null) - throw new ArgumentNullException(nameof(methodName)); + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("Method name must not be null or empty.", nameof(methodName)); return RegisterMethod(typeof(T), methodName); } diff --git a/Source/Csla/IBusinessDocumentBase.cs b/Source/Csla/IBusinessDocumentBase.cs index bf2276730e..f03ad35b45 100644 --- a/Source/Csla/IBusinessDocumentBase.cs +++ b/Source/Csla/IBusinessDocumentBase.cs @@ -13,7 +13,7 @@ namespace Csla ///

/// Consolidated interface for the BusinessDocumentBase type, /// which combines BusinessBase and BusinessListBase capabilities. - /// A business document has its own properties AND contains + /// A business document has its own properties AND is /// a collection of child items. /// /// Type of the child objects contained in the collection. From ca5b9ee40ae3d45e19bd6fd2304b350c81291de3 Mon Sep 17 00:00:00 2001 From: Rockford lhotka Date: Mon, 16 Mar 2026 00:29:08 -0500 Subject: [PATCH 07/19] #1830 Address Stephan's PR review feedback on BusinessDocumentBase - Add UnDeleteChild() to IEditableBusinessObject and BusinessBase to properly unmark children as deleted when re-added to a collection - Add XML doc tags to all public collection methods - Add doc for InvalidOperationException on InsertItem/SetItem - Add OnChildChanged notification in SetItem for consistency with InsertItem and RemoveItem - Clarify SuppressListChangedEvents vs LoadListMode doc (public vs protected) - Add comment explaining LoadListMode usage in SetItem - Use for IsDirty override - Remove old-way RegisterProperty(PropertyInfo

) overload - Add RemoveThenReAdd_ChildIsNotDeleted test Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/Csla/BusinessDocumentBase.cs | 43 ++++++++----------- Source/Csla/Core/BusinessBase.cs | 19 ++++++++ Source/Csla/Core/IEditableBusinessObject.cs | 6 +++ .../BusinessDocumentBaseTests.cs | 16 +++++++ Source/tests/Csla.test/Server/FakeEntity.cs | 1 + 5 files changed, 61 insertions(+), 24 deletions(-) diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs index 486eec9f87..42755fb8bc 100644 --- a/Source/Csla/BusinessDocumentBase.cs +++ b/Source/Csla/BusinessDocumentBase.cs @@ -110,6 +110,9 @@ 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; @@ -145,7 +148,8 @@ public bool RaiseListChangedEvents ///

/// Use this object to suppress list changed events - /// during bulk operations. Equivalent to . + /// during bulk operations. This is the public API equivalent + /// of the protected property. /// public IDisposable SuppressListChangedEvents => LoadListMode; @@ -203,6 +207,7 @@ public C this[int index] /// Adds an item to the collection. ///
/// The child object to add. + /// is . public void Add(C item) { if (item is null) @@ -216,6 +221,7 @@ public void Add(C item) ///
/// Zero-based index. /// The child object to insert. + /// is . public void Insert(int index, C item) { if (item is null) @@ -229,6 +235,7 @@ public void Insert(int index, C item) ///
/// The child object to remove. /// True if the item was found and removed. + /// is . public bool Remove(C item) { if (item is null) @@ -262,6 +269,7 @@ public void Clear() /// Determines whether the collection contains a specific item. ///
/// The item to locate. + /// is . public bool Contains(C item) { if (item is null) @@ -274,6 +282,7 @@ public bool Contains(C 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) @@ -287,6 +296,7 @@ public int IndexOf(C item) /// /// Destination array. /// Start index in array. + /// is . public void CopyTo(C[] array, int arrayIndex) { if (array is null) @@ -312,6 +322,7 @@ public void CopyTo(C[] array, int arrayIndex) /// 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) @@ -384,6 +395,7 @@ protected virtual void RemoveItem(int index) /// 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) @@ -396,7 +408,8 @@ protected virtual void SetItem(int index, C item) if (!ReferenceEquals(_items[index], item)) child = _items[index]; - // delete old item + // suppress events while deleting old item to avoid + // intermediate state notifications using (LoadListMode) { if (child != null) @@ -417,8 +430,10 @@ protected virtual void SetItem(int index, C item) item.EditLevelAdded = EditLevel; _items[index] = item; OnAddEventHooks((IBusinessObject)item); + var collectionArgs = new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, item, (object?)child, index); if (RaiseListChangedEvents) - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, item, (object?)child, index)); + OnCollectionChanged(collectionArgs); + OnChildChanged(new Core.ChildChangedEventArgs(item, null, collectionArgs)); } /// @@ -599,11 +614,7 @@ Task IParent.RemoveChild(IEditableBusinessObject child) #region Status Property Overrides - /// - /// Gets a value indicating whether this object's data has been changed. - /// Aggregates the object's own dirty state with the dirty state - /// of all collection children. - /// + /// public override bool IsDirty { get @@ -932,22 +943,6 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) #region Register Properties - /// - /// Indicates that the specified property belongs - /// to the business object type. - /// - /// Type of property. - /// PropertyInfo object for the property. - /// The provided IPropertyInfo object. - /// is . - protected static new PropertyInfo

RegisterProperty<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] P>(PropertyInfo

info) - { - if (info is null) - throw new ArgumentNullException(nameof(info)); - - return Core.FieldManager.PropertyInfoManager.RegisterProperty

(typeof(T), info); - } - ///

/// Indicates that the specified property belongs /// to the business object type. diff --git a/Source/Csla/Core/BusinessBase.cs b/Source/Csla/Core/BusinessBase.cs index 4bba0aa45b..7cfb6493e1 100644 --- a/Source/Csla/Core/BusinessBase.cs +++ b/Source/Csla/Core/BusinessBase.cs @@ -998,6 +998,20 @@ internal void DeleteChild() MarkDeleted(); } + /// + /// 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); + + IsDeleted = false; + MetaPropertyHasChanged("IsDeleted"); + } + #endregion #region Edit Level Tracking (child only) @@ -1566,6 +1580,11 @@ void IEditableBusinessObject.DeleteChild() DeleteChild(); } + void IEditableBusinessObject.UnDeleteChild() + { + UnDeleteChild(); + } + void IEditableBusinessObject.SetParent(IParent? parent) { SetParent(parent); diff --git a/Source/Csla/Core/IEditableBusinessObject.cs b/Source/Csla/Core/IEditableBusinessObject.cs index bc82e8fc9b..fdeadc9b15 100644 --- a/Source/Csla/Core/IEditableBusinessObject.cs +++ b/Source/Csla/Core/IEditableBusinessObject.cs @@ -35,6 +35,12 @@ public interface IEditableBusinessObject : IBusinessObject, ISupportUndo, IUndoa /// void DeleteChild(); /// + /// Called by a parent object to reverse a + /// previous call to , + /// restoring the child to a non-deleted state. + /// + void UnDeleteChild(); + /// /// Used by BusinessListBase as a child object is /// created to tell the child object about its /// parent. diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs index 0884061a9c..1adc0deede 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseTests.cs @@ -618,6 +618,22 @@ public void DeletedListCancelEdit_RestoresDeletedItems() 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 diff --git a/Source/tests/Csla.test/Server/FakeEntity.cs b/Source/tests/Csla.test/Server/FakeEntity.cs index c86c3a1fa5..2be9e3629a 100644 --- a/Source/tests/Csla.test/Server/FakeEntity.cs +++ b/Source/tests/Csla.test/Server/FakeEntity.cs @@ -53,6 +53,7 @@ public void CancelEdit() { } public void CopyState(int parentEditLevel, bool parentBindingEdit) { } public void Delete() { } public void DeleteChild() { } + public void UnDeleteChild() { } public void GetChildren(SerializationInfo info, MobileFormatter formatter) { } public void GetState(SerializationInfo info) { } public object Save() From 45f37f72d58333262599f99dec4b69bff27df309 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sat, 4 Apr 2026 18:57:33 -0500 Subject: [PATCH 08/19] #1830 Restrict BusinessDocumentBase to NET8_0_OR_GREATER Wrap BusinessDocumentBase, IBusinessDocumentBase, and the UnDeleteChild additions in #if NET8_0_OR_GREATER so these changes only apply to modern .NET, not .NET Framework or netstandard2.0. Give UnDeleteChild() a default interface implementation (throws NotImplementedException) so adding it to IEditableBusinessObject is not a breaking change. Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/Csla/BusinessDocumentBase.cs | 2 ++ Source/Csla/Core/BusinessBase.cs | 4 ++++ Source/Csla/Core/IEditableBusinessObject.cs | 4 +++- Source/Csla/IBusinessDocumentBase.cs | 2 ++ Source/tests/Csla.test/Server/FakeEntity.cs | 2 ++ 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Source/Csla/BusinessDocumentBase.cs b/Source/Csla/BusinessDocumentBase.cs index 42755fb8bc..e70d2c343f 100644 --- a/Source/Csla/BusinessDocumentBase.cs +++ b/Source/Csla/BusinessDocumentBase.cs @@ -6,6 +6,7 @@ // Base class combining BusinessBase and BusinessListBase capabilities. //----------------------------------------------------------------------- +#if NET8_0_OR_GREATER using System.Collections; using System.Collections.Specialized; using System.ComponentModel; @@ -1142,3 +1143,4 @@ protected virtual async Task Child_UpdateAsync(params object?[] parameters) #endregion } } +#endif diff --git a/Source/Csla/Core/BusinessBase.cs b/Source/Csla/Core/BusinessBase.cs index 7bc221a4ba..c5277febef 100644 --- a/Source/Csla/Core/BusinessBase.cs +++ b/Source/Csla/Core/BusinessBase.cs @@ -994,6 +994,7 @@ internal void DeleteChild() MarkDeleted(); } +#if NET8_0_OR_GREATER /// /// Called by a parent object to reverse a /// previous call to , @@ -1007,6 +1008,7 @@ internal void UnDeleteChild() IsDeleted = false; MetaPropertyHasChanged("IsDeleted"); } +#endif #endregion @@ -1576,10 +1578,12 @@ void IEditableBusinessObject.DeleteChild() DeleteChild(); } +#if NET8_0_OR_GREATER void IEditableBusinessObject.UnDeleteChild() { UnDeleteChild(); } +#endif void IEditableBusinessObject.SetParent(IParent? parent) { diff --git a/Source/Csla/Core/IEditableBusinessObject.cs b/Source/Csla/Core/IEditableBusinessObject.cs index fdeadc9b15..c5c63f0d1a 100644 --- a/Source/Csla/Core/IEditableBusinessObject.cs +++ b/Source/Csla/Core/IEditableBusinessObject.cs @@ -34,12 +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(); + 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 index f03ad35b45..3a21cf0de3 100644 --- a/Source/Csla/IBusinessDocumentBase.cs +++ b/Source/Csla/IBusinessDocumentBase.cs @@ -6,6 +6,7 @@ // Consolidated interface for the BusinessDocumentBase type. //----------------------------------------------------------------------- +#if NET8_0_OR_GREATER using Csla.Core; namespace Csla @@ -24,3 +25,4 @@ public interface IBusinessDocumentBase : { } } +#endif diff --git a/Source/tests/Csla.test/Server/FakeEntity.cs b/Source/tests/Csla.test/Server/FakeEntity.cs index 2be9e3629a..28109311da 100644 --- a/Source/tests/Csla.test/Server/FakeEntity.cs +++ b/Source/tests/Csla.test/Server/FakeEntity.cs @@ -53,7 +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() From c43ae4dc6a1757b6b771db7d62e2b5e3c475dde6 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 17:22:17 -0500 Subject: [PATCH 09/19] #1830 Address Stephan's review: guard UnDeleteChild, enable CI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UnDeleteChild now returns early if the child is not already deleted, avoiding a spurious MetaPropertyHasChanged notification - Remove SkipOnCIServer from all metastate tests — they are synchronous PropertyChanged assertions, not timing-sensitive Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/Csla/Core/BusinessBase.cs | 3 +++ .../BusinessDocumentBaseMetastateTests.cs | 18 ++++++------------ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/Source/Csla/Core/BusinessBase.cs b/Source/Csla/Core/BusinessBase.cs index c5277febef..838da17f02 100644 --- a/Source/Csla/Core/BusinessBase.cs +++ b/Source/Csla/Core/BusinessBase.cs @@ -1005,6 +1005,9 @@ internal void UnDeleteChild() if (!IsChild) throw new NotSupportedException(Resources.NoDeleteRootException); + if (!IsDeleted) + return; + IsDeleted = false; MetaPropertyHasChanged("IsDeleted"); } diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index a342f032b6..a09b12759e 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -5,8 +5,7 @@ // // // Tests for metastate PropertyChanged events on BusinessDocumentBase. -// Mirrors BasicModernTests patterns. Requires Xaml PropertyChangedMode -// and is skipped on CI server due to timing sensitivity. +// Mirrors BasicModernTests patterns. Requires Xaml PropertyChangedMode. // //----------------------------------------------------------------------- @@ -41,8 +40,7 @@ private MetastateDocument NewDocument() #region MakeOld [TestMethod] - [TestCategory("SkipOnCIServer")] - public void MakeOldMetastateEvents() + public void MakeOldMetastateEvents() { var doc = NewDocument(); var changed = new List(); @@ -65,8 +63,7 @@ public void MakeOldMetastateEvents() #region MarkDeleted [TestMethod] - [TestCategory("SkipOnCIServer")] - public void MarkDeletedMetastateEvents() + public void MarkDeletedMetastateEvents() { var doc = NewDocument(); doc.Name = "abc"; @@ -91,8 +88,7 @@ public void MarkDeletedMetastateEvents() #region Property Changed Metastate [TestMethod] - [TestCategory("SkipOnCIServer")] - public void RootChangedMetastateEventsId() + public void RootChangedMetastateEventsId() { // New doc is invalid (Name required) — setting Id (no rule) still triggers metastate events var doc = NewDocument(); @@ -112,8 +108,7 @@ public void RootChangedMetastateEventsId() } [TestMethod] - [TestCategory("SkipOnCIServer")] - public void RootChangedMetastateEventsName() + public void RootChangedMetastateEventsName() { var doc = NewDocument(); var changed = new List(); @@ -151,8 +146,7 @@ public void RootChangedMetastateEventsName() } [TestMethod] - [TestCategory("SkipOnCIServer")] - public void RootChangedMetastateEventsChild() + public void RootChangedMetastateEventsChild() { var childPortal = _testDIContext.CreateChildDataPortal(); From edd5ed461225198b893fdf123a899d0bf042a5b0 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 17:39:14 -0500 Subject: [PATCH 10/19] #1830 Fix metastate tests failing on CI due to AsyncLocal contamination Add TestInitialize to resolve ApplicationContext before each test, ensuring the Xaml-mode context is set on the current AsyncLocal flow. Other test classes in the Csla.test assembly use default (Windows) mode and can contaminate the AsyncLocal between test runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BusinessDocumentBaseMetastateTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index a09b12759e..5ada961ba5 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -31,6 +31,15 @@ public static void ClassInitialize(TestContext context) _testDIContext = new TestDIContext(serviceProvider); } + [TestInitialize] + public void TestInitialize() + { + // Resolve ApplicationContext to ensure the Xaml-mode context is set + // on the current AsyncLocal flow, preventing contamination from other + // test classes in this assembly that use default (Windows) mode. + _testDIContext.CreateTestApplicationContext(); + } + private MetastateDocument NewDocument() { var portal = _testDIContext.CreateDataPortal(); From 10be57b028815ba83f760ee608e281313169c718 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 18:36:58 -0500 Subject: [PATCH 11/19] #1830 Add diagnostic assertions to metastate tests for CI debugging Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BusinessDocumentBaseMetastateTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index 5ada961ba5..5d3b06f3ad 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -49,15 +49,22 @@ private MetastateDocument NewDocument() #region MakeOld [TestMethod] - public void MakeOldMetastateEvents() + public void MakeOldMetastateEvents() { var doc = NewDocument(); + + // Diagnostic: verify mode is Xaml + var appContext = _testDIContext.CreateTestApplicationContext(); + var mode = appContext.PropertyChangedMode; + Assert.AreEqual(ApplicationContext.PropertyChangedModes.Xaml, mode, + $"PropertyChangedMode should be Xaml but was {mode}"); + 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("IsNew"), $"IsNew should fire. Events: [{string.Join(", ", changed)}]"); Assert.IsTrue(changed.Contains("IsDirty"), "IsDirty should fire"); Assert.IsTrue(changed.Contains("IsSelfDirty"), "IsSelfDirty should fire"); Assert.IsTrue(changed.Contains("IsSavable"), "IsSavable should fire"); From fbe6449d0e9b696d1c9a20dd88b298dcdeeed471 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 18:45:16 -0500 Subject: [PATCH 12/19] #1830 More comprehensive diagnostics for CI metastate test failure Test whether PropertyChanged fires at all for a regular property change before testing metastate events. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BusinessDocumentBaseMetastateTests.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index 5d3b06f3ad..599cbac105 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -53,11 +53,12 @@ public void MakeOldMetastateEvents() { var doc = NewDocument(); - // Diagnostic: verify mode is Xaml - var appContext = _testDIContext.CreateTestApplicationContext(); - var mode = appContext.PropertyChangedMode; - Assert.AreEqual(ApplicationContext.PropertyChangedModes.Xaml, mode, - $"PropertyChangedMode should be Xaml but was {mode}"); + // Diagnostic: test if PropertyChanged fires at all for a regular property change + var probe = new List(); + doc.PropertyChanged += (_, e) => probe.Add(e.PropertyName ?? "(null)"); + doc.Name = "probe"; + Assert.IsTrue(probe.Count > 0, + $"PropertyChanged should fire for Name set, but got 0 events. IsNew={doc.IsNew}, IsDirty={doc.IsDirty}"); var changed = new List(); doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); From bb0c37eb65c69ecd015a72a230da54bc5ed4bc43 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 18:53:06 -0500 Subject: [PATCH 13/19] #1830 Diagnostic: check PropertyChangedMode on the object's ApplicationContext Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BusinessDocumentBaseMetastateTests.cs | 10 ++++------ .../BusinessDocumentBase/MetastateDocument.cs | 3 +++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index 599cbac105..bc3d9c365b 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -53,12 +53,10 @@ public void MakeOldMetastateEvents() { var doc = NewDocument(); - // Diagnostic: test if PropertyChanged fires at all for a regular property change - var probe = new List(); - doc.PropertyChanged += (_, e) => probe.Add(e.PropertyName ?? "(null)"); - doc.Name = "probe"; - Assert.IsTrue(probe.Count > 0, - $"PropertyChanged should fire for Name set, but got 0 events. IsNew={doc.IsNew}, IsDirty={doc.IsDirty}"); + // Diagnostic: verify mode is Xaml on the OBJECT's ApplicationContext + var objectMode = doc.GetPropertyChangedMode(); + Assert.AreEqual(ApplicationContext.PropertyChangedModes.Xaml, objectMode, + $"Object's PropertyChangedMode should be Xaml but was {objectMode}"); var changed = new List(); doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); diff --git a/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs b/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs index 1048d569b0..bc214cc808 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs @@ -30,6 +30,9 @@ public string Name public void MakeOld() => MarkOld(); + public ApplicationContext.PropertyChangedModes GetPropertyChangedMode() + => ApplicationContext.PropertyChangedMode; + [Create] private void DataPortal_Create() { From 01df8ced27fabbd57b66d4bfe2f6ab12e7ade1d3 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 19:02:19 -0500 Subject: [PATCH 14/19] #1830 Fix metastate tests: disable UseLocalScope to preserve Xaml mode The LocalProxy's UseLocalScope creates a new DI scope whose ApplicationContext resolves PropertyChangedMode as Windows (default) instead of inheriting the Xaml setting. Disabling UseLocalScope keeps the root ApplicationContext with Xaml mode on the business object. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BusinessDocumentBaseMetastateTests.cs | 22 +++++-------------- .../BusinessDocumentBase/MetastateDocument.cs | 3 --- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index bc3d9c365b..f2a145551a 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -25,21 +25,16 @@ public class BusinessDocumentBaseMetastateTests public static void ClassInitialize(TestContext context) { var services = new ServiceCollection(); - services.AddCsla(o => o.Binding(bo => bo.PropertyChangedMode = ApplicationContext.PropertyChangedModes.Xaml)); + services.AddCsla(o => + { + o.Binding(bo => bo.PropertyChangedMode = ApplicationContext.PropertyChangedModes.Xaml); + o.DataPortal(dp => dp.AddClientSideDataPortal(dpo => dpo.UseLocalProxy(lp => lp.UseLocalScope = false))); + }); services.AddScoped(); var serviceProvider = services.BuildServiceProvider(); _testDIContext = new TestDIContext(serviceProvider); } - [TestInitialize] - public void TestInitialize() - { - // Resolve ApplicationContext to ensure the Xaml-mode context is set - // on the current AsyncLocal flow, preventing contamination from other - // test classes in this assembly that use default (Windows) mode. - _testDIContext.CreateTestApplicationContext(); - } - private MetastateDocument NewDocument() { var portal = _testDIContext.CreateDataPortal(); @@ -53,17 +48,12 @@ public void MakeOldMetastateEvents() { var doc = NewDocument(); - // Diagnostic: verify mode is Xaml on the OBJECT's ApplicationContext - var objectMode = doc.GetPropertyChangedMode(); - Assert.AreEqual(ApplicationContext.PropertyChangedModes.Xaml, objectMode, - $"Object's PropertyChangedMode should be Xaml but was {objectMode}"); - var changed = new List(); doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); doc.MakeOld(); - Assert.IsTrue(changed.Contains("IsNew"), $"IsNew should fire. Events: [{string.Join(", ", changed)}]"); + 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"); diff --git a/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs b/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs index bc214cc808..1048d569b0 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/MetastateDocument.cs @@ -30,9 +30,6 @@ public string Name public void MakeOld() => MarkOld(); - public ApplicationContext.PropertyChangedModes GetPropertyChangedMode() - => ApplicationContext.PropertyChangedMode; - [Create] private void DataPortal_Create() { From 660c07ebcba34afbdff71ea94b9e7372815bce9f Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 19:11:02 -0500 Subject: [PATCH 15/19] #1830 Restore SkipOnCIServer on metastate tests The DefaultDataPortalActivator (line 43) resolves ApplicationContext from its ServiceProvider, which on CI picks up a Windows-mode context from AsyncLocal contamination by other tests in the Csla.test assembly. This is the same issue that causes BasicModernTests to skip these tests. The root cause is a framework issue to address separately. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BusinessDocumentBaseMetastateTests.cs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs index f2a145551a..13aaf3c30d 100644 --- a/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs +++ b/Source/tests/Csla.test/BusinessDocumentBase/BusinessDocumentBaseMetastateTests.cs @@ -6,6 +6,8 @@ // // 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). // //----------------------------------------------------------------------- @@ -25,11 +27,7 @@ public class BusinessDocumentBaseMetastateTests public static void ClassInitialize(TestContext context) { var services = new ServiceCollection(); - services.AddCsla(o => - { - o.Binding(bo => bo.PropertyChangedMode = ApplicationContext.PropertyChangedModes.Xaml); - o.DataPortal(dp => dp.AddClientSideDataPortal(dpo => dpo.UseLocalProxy(lp => lp.UseLocalScope = false))); - }); + services.AddCsla(o => o.Binding(bo => bo.PropertyChangedMode = ApplicationContext.PropertyChangedModes.Xaml)); services.AddScoped(); var serviceProvider = services.BuildServiceProvider(); _testDIContext = new TestDIContext(serviceProvider); @@ -44,10 +42,10 @@ private MetastateDocument NewDocument() #region MakeOld [TestMethod] + [TestCategory("SkipOnCIServer")] public void MakeOldMetastateEvents() { var doc = NewDocument(); - var changed = new List(); doc.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); @@ -68,7 +66,8 @@ public void MakeOldMetastateEvents() #region MarkDeleted [TestMethod] - public void MarkDeletedMetastateEvents() + [TestCategory("SkipOnCIServer")] + public void MarkDeletedMetastateEvents() { var doc = NewDocument(); doc.Name = "abc"; @@ -93,7 +92,8 @@ public void MarkDeletedMetastateEvents() #region Property Changed Metastate [TestMethod] - public void RootChangedMetastateEventsId() + [TestCategory("SkipOnCIServer")] + public void RootChangedMetastateEventsId() { // New doc is invalid (Name required) — setting Id (no rule) still triggers metastate events var doc = NewDocument(); @@ -113,7 +113,8 @@ public void RootChangedMetastateEventsId() } [TestMethod] - public void RootChangedMetastateEventsName() + [TestCategory("SkipOnCIServer")] + public void RootChangedMetastateEventsName() { var doc = NewDocument(); var changed = new List(); @@ -151,7 +152,8 @@ public void RootChangedMetastateEventsName() } [TestMethod] - public void RootChangedMetastateEventsChild() + [TestCategory("SkipOnCIServer")] + public void RootChangedMetastateEventsChild() { var childPortal = _testDIContext.CreateChildDataPortal(); From 29ce6d1353f6b46b4d2cc93f620b635ac25517c1 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 21:20:10 -0500 Subject: [PATCH 16/19] #1830 Convert ProjectTracker to use BusinessDocumentBase ProjectEdit and ResourceEdit now extend BusinessDocumentBase, rolling their child collections directly into the root type. ProjectResources and ResourceAssignments classes removed. RoleEditList also converted to BusinessDocumentBase. Updated AutoImplementProperties source generator to recognize BusinessDocumentBase as a valid CSLA base type. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ProjectTracker/Directory.Packages.props | 2 +- .../Pages/EditProject.razor | 12 +- .../Admin/RoleEditList.cs | 9 +- .../Admin/RoleEditManager.cs | 2 +- .../CslaBaseTypes/BusinessDocumentBase.cs | 12 ++ .../ProjectEdit.cs | 110 ++++++++---------- .../ProjectResources.cs | 55 --------- .../ResourceAssignments.cs | 61 ---------- .../ResourceEdit.cs | 93 +++++++-------- .../SerializationPartialBuilder.cs | 8 ++ 10 files changed, 126 insertions(+), 238 deletions(-) create mode 100644 Samples/ProjectTracker/ProjectTracker.BusinessLibrary/CslaBaseTypes/BusinessDocumentBase.cs delete mode 100644 Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ProjectResources.cs delete mode 100644 Samples/ProjectTracker/ProjectTracker.BusinessLibrary/ResourceAssignments.cs 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.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/SerializationPartialBuilder.cs b/Source/Csla.Generators/cs/AutoImplementProperties/Csla.Generator.AutoImplementProperties.CSharp/AutoImplement/SerializationPartialBuilder.cs index a00cd48e14..27cc240939 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"; From 2c2343c321dd55752af6cf5ff050e5704898a415 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 22:03:25 -0500 Subject: [PATCH 17/19] #1830 Fix WCF channel NuGet packaging and add XML docs Add Directory.Package.props import, SignAssembly, BaseOutputPath, and NuGet metadata to Csla.Channels.Wcf.csproj. Add missing XML doc comments on WcfProxy members. Co-Authored-By: Claude Opus 4.6 (1M context) --- Source/Csla.Channels.Wcf/Client/WcfProxy.cs | 7 +++++++ Source/Csla.Channels.Wcf/Csla.Channels.Wcf.csproj | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) 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 @@ - From bd8a959de80aba91ae22a538dd67b8bd1ec2c669 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 22:14:41 -0500 Subject: [PATCH 18/19] Update release notes for v10.1.0 --- releasenotes.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) 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** From 39a22739f1a4219ba85f4d3694d8e8ccafcfa223 Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Sun, 5 Apr 2026 22:17:31 -0500 Subject: [PATCH 19/19] #1830 Add SQLite WAL journal files to gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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