Copying with property groups

Part of property groups.

Two calls copy property values between entities. Both match properties by name and use the normal setters and validation.

Call Copies Use it to
group.Copy(source, target) Every property in the group Duplicate a record, or default one from a template
args.CopyTo(target) One changed property, inside group.Changed().Do(...) Keep two records in sync

Rules for both calls:

  • Source and target must share an active EventContext. The target property type must accept the source value. Core does not convert values.
  • Missing groups, missing properties, and equal values are skipped.
  • Copy skips collections. Both sides need setters and enabled features.
  • Writable virtual properties are copied, including computed properties.
  • Read-only or hidden targets reject writes. Pass skipReadOnly: true to skip them.
  • An error stops copying. Earlier changes stay pending; Core does not undo them.
  • Neither call saves or creates records. Natural keys (Customer.Id), document numbers, and other business keys should normally not be in copy groups. Other required properties should often be included.
  • Copy reads each source property's accepted value in name order. It does not copy rejected source edits.
  • After reloading logic, get the group again before copying.

Scenario 1: A method that copies a contact

A CopyContact method creates a new contact and copies the shared fields onto it.

// Contact.cs (model)
[PropertyGroup<ContactCopy>]
public partial class Contact
{
    [Method("Copy contact", MethodType.Modify)]
    public partial void CopyContact();
}
// ContactCopy.cs (model)
public sealed class ContactCopy : IPropertyGroup;
// ContactBL.cs (business logic)
[Logic]
public class ContactBL(Contact.Logic contact)
{
    [RegisterLogic]
    public void CopyContactMethod()
    {
        var copyFields = contact.PropertyGroup<ContactCopy>()
            .Add(c => new { c.Company, c.JobTitle, c.Email, c.Phone, c.Address });

        contact.Method(c => c.CopyContact).Do((source, args) =>
        {
            var copy = new Contact(args.Context);
            copyFields.Copy(source, copy);
            copy.FirstName = source.FirstName;
            copy.LastName = $"{source.LastName} (copy)";
        });
    }
}
  • new Contact(args.Context) creates the new record in the same context, which Copy requires.
  • The group lists only the fields that every copy should carry. Set identifying fields such as the name in the method.

NOTE: We have not done a lot with copying. This does not copy children of an entity. I expect we should have the ability to copy an Invoice (Header and detail) or a customer (Account, Contact, Addresses). I expect to make future changes when we work on copying many entities- Glenn

Scenario 2: Default a new product from its category

A category names a default product, category.DefaultEntity, that holds the starting values for new products in that category. When a new product gets a category, copy those values onto it.

// Product.cs (model)
public sealed class CategoryDefaults : IPropertyGroup;

[PropertyGroup<CategoryDefaults>]
public partial class Product;
// ProductBL.cs (business logic)
[Logic]
public class ProductBL(Product.Logic product)
{
    [RegisterLogic]
    public void DefaultFromCategory()
    {
        var defaults = product.PropertyGroup<CategoryDefaults>()
            .Add(p => new { p.Description, p.Weight, p.Price }); //Sku is not included

        product.OnChanged(p => p.Category)
            .Do(p =>
            {
                if (!p._State.IsNew || p.Category?.DefaultEntity is not { } template)
                    return;

                defaults.Copy(template, p, skipReadOnly: true);
            });
    }
}
  • IsNew limits the copy to new products, so changing the category on a saved product leaves its values alone.
  • skipReadOnly: true skips any property that is currently read-only instead of throwing.
  • Copy overwrites existing values. To fill only empty properties, use a Default event for each property instead.

Keep two records in sync

Use args.CopyTo in a Changed handler to copy each change as it happens:

// Business logic, inside a [RegisterLogic] method
contact.PropertyGroup<SyncedToPeerContact>()
    .Add(c => new { c.Email, c.Phone })
    .Changed().Do((entity, args) =>
    {
        if (args.ChangedBy == ChangedBy.Direct && entity.Peer is { } peer)
            args.CopyTo(peer);
    });

CopyTo throws for collection events.

  • CopyTo does not save. The code that made the edit must save both records, and the target must already exist in the same EventContext.
  • ChangedBy.Direct copies direct edits, such as API updates. It skips changes made by handlers (LogicSubscriber), which prevents copying loops.
  • To copy handler changes too, track each copy to prevent loops. See ERP header synchronization.

Example: Order page

In the Order example, a SharedFields group holds Amount and Note on both Order and Invoice. The group is not tied to the contact scenarios above.

Copy one changed property with CopyTo

When a user edits Amount, copy only Amount to the linked Invoice:

// Business logic, inside a [RegisterLogic] method
order.PropertyGroup<SharedFields>().Changed().Do((o, args) =>
{
    if (args.ChangedBy == ChangedBy.Direct && o.LinkedInvoice is { } invoice)
        args.CopyTo(invoice);
});

Editing Note copies only Note. Description and document numbers are outside the shared group. This skips Apply note, which changes Note in a handler.

Order: edit Amount to $240.00.

Order with Amount 240 and Note Deliver before noon

Linked Invoice: Amount becomes $240.00. Note stays unchanged. Refresh the Invoice to see the saved value.

Linked Invoice with Amount 240 and its unchanged Invoice note

Copy a whole group with Copy

Copy to draft copies both Amount and Note to an existing draft Invoice:

// Order.cs (model)
[Method("Copy to draft", MethodType.Modify, SaveOnCompletion = true)]
public partial void CopyToDraft();
// Business logic, inside a [RegisterLogic] method
order.Method(o => o.CopyToDraft)
    .DisableIf(o => o.Draft == null)
    .Do(o =>
    {
        if (o.Draft is { } draft)
            order.PropertyGroup<SharedFields>().Copy(o, draft);
    })
    .OnSuccess("Shared fields copied to draft.");

SaveOnCompletion saves after the method. Copy does not save or create records.

Source Order: copy Amount and Note.

Order with Amount 240 and Note Deliver before noon

Draft Invoice: both values now match the Order. Refresh the draft to see the saved values.

Draft Invoice with Amount 240 and Note Deliver before noon