This page explains how LiteOrm models relationships with ForeignType, TableJoin, ForeignColumn, and AutoExpand.
ForeignType: property-level relationship metadata, best for a single foreign key columnTableJoin: type-level relationship metadata, best for reusable joins and composite-key relationshipsForeignColumn: fields projected from related tables into a view modelAutoExpand: extends the available relationship path so deeper related fields can be resolved laterExpr.ExistsRelated(...): uses an existing relationship to build an EXISTS filterThe following example is suitable for first-time LiteOrm association query users to get started:
[Table("Users")]
public class User
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
[Column("UserName")]
public string? UserName { get; set; }
}
[Table("Orders")]
public class Order
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
[Column("UserId")]
[ForeignType(typeof(User))]
public int UserId { get; set; }
}
public class OrderView : Order
{
[ForeignColumn(typeof(User), Property = nameof(User.UserName))]
public string? UserName { get; set; }
}
var orders = await orderService.SearchAsync<OrderView>();
If OrderView.UserName retrieves the correct value, the most basic ForeignType + ForeignColumn association chain is working.
Use ForeignType for a normal single-column foreign key.
[Table("Orders")]
public class Order
{
[Column("Id", IsPrimaryKey = true, IsIdentity = true)]
public int Id { get; set; }
[Column("UserId")]
[ForeignType(typeof(User), Alias = "U", JoinType = TableJoinType.Left, AutoExpand = false)]
public int UserId { get; set; }
[Column("Amount")]
public decimal Amount { get; set; }
}
Explanation: ForeignType is used to annotate the foreign key column corresponding to the external entity. When querying a view, ForeignColumn in the view class can automatically generate JOINs and read external table columns.
ForeignType Declarations on One ColumnThe same column can now declare multiple ForeignType entries. This is useful when one key needs to expose multiple readable relationship paths.
[Table("Documents")]
public class Document
{
[Column("OwnerId")]
[ForeignType(typeof(User), Alias = "Owner")]
[ForeignType(typeof(Department), Alias = "OwnerDept")]
public int OwnerId { get; set; }
}
public class DocumentView : Document
{
[ForeignColumn("Owner", Property = nameof(User.UserName))]
public string? OwnerName { get; set; }
[ForeignColumn("OwnerDept", Property = nameof(Department.Name))]
public string? OwnerDeptName { get; set; }
}
Notes:
ForeignType still represents a single-column relationship, and LiteOrm exposes them uniformly through SqlColumn.ForeignTables.Alias to avoid ambiguity.ForeignColumn should reference the alias when you need a specific path; type-based lookup is only suitable when there is a single unambiguous target.TableJoin is suitable for expressing complex association relationships.
If the target table uses a composite primary key, you can use ForeignKeys = "Key1,Key2" to provide multiple foreign key columns in order; ForeignType does not support this multi-column association scenario.
If you have a compatibility-driven mapping that must join by non-primary target fields, you can explicitly override the target join keys with PrimeKeys = "Code" or PrimeKeys = "Key1,Key2". This overrides the default “join by target primary key” behavior, but it is not the recommended style for normal models.
[TableJoin(typeof(OrderItem), "OrderId,LineNo", AliasName = "Item")]
public class Shipment
{
[Column("OrderId")]
public long OrderId { get; set; }
[Column("LineNo")]
public int LineNo { get; set; }
}
In this model, Shipment.OrderId + Shipment.LineNo will associate with the composite primary key of OrderItem in order.
// SalesRecord example: SalesUserId relates to User, and automatically expands User's relationships (such as Department)
[Column("SalesUserId")]
[ForeignType(typeof(User), AutoExpand = true)]
public int SalesUserId { get; set; }
public class SalesRecordView : SalesRecord
{
[ForeignColumn(typeof(User))]
public string? UserName { get; set; }
[ForeignColumn(typeof(Department), Property = nameof(Department.Name))]
public string? DepartmentName { get; set; }
}
// When querying SalesRecordView, LiteOrm can continue to resolve DepartmentName along the relationship path already defined in User.
Note: The core purpose of AutoExpand is to “make the next level of relationship path resolvable”. Whether more JOINs are actually generated still depends on whether the query really references fields or conditions on those paths.
| Scenario | AutoExpand = false |
AutoExpand = true |
|---|---|---|
| Only need first-level foreign table fields | Recommended | Works but usually unnecessary |
| Need to read across second-level relationships | Need to manually declare more joins | Recommended |
| Large tables, complex views, performance-sensitive | Safer | Evaluate carefully |
| Want to reduce view declaration complexity | Normal | More convenient |
LiteOrm.Demo\Models\User.cs provides a practical secondary relationship expansion model:
[Table("Sales_{0}")]
public class SalesRecord : ObjectBase, IArged
{
[Column("SalesUserId")]
[ForeignType(typeof(User), AutoExpand = true)]
public int SalesUserId { get; set; }
}
public class SalesRecordView : SalesRecord
{
[ForeignColumn(typeof(User))]
public string? UserName { get; set; }
[ForeignColumn(typeof(Department), Property = nameof(Department.Name))]
public string? DepartmentName { get; set; }
}
Key points:
SalesRecord only directly relates to UserUser itself relates to Department through ForeignType/TableJoinAutoExpand = true allows SalesRecordView to directly read Department.NameWithout AutoExpand enabled, fields like DepartmentName at the secondary level typically require additional join path declarations. This is also the most common and worthwhile use case for AutoExpand: filling in resolvable paths for multi-level relationships.
Demonstrates “Department + Parent Department” two-level relationship:
[Table("Users")]
[TableJoin("Dept", typeof(Department), nameof(Department.ParentId), AliasName = "Parent")]
public class User
{
[Column("DeptId")]
[ForeignType(typeof(Department), Alias = "Dept")]
public int? DeptId { get; set; }
}
public class UserView : User
{
[ForeignColumn("Dept", Property = "Name")]
public string? DeptName { get; set; }
[ForeignColumn("Parent", Property = "Name")]
public string? ParentDeptName { get; set; }
}
This pattern is suitable for stable multi-level read scenarios like “User → Department → Parent Department”.
Verifying that multi-level relationship fields can be used for filtering:
var usersByDept = await viewService.SearchAsync(u => u.DeptName == "Sub Dept");
var usersByParentDept = await viewService.SearchAsync(u => u.ParentDeptName == "Root Dept");
var combinedUsers = await viewService.SearchAsync(
u => u.DeptName == "Sub Dept" && u.ParentDeptName == "Root Dept"
);
Association fields can directly participate in sorting and pagination:
using static LiteOrm.Common.Expr;
var expr1 = From<TestUserView>()
.Where<TestUserView>(u => u.DeptName != null)
.OrderBy((nameof(TestUserView.DeptName), true))
.OrderBy((nameof(TestUser.Age), false))
.Section(0, 3);
var users1 = await viewService.SearchAsync(expr1);
And for deeper parent department fields:
using static LiteOrm.Common.Expr;
var expr2 = From<TestUserView>()
.Where<TestUserView>(u => u.ParentDeptName == "Parent Dept")
.OrderBy(nameof(TestUserView.ParentDeptName))
.OrderBy(nameof(TestUserView.DeptName))
.OrderBy(nameof(TestUser.Age))
.Section(0, 5);
var users2 = await viewService.SearchAsync(expr2);
This shows that ForeignColumn can not only display data, but also directly participate in:
When you don’t want to explicitly expose association fields in the view model but just want to “filter the main table by association table conditions”, you can use ExistsRelated.
ExistsRelated follows these priority rules when constructing relationship paths:
Association matching order:
Order.UserId -> User.Id)User.DeptId -> Department.Id)This “matching” is not based on hard-coded property-name guessing. It uses relationship metadata already declared in the model:
JoinedTablesDefinitionType in ForeignType / TableJoin can accept the target type in ExistsRelated<T>, meaning it matches the declared type itself and its subclasses onlyJoinedTables and try to infer the relationship back to the current main tableIn other words, ExistsRelated<TestDepartment>(...) depends on declared metadata such as ForeignType and TableJoin. It does not try to infer a relationship just because a field name “looks like” a foreign key at runtime.
That also means:
In other words, ForeignExpr / ExistsRelated<T> now follows inheritance only in the “declared type -> subclass” direction. Parent-type fallback is no longer part of the matching rule.
Multi-path merge logic:
OR conditionsAND, so all key pairs in that path must match together.You can think of it as:
(path1.key1 = key1 AND path1.key2 = key2 ...)
OR
(path2.key1 = key1 AND path2.key2 = key2 ...)
using static LiteOrm.Common.Expr;
// Query departments that "have users named ERRev_User1"
var expr = ExistsRelated<TestUser>(Prop("Name") == "ERRev_User1");
var results = await objectViewDAO.Search(expr).ToListAsync();
Even if TestDepartment itself does not have a directly declared ForeignType to TestUser, the framework can still complete reverse inference through the known association TestUser.DeptId -> TestDepartment.Id.
Usage guidance:
ExistsRelated to generate an EXISTS subquery correlated to the current main table, at least one side must already declare relationship metadata.ExistsRelated to invent the correlation condition for you. In that case, use explicit Expr.Exists(...) and write the correlation yourself, or add ForeignType / TableJoin metadata first.ConstFilter, the EXISTS subquery generated by ExistsRelated will also carry that fixed rule automatically. InnerExpr only needs to express the additional condition for the current query.using static LiteOrm.Common.Expr;
// 1. Forward: filter users by associated department
var expr = ExistsRelated<TestDepartment>(Prop("Name") == "ER_IT");
var users = await objectViewDAO.Search(expr).ToListAsync();
// 2. Negation: exclude users belonging to the target department
var notInIT = await objectViewDAO.Search(
!ExistsRelated<TestDepartment>(Prop("Name") == "ERNot_IT")
).ToListAsync();
// 3. Combine with regular field conditions
var matureItUsers = await objectViewDAO.Search(
ExistsRelated<TestDepartment>(Prop("Name") == "ERCombo_IT")
& (Prop("Age") >= 30)
).ToListAsync();
Usage recommendations:
ExistsRelatedDeptName / ParentDeptName: prefer ForeignColumn viewForeignTypeAttribute: ObjectType, Alias, JoinType, AutoExpandTableJoinAttribute: Source, TargetType, ForeignKeys, AliasName, JoinType, AutoExpandForeignColumnAttribute: Foreign (Type or AliasName), Property (column to retrieve)ColumnAttribute: Constant (fixed filter; see the permission filtering guide)In implementation, LiteOrm merges ForeignType and TableJoin information during the metadata phase to generate JoinedTable / ForeignTable structures. For fixed-filter metadata and SQL injection details, see Permission Filtering and User Scope Control.
Q: Can ForeignColumn’s Foreign be a TableJoin’s Alias? A: Yes. ForeignColumn’s Foreign parameter can be either an external type (Type) or an AliasName defined in TableJoin.
Q: Does AutoExpand expand infinitely? A: AutoExpand expands level by level according to defined associations, but the actual expansion depth depends on registered TableJoin/ForeignType configurations. Control carefully to avoid circular or explosive expansion.
Q: How to choose between ForeignType and TableJoin? A: Prefer ForeignType for single-column foreign keys; prefer TableJoin for joint primary keys or multi-column associations.
Q: When should I use Column.Constant?
A: Use it when the model itself always represents one fixed slice. For the full boundary, ConstFilter pipeline, and multi-tenant guidance, see Permission Filtering and User Scope Control.