skills/kashiash/xaf-skills/xaf-xpo-models

xaf-xpo-models

SKILL.md

XAF: XPO Business Object Models

Base Classes

Class Key Type OID Field Deferred Delete Optimistic Lock Use Case
XPObject int (auto) OID (int) Yes (GCRecord) Yes (auto) Simple integer PK
XPCustomObject Custom ([Key]) User-defined Yes (GCRecord) Yes (auto) Custom PK type (Guid, string, composite)
XPBaseObject Custom ([Key]) User-defined No (immediate) Yes (auto) Physical deletion required
XPLiteObject Custom ([Key]) User-defined No No Legacy/views/joins, no concurrency
BaseObject (BCL) Guid (Oid) Oid (Guid) Yes Yes Recommended for new XAF projects

Inheritance chain: XPObjectXPCustomObjectXPBaseObjectPersistentBase

Deferred deletion: XPO sets GCRecord to a non-null value instead of deleting the row.

XAF recommendation: Use BaseObject (from DevExpress.Persistent.BaseImpl) for new XPO entities.

// Most common XAF XPO pattern
using DevExpress.Persistent.BaseImpl;
using DevExpress.Xpo;

public class Contact : BaseObject {
    public Contact(Session session) : base(session) { }

    private string firstName;
    public string FirstName {
        get => firstName;
        set => SetPropertyValue(nameof(FirstName), ref firstName, value);
    }
}

// XPObject (int key)
public class Product : XPObject {
    public Product(Session session) : base(session) { }
    private string name;
    public string Name {
        get => name;
        set => SetPropertyValue(nameof(Name), ref name, value);
    }
}

// XPCustomObject - custom key type
public class Setting : XPCustomObject {
    public Setting(Session session) : base(session) { }
    [Key]
    public string Key {
        get => key;
        set => SetPropertyValue(nameof(Key), ref key, value);
    }
    private string key;
}

Key Attributes

Attribute Purpose Example
[Persistent("ColName")] Map to specific DB column/table name [Persistent("first_name")]
[NonPersistent] Exclude from DB (calculated/transient) [NonPersistent]
[Size(n)] String column length (default 100) [Size(255)]
[Size(SizeAttribute.Unlimited)] nvarchar(max) / TEXT large strings/blobs
[Indexed] Create DB index [Indexed]
[Indexed(Unique=true)] Unique constraint + index [Indexed(Unique=true)]
[Association("Name")] Declare relation endpoint (BOTH sides) [Association("Dept-Contacts")]
[Aggregated] Cascade delete child collection on collection property
[Key] / [Key(AutoGenerate=true)] Mark primary key for XPBaseObject/XPLiteObject
[PersistentAlias("expr")] Server-side calculated alias [PersistentAlias("Price*Qty")]
[DbType("nvarchar(50)")] Exact DB column type [DbType("decimal(18,4)")]
[Delayed] Defer loading (large blob/text) on binary/ntext properties
[FetchOnly] Read-only DB computed column [FetchOnly]
[OptimisticLocking(false)] Disable locking on class class-level
[OptimisticLockingIgnored] Exclude property from locking version property-level
[NullValue(0)] Default value for null reads [NullValue("")]
[DisplayName("Label")] UI display name [DisplayName("First Name")]
[VisibleInDetailView(false)] Hide in Detail View property-level
[NoForeignKey] Skip FK constraint generation [NoForeignKey]

Relations

One-to-Many

Both ends MUST use the same association name string.

public class Department : BaseObject {
    public Department(Session session) : base(session) { }

    [Association("Department-Contacts")]
    public XPCollection<Contact> Contacts => GetCollection<Contact>(nameof(Contacts));
}

public class Contact : BaseObject {
    public Contact(Session session) : base(session) { }

    private Department department;
    [Association("Department-Contacts")]
    public Department Department {
        get => department;
        set => SetPropertyValue(nameof(Department), ref department, value);
    }
}

Add [Aggregated] to the collection for cascade delete.

Many-to-Many

Option A: Auto-generated intermediate table

public class Employee : BaseObject {
    public Employee(Session session) : base(session) { }

    [Association("Employee-Tasks")]
    public XPCollection<Task> Tasks => GetCollection<Task>(nameof(Tasks));
}

public class Task : BaseObject {
    public Task(Session session) : base(session) { }

    [Association("Employee-Tasks")]
    public XPCollection<Employee> Employees => GetCollection<Employee>(nameof(Employees));
}
// XPO auto-creates EmployeeTasks intermediate table

Option B: Explicit intermediate class (extra fields)

public class EmployeeTask : BaseObject {
    public EmployeeTask(Session session) : base(session) { }

    private Employee employee;
    [Association("Employee-EmployeeTasks")]
    public Employee Employee {
        get => employee;
        set => SetPropertyValue(nameof(Employee), ref employee, value);
    }

    private Task task;
    [Association("Task-EmployeeTasks")]
    public Task Task {
        get => task;
        set => SetPropertyValue(nameof(Task), ref task, value);
    }
    public DateTime AssignedOn { get; set; }
}

public class Employee : BaseObject {
    [Association("Employee-EmployeeTasks"), Aggregated]
    public XPCollection<EmployeeTask> EmployeeTasks => GetCollection<EmployeeTask>(nameof(EmployeeTasks));

    [ManyToManyAlias(nameof(EmployeeTasks), nameof(EmployeeTask.Task))]
    public XPCollection<Task> Tasks => GetCollection<Task>(nameof(Tasks));
}

One-to-One

public class Person : BaseObject {
    public Person(Session session) : base(session) { }

    private Passport passport;
    public Passport Passport {
        get => passport;
        set {
            Passport prev = passport;
            SetPropertyValue(nameof(Passport), ref passport, value);
            if (!IsLoading) {
                if (prev != null && prev.Person == this) prev.Person = null;
                if (value != null && value.Person != this) value.Person = this;
            }
        }
    }
}

Calculated / Non-Stored Properties

// NonPersistent - in-memory only, NOT filterable server-side
[NonPersistent]
public string FullName => $"{FirstName} {LastName}";

// PersistentAlias - server-side, filterable, no extra DB column
[PersistentAlias("Price * Quantity * (1 - Discount)")]
public decimal TotalPrice {
    get { return Convert.ToDecimal(EvaluateAlias(nameof(TotalPrice))); }
}

// Aggregate alias
[PersistentAlias("Orders.Count()")]
public int OrderCount {
    get { return Convert.ToInt32(EvaluateAlias(nameof(OrderCount))); }
}

// FetchOnly - maps to DB computed column (read-only)
[FetchOnly]
[Persistent("computed_total")]
public decimal ComputedTotal {
    get { return GetPropertyValue<decimal>(nameof(ComputedTotal)); }
}

Lifecycle Hooks

public class Order : BaseObject {
    public Order(Session session) : base(session) { }

    public override void AfterConstruction() {
        base.AfterConstruction();
        OrderDate = DateTime.Now;
        Status = OrderStatus.New;
    }

    public override void OnSaving() {
        base.OnSaving();
        if (IsNewObject) CreatedDate = DateTime.UtcNow;
        LastModified = DateTime.UtcNow;
    }

    public override void OnLoaded() {
        base.OnLoaded();
        // refresh in-memory caches
    }
}

Session Access

// Query in same session
public IList<LineItem> GetPendingItems() {
    return Session.Query<LineItem>()
                  .Where(li => li.Invoice == this && !li.IsProcessed)
                  .ToList();
}

// Find by criteria
public static Contact FindByEmail(Session session, string email) {
    return session.FindObject<Contact>(
        CriteriaOperator.Parse("Email = ?", email));
}

Never create new Session() inside a persistent object. Always use this.Session.


Optimistic Locking

XPO automatically adds OptimisticLockField (int) to most base classes.

// Disable for high-volume log tables
[OptimisticLocking(false)]
public class AuditLog : XPObject {
    public AuditLog(Session session) : base(session) { }
}

// Exclude property from bumping lock version
[OptimisticLockingIgnored]
public DateTime LastViewed {
    get => lastViewed;
    set => SetPropertyValue(nameof(LastViewed), ref lastViewed, value);
}
private DateTime lastViewed;

Common Pitfalls

Pitfall Solution
Missing Session constructor Every XPO class must have public MyClass(Session session) : base(session) { }
Auto-properties { get; set; } Breaks change tracking — always use SetPropertyValue with backing field
GetCollection<T>() in constructor Don't access collections during construction
new XPCollection<T>(session) in getter Creates unbound collection every access — use GetCollection<T>(nameof(Prop))
[Association] on one side only Both ends MUST have [Association("same-name")]
Default [Size] is 100 Without [Size], strings become nvarchar(100)
Cross-session object assignment Never assign an object from one Session to another
Session.Save() inside OnSaving() Causes infinite recursion — don't call Save/CommitChanges in hooks
Session/IObjectSpace stored in static field Memory leak — always scope to operation with using var os = ...
GetObjects<T>() without criteria on large tables Loads entire table — add CriteriaOperator or Take()

Source Links

Weekly Installs
5
GitHub Stars
2
First Seen
5 days ago
Installed on
opencode5
gemini-cli5
claude-code5
github-copilot5
codex5
amp5