@ -0,0 +1,149 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Define custom REST API endpoints with JavaScript handlers in the ABP Low-Code System. Create dynamic APIs without writing C# controllers." |
|||
} |
|||
``` |
|||
|
|||
# Custom Endpoints |
|||
|
|||
Custom Endpoints allow you to define REST API routes with server-side JavaScript handlers directly in `model.json`. Each endpoint is registered as an ASP.NET Core endpoint at startup and supports hot-reload when the model changes. |
|||
|
|||
## Defining Endpoints |
|||
|
|||
Add endpoints to the `endpoints` array in `model.json`: |
|||
|
|||
```json |
|||
{ |
|||
"endpoints": [ |
|||
{ |
|||
"name": "GetProductStats", |
|||
"route": "/api/custom/products/stats", |
|||
"method": "GET", |
|||
"description": "Get product statistics", |
|||
"requireAuthentication": false, |
|||
"javascript": "var count = await db.count('LowCodeDemo.Products.Product');\nreturn ok({ totalProducts: count });" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### Endpoint Descriptor |
|||
|
|||
| Field | Type | Default | Description | |
|||
|-------|------|---------|-------------| |
|||
| `name` | string | **Required** | Unique endpoint name | |
|||
| `route` | string | **Required** | URL route pattern (supports `{parameters}`) | |
|||
| `method` | string | `"GET"` | HTTP method: `GET`, `POST`, `PUT`, `DELETE` | |
|||
| `javascript` | string | **Required** | JavaScript handler code | |
|||
| `description` | string | null | Description for documentation | |
|||
| `requireAuthentication` | bool | `true` | Require authenticated user | |
|||
| `requiredPermissions` | string[] | null | Required permission names | |
|||
|
|||
## Route Parameters |
|||
|
|||
Use `{paramName}` syntax in the route. Access values via the `route` object: |
|||
|
|||
```json |
|||
{ |
|||
"name": "GetProductById", |
|||
"route": "/api/custom/products/{id}", |
|||
"method": "GET", |
|||
"javascript": "var product = await db.get('LowCodeDemo.Products.Product', route.id);\nif (!product) { return notFound('Product not found'); }\nreturn ok({ id: product.Id, name: product.Name, price: product.Price });" |
|||
} |
|||
``` |
|||
|
|||
## JavaScript Context |
|||
|
|||
Inside custom endpoint scripts, you have access to: |
|||
|
|||
### Request Context |
|||
|
|||
| Variable | Description | |
|||
|----------|-------------| |
|||
| `request` | Full request object | |
|||
| `route` | Route parameter values (e.g., `route.id`) | |
|||
| `params` | Alias for route parameters | |
|||
| `query` | Query string parameters (e.g., `query.q`, `query.page`) | |
|||
| `body` | Request body (for POST/PUT) | |
|||
| `headers` | Request headers | |
|||
| `user` | Current user (same as `context.currentUser` in [Interceptors](interceptors.md)) | |
|||
| `email` | Email sender (same as `context.emailSender` in [Interceptors](interceptors.md)) | |
|||
|
|||
### Response Helpers |
|||
|
|||
| Function | HTTP Status | Description | |
|||
|----------|-------------|-------------| |
|||
| `ok(data)` | 200 | Success response with data | |
|||
| `created(data)` | 201 | Created response with data | |
|||
| `noContent()` | 204 | No content response | |
|||
| `badRequest(message)` | 400 | Bad request response | |
|||
| `unauthorized(message)` | 401 | Unauthorized response | |
|||
| `forbidden(message)` | 403 | Forbidden response | |
|||
| `notFound(message)` | 404 | Not found response | |
|||
| `error(message)` | 500 | Internal server error response | |
|||
| `response(statusCode, data, error)` | Custom | Custom status code response | |
|||
|
|||
### Logging |
|||
|
|||
| Function | Description | |
|||
|----------|-------------| |
|||
| `log(message)` | Log an informational message | |
|||
| `logWarning(message)` | Log a warning message | |
|||
| `logError(message)` | Log an error message | |
|||
|
|||
### Database API |
|||
|
|||
The full [Scripting API](scripting-api.md) (`db` object) is available for querying and mutating data. |
|||
|
|||
## Examples |
|||
|
|||
### Get Statistics |
|||
|
|||
```json |
|||
{ |
|||
"name": "GetProductStats", |
|||
"route": "/api/custom/products/stats", |
|||
"method": "GET", |
|||
"requireAuthentication": false, |
|||
"javascript": "var totalCount = await db.count('LowCodeDemo.Products.Product');\nvar avgPrice = totalCount > 0 ? await db.query('LowCodeDemo.Products.Product').average(p => p.Price) : 0;\nreturn ok({ totalProducts: totalCount, averagePrice: avgPrice });" |
|||
} |
|||
``` |
|||
|
|||
### Search with Query Parameters |
|||
|
|||
```json |
|||
{ |
|||
"name": "SearchCustomers", |
|||
"route": "/api/custom/customers/search", |
|||
"method": "GET", |
|||
"requireAuthentication": true, |
|||
"javascript": "var searchTerm = query.q || '';\nvar customers = await db.query('LowCodeDemo.Customers.Customer')\n .where(c => c.Name.toLowerCase().includes(searchTerm.toLowerCase()))\n .take(10)\n .toList();\nreturn ok(customers.map(c => ({ id: c.Id, name: c.Name, email: c.EmailAddress })));" |
|||
} |
|||
``` |
|||
|
|||
### Dashboard Summary |
|||
|
|||
```json |
|||
{ |
|||
"name": "GetDashboardSummary", |
|||
"route": "/api/custom/dashboard", |
|||
"method": "GET", |
|||
"requireAuthentication": true, |
|||
"javascript": "var productCount = await db.count('LowCodeDemo.Products.Product');\nvar customerCount = await db.count('LowCodeDemo.Customers.Customer');\nvar orderCount = await db.count('LowCodeDemo.Orders.Order');\nreturn ok({ products: productCount, customers: customerCount, orders: orderCount, user: user.isAuthenticated ? user.userName : 'Anonymous' });" |
|||
} |
|||
``` |
|||
|
|||
## Authentication and Authorization |
|||
|
|||
| Setting | Behavior | |
|||
|---------|----------| |
|||
| `requireAuthentication: false` | Endpoint is publicly accessible | |
|||
| `requireAuthentication: true` | User must be authenticated | |
|||
| `requiredPermissions: ["MyApp.Products"]` | User must have the specified permissions | |
|||
|
|||
## See Also |
|||
|
|||
* [Scripting API](scripting-api.md) |
|||
* [Interceptors](interceptors.md) |
|||
* [model.json Structure](model-json.md) |
|||
@ -0,0 +1,572 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Define dynamic entities using C# attributes and configure them with the Fluent API in the ABP Low-Code System. The primary way to build auto-generated admin panels." |
|||
} |
|||
``` |
|||
|
|||
# Attributes & Fluent API |
|||
|
|||
C# Attributes and the Fluent API are the **recommended way** to define dynamic entities. They provide compile-time checking, IntelliSense, refactoring support, and keep your entity definitions close to your domain code. |
|||
|
|||
## Quick Start |
|||
|
|||
### Step 1: Define an Entity |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Products")] |
|||
public class Product : DynamicEntityBase |
|||
{ |
|||
[DynamicPropertyUnique] |
|||
public string Name { get; set; } |
|||
|
|||
[DynamicPropertyUI(DisplayName = "Unit Price")] |
|||
public decimal Price { get; set; } |
|||
|
|||
public int StockCount { get; set; } |
|||
|
|||
public DateTime? ReleaseDate { get; set; } |
|||
} |
|||
```` |
|||
|
|||
### Step 2: Add Migration and Run |
|||
|
|||
```bash |
|||
dotnet ef migrations add Added_Product |
|||
dotnet ef database update |
|||
``` |
|||
|
|||
You now have a complete Product management page with data grid, create/edit modals, search, sorting, and pagination. |
|||
|
|||
### Step 3: Add Relationships |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Orders")] |
|||
public class Order : DynamicEntityBase |
|||
{ |
|||
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
|||
public Guid CustomerId { get; set; } |
|||
|
|||
public decimal TotalAmount { get; set; } |
|||
public bool IsDelivered { get; set; } |
|||
} |
|||
|
|||
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
|||
public class OrderLine : DynamicEntityBase |
|||
{ |
|||
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
|||
public Guid ProductId { get; set; } |
|||
|
|||
public int Quantity { get; set; } |
|||
public decimal Amount { get; set; } |
|||
} |
|||
```` |
|||
|
|||
The `Order` page now has a foreign key dropdown for Customer, and `OrderLine` is managed as a nested child inside the Order detail modal. |
|||
|
|||
## Three-Layer Configuration System |
|||
|
|||
The Low-Code System uses a layered configuration model. From lowest to highest priority: |
|||
|
|||
1. **Code Layer** — C# classes with `[DynamicEntity]` and other attributes |
|||
2. **JSON Layer** — `model.json` file (see [model.json Structure](model-json.md)) |
|||
3. **Fluent Layer** — `AbpDynamicEntityConfig.EntityConfigurations` |
|||
|
|||
A `DefaultLayer` runs last to fill in any missing values with conventions. |
|||
|
|||
> When the same entity or property is configured in multiple layers, the higher-priority layer wins. |
|||
|
|||
## C# Attributes Reference |
|||
|
|||
### `[DynamicEntity]` |
|||
|
|||
Marks a class as a dynamic entity. The entity name is derived from the class namespace and name. |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
public class Product : DynamicEntityBase |
|||
{ |
|||
public string Name { get; set; } |
|||
public decimal Price { get; set; } |
|||
} |
|||
```` |
|||
|
|||
Use the `Parent` property for parent-child (master-detail) relationships: |
|||
|
|||
````csharp |
|||
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
|||
public class OrderLine : DynamicEntityBase |
|||
{ |
|||
public Guid ProductId { get; set; } |
|||
public int Quantity { get; set; } |
|||
} |
|||
```` |
|||
|
|||
### `[DynamicEntityUI]` |
|||
|
|||
Configures entity-level UI. Entities with `PageTitle` get a menu item and a dedicated page: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Product Management")] |
|||
public class Product : DynamicEntityBase |
|||
{ |
|||
// ... |
|||
} |
|||
```` |
|||
|
|||
### `[DynamicForeignKey]` |
|||
|
|||
Defines a foreign key relationship on a `Guid` property: |
|||
|
|||
````csharp |
|||
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
|||
public Guid CustomerId { get; set; } |
|||
```` |
|||
|
|||
| Parameter | Description | |
|||
|-----------|-------------| |
|||
| `entityName` | Full name of the target entity — can be a **dynamic entity** (e.g., `"MyApp.Customers.Customer"`) or a **[reference entity](reference-entities.md)** (e.g., `"Volo.Abp.Identity.IdentityUser"`) | |
|||
| `displayPropertyName` | Property to show in lookups | |
|||
| `access` | `ForeignAccess.None`, `ForeignAccess.View`, or `ForeignAccess.Edit` (see [Foreign Access](foreign-access.md)) | |
|||
|
|||
### `[DynamicPropertyUI]` |
|||
|
|||
Controls property visibility and behavior in the UI: |
|||
|
|||
````csharp |
|||
[DynamicPropertyUI( |
|||
DisplayName = "Registration Number", |
|||
IsAvailableOnListing = true, |
|||
IsAvailableOnDataTableFiltering = true, |
|||
CreationFormAvailability = EntityPropertyUIFormAvailability.Hidden, |
|||
EditingFormAvailability = EntityPropertyUIFormAvailability.NotAvailable, |
|||
QuickLookOrder = 100 |
|||
)] |
|||
public string RegistrationNumber { get; set; } |
|||
```` |
|||
|
|||
| Property | Type | Default | Description | |
|||
|----------|------|---------|-------------| |
|||
| `DisplayName` | string | null | Custom label for the property | |
|||
| `IsAvailableOnListing` | bool | `true` | Show in data grid | |
|||
| `IsAvailableOnDataTableFiltering` | bool | `true` | Show in filter panel | |
|||
| `CreationFormAvailability` | enum | `Available` | Visibility on create form | |
|||
| `EditingFormAvailability` | enum | `Available` | Visibility on edit form | |
|||
| `QuickLookOrder` | int | `-2` | Order in quick-look panel | |
|||
|
|||
The quick-look panel shows a summary of the selected record: |
|||
|
|||
 |
|||
|
|||
### `[DynamicPropertyServerOnly]` |
|||
|
|||
Hides a property from API clients entirely. It is stored in the database but never returned to the client: |
|||
|
|||
````csharp |
|||
[DynamicPropertyServerOnly] |
|||
public string InternalNotes { get; set; } |
|||
```` |
|||
|
|||
### `[DynamicPropertySetByClients]` |
|||
|
|||
Controls whether clients can set this property value. Useful for computed or server-assigned fields: |
|||
|
|||
````csharp |
|||
[DynamicPropertySetByClients(false)] |
|||
public string RegistrationNumber { get; set; } |
|||
```` |
|||
|
|||
### `[DynamicPropertyUnique]` |
|||
|
|||
Marks a property as requiring unique values across all records: |
|||
|
|||
````csharp |
|||
[DynamicPropertyUnique] |
|||
public string ProductCode { get; set; } |
|||
```` |
|||
|
|||
### `[DynamicEntityCommandInterceptor]` |
|||
|
|||
Defines JavaScript interceptors on a class for CRUD lifecycle hooks: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityCommandInterceptor( |
|||
"Create", |
|||
InterceptorType.Pre, |
|||
"if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
|||
)] |
|||
[DynamicEntityCommandInterceptor( |
|||
"Delete", |
|||
InterceptorType.Post, |
|||
"context.log('Deleted: ' + context.commandArgs.entityId);" |
|||
)] |
|||
public class Organization : DynamicEntityBase |
|||
{ |
|||
public string Name { get; set; } |
|||
} |
|||
```` |
|||
|
|||
> The `Name` parameter must be one of: `"Create"`, `"Update"`, or `"Delete"`. The `InterceptorType` can be `Pre`, `Post`, or `Replace`. When `Replace` is used, the default DB operation is skipped entirely and only the JavaScript handler runs. **`Replace-Create` must return the new entity's Id** (e.g. `return result.Id;` after `db.insert`). Multiple interceptors can be added to the same class (`AllowMultiple = true`). |
|||
|
|||
See [Interceptors](interceptors.md) for the full JavaScript context API. |
|||
|
|||
### `[DynamicEnum]` |
|||
|
|||
Marks an enum for use in dynamic entity properties: |
|||
|
|||
````csharp |
|||
[DynamicEnum] |
|||
public enum OrganizationType |
|||
{ |
|||
Corporate = 0, |
|||
Enterprise = 1, |
|||
Startup = 2, |
|||
Consulting = 3 |
|||
} |
|||
```` |
|||
|
|||
Reference in an entity: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Organizations")] |
|||
public class Organization : DynamicEntityBase |
|||
{ |
|||
public string Name { get; set; } |
|||
public OrganizationType OrganizationType { get; set; } |
|||
} |
|||
```` |
|||
|
|||
### Enum Localization |
|||
|
|||
Enum values can be localized using ABP's localization system. Add localization keys in the format `Enum:{EnumTypeName}.{ValueName}` to your localization JSON files: |
|||
|
|||
```json |
|||
{ |
|||
"culture": "en", |
|||
"texts": { |
|||
"Enum:OrganizationType.Corporate": "Corporate", |
|||
"Enum:OrganizationType.Enterprise": "Enterprise", |
|||
"Enum:OrganizationType.Startup": "Startup", |
|||
"Enum:OrganizationType.Consulting": "Consulting" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
The Blazor UI automatically uses these localization keys for enum dropdowns and display values. If no localization key is found, the enum member name is used as-is. |
|||
|
|||
## Fluent API |
|||
|
|||
The Fluent API has the **highest priority** in the configuration system. Use `AbpDynamicEntityConfig.EntityConfigurations` to override any attribute or JSON setting programmatically. |
|||
|
|||
### Basic Usage |
|||
|
|||
Configure in your Low-Code Initializer (e.g. `MyAppLowCodeInitializer`): |
|||
|
|||
````csharp |
|||
public static class MyAppLowCodeInitializer |
|||
{ |
|||
private static readonly AsyncOneTimeRunner Runner = new(); |
|||
|
|||
public static async Task InitializeAsync() |
|||
{ |
|||
await Runner.RunAsync(async () => |
|||
{ |
|||
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
|||
"MyApp.Products.Product", |
|||
entity => |
|||
{ |
|||
entity.DefaultDisplayPropertyName = "Name"; |
|||
|
|||
var priceProperty = entity.AddOrGetProperty("Price"); |
|||
priceProperty.AsRequired(); |
|||
priceProperty.UI = new EntityPropertyUIDescriptor |
|||
{ |
|||
DisplayName = "Unit Price", |
|||
CreationFormAvailability = EntityPropertyUIFormAvailability.Available |
|||
}; |
|||
|
|||
entity.AddOrGetProperty("InternalNotes").AsServerOnly(); |
|||
} |
|||
); |
|||
|
|||
await DynamicModelManager.Instance.InitializeAsync(); |
|||
}); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
You can also use the generic overload with a type parameter: |
|||
|
|||
````csharp |
|||
AbpDynamicEntityConfig.EntityConfigurations.Configure<Product>(entity => |
|||
{ |
|||
entity.DefaultDisplayPropertyName = "Name"; |
|||
}); |
|||
```` |
|||
|
|||
### Entity Configuration |
|||
|
|||
The `Configure` method provides an `EntityDescriptor` instance. You can set its properties directly: |
|||
|
|||
| Property / Method | Description | |
|||
|--------|-------------| |
|||
| `DefaultDisplayPropertyName` | Set the display property for lookups | |
|||
| `Parent` | Set parent entity name for nesting | |
|||
| `UI` | Set entity-level UI (`EntityUIDescriptor` with `PageTitle`) | |
|||
| `AddOrGetProperty(name)` | Get or create a property descriptor for configuration | |
|||
| `FindProperty(name)` | Find a property descriptor by name (returns `null` if not found) | |
|||
| `GetProperty(name)` | Get a property descriptor by name (throws if not found) | |
|||
| `Interceptors` | List of `CommandInterceptorDescriptor` — add interceptors directly | |
|||
|
|||
### Property Configuration |
|||
|
|||
`AddOrGetProperty` returns an `EntityPropertyDescriptor`. Configure it using direct property assignment and extension methods: |
|||
|
|||
| Property / Extension Method | Description | |
|||
|--------|-------------| |
|||
| `.AsRequired(bool)` | Mark as required (extension method, returns the descriptor for chaining) | |
|||
| `.AsServerOnly(bool)` | Hide from clients (extension method, returns the descriptor for chaining) | |
|||
| `.MapToDbField(bool)` | Control if property is stored in DB (extension method, returns the descriptor for chaining) | |
|||
| `.IsUnique` | Set to `true` to mark as unique | |
|||
| `.AllowSetByClients` | Set to `false` to prevent client writes | |
|||
| `.ForeignKey` | Set a `ForeignKeyDescriptor` to configure foreign key relationship | |
|||
| `.UI` | Set an `EntityPropertyUIDescriptor` to configure property UI | |
|||
|
|||
### Chaining Extension Methods |
|||
|
|||
The extension methods `AsRequired()`, `AsServerOnly()`, and `MapToDbField()` return the property descriptor, enabling fluent chaining: |
|||
|
|||
````csharp |
|||
entity.AddOrGetProperty("InternalNotes") |
|||
.AsServerOnly() |
|||
.AsRequired() |
|||
.MapToDbField(); |
|||
```` |
|||
|
|||
### Configuring Foreign Keys |
|||
|
|||
````csharp |
|||
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
|||
"MyApp.Orders.Order", |
|||
entity => |
|||
{ |
|||
var customerIdProperty = entity.AddOrGetProperty("CustomerId"); |
|||
customerIdProperty.ForeignKey = new ForeignKeyDescriptor |
|||
{ |
|||
EntityName = "MyApp.Customers.Customer", |
|||
DisplayPropertyName = "Name", |
|||
Access = ForeignAccess.Edit |
|||
}; |
|||
} |
|||
); |
|||
```` |
|||
|
|||
### Adding Interceptors |
|||
|
|||
````csharp |
|||
entity.Interceptors.Add(new CommandInterceptorDescriptor("Create") |
|||
{ |
|||
Type = InterceptorType.Pre, |
|||
Javascript = "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
|||
}); |
|||
```` |
|||
|
|||
## Assembly Registration |
|||
|
|||
Register assemblies containing `[DynamicEntity]` classes in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer): |
|||
|
|||
````csharp |
|||
AbpDynamicEntityConfig.SourceAssemblies.Add( |
|||
new DynamicEntityAssemblyInfo( |
|||
typeof(MyDomainModule).Assembly, |
|||
rootNamespace: "MyApp", |
|||
projectRootPath: sourcePath // For model.json hot-reload |
|||
) |
|||
); |
|||
```` |
|||
|
|||
| Parameter | Description | |
|||
|-----------|-------------| |
|||
| `assembly` | The assembly containing `[DynamicEntity]` classes and/or `model.json` | |
|||
| `rootNamespace` | Root namespace for the assembly (used for embedded resource lookup) | |
|||
| `projectRootPath` | Path to the Domain project source folder (enables `model.json` hot-reload in development) | |
|||
|
|||
You can also register entity types directly: |
|||
|
|||
````csharp |
|||
AbpDynamicEntityConfig.DynamicEntityTypes.Add(typeof(Product)); |
|||
AbpDynamicEntityConfig.DynamicEnumTypes.Add(typeof(OrganizationType)); |
|||
```` |
|||
|
|||
## Combining with model.json |
|||
|
|||
Attributes and model.json work together seamlessly. A common pattern: |
|||
|
|||
1. **Define core entities** with C# attributes (compile-time safety) |
|||
2. **Add additional entities** via model.json (no recompilation needed) |
|||
3. **Fine-tune configuration** with Fluent API (overrides everything) |
|||
|
|||
The three-layer system merges all definitions: |
|||
|
|||
``` |
|||
Fluent API (highest) > JSON (model.json) > Code (Attributes) > Defaults (lowest) |
|||
``` |
|||
|
|||
For example, if an attribute sets `[DynamicPropertyUnique]` and model.json sets `"isUnique": false`, the JSON value wins because JSON layer has higher priority than Code layer. |
|||
|
|||
## End-to-End Example |
|||
|
|||
A complete e-commerce-style entity setup: |
|||
|
|||
````csharp |
|||
// Enum |
|||
[DynamicEnum] |
|||
public enum OrderStatus |
|||
{ |
|||
Pending = 0, |
|||
Processing = 1, |
|||
Shipped = 2, |
|||
Delivered = 3 |
|||
} |
|||
|
|||
// Customer entity |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Customers")] |
|||
public class Customer : DynamicEntityBase |
|||
{ |
|||
[DynamicPropertyUnique] |
|||
public string Name { get; set; } |
|||
|
|||
[DynamicPropertyUI(DisplayName = "Phone Number", QuickLookOrder = 100)] |
|||
public string Telephone { get; set; } |
|||
|
|||
[DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")] |
|||
public Guid? UserId { get; set; } |
|||
|
|||
[DynamicPropertyServerOnly] |
|||
public string InternalNotes { get; set; } |
|||
} |
|||
|
|||
// Product entity |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Products")] |
|||
public class Product : DynamicEntityBase |
|||
{ |
|||
[DynamicPropertyUnique] |
|||
public string Name { get; set; } |
|||
|
|||
public decimal Price { get; set; } |
|||
public int StockCount { get; set; } |
|||
} |
|||
|
|||
// Order entity with child OrderLine |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Orders")] |
|||
[DynamicEntityCommandInterceptor( |
|||
"Update", |
|||
InterceptorType.Pre, |
|||
@"if(context.commandArgs.data['IsDelivered']) { |
|||
if(!context.currentUser.roles.includes('admin')) { |
|||
globalError = 'Only admins can mark as delivered!'; |
|||
} |
|||
}" |
|||
)] |
|||
public class Order : DynamicEntityBase |
|||
{ |
|||
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
|||
public Guid CustomerId { get; set; } |
|||
|
|||
public decimal TotalAmount { get; set; } |
|||
public bool IsDelivered { get; set; } |
|||
public OrderStatus Status { get; set; } |
|||
} |
|||
|
|||
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
|||
public class OrderLine : DynamicEntityBase |
|||
{ |
|||
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
|||
public Guid ProductId { get; set; } |
|||
|
|||
public int Quantity { get; set; } |
|||
public decimal Amount { get; set; } |
|||
} |
|||
```` |
|||
|
|||
Register everything in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer): |
|||
|
|||
````csharp |
|||
public static class MyAppLowCodeInitializer |
|||
{ |
|||
private static readonly AsyncOneTimeRunner Runner = new(); |
|||
|
|||
public static async Task InitializeAsync() |
|||
{ |
|||
await Runner.RunAsync(async () => |
|||
{ |
|||
// Reference existing ABP entities |
|||
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>("UserName"); |
|||
|
|||
// Register assembly |
|||
AbpDynamicEntityConfig.SourceAssemblies.Add( |
|||
new DynamicEntityAssemblyInfo(typeof(MyDomainModule).Assembly) |
|||
); |
|||
|
|||
// Initialize |
|||
await DynamicModelManager.Instance.InitializeAsync(); |
|||
}); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
Configure your DbContext to implement `IDbContextWithDynamicEntities`: |
|||
|
|||
````csharp |
|||
public class MyAppDbContext : AbpDbContext<MyAppDbContext>, IDbContextWithDynamicEntities |
|||
{ |
|||
// ... constructors and DbSets ... |
|||
|
|||
protected override void OnModelCreating(ModelBuilder builder) |
|||
{ |
|||
builder.ConfigureDynamicEntities(); |
|||
base.OnModelCreating(builder); |
|||
} |
|||
} |
|||
```` |
|||
|
|||
Configure your DbContextFactory for EF Core CLI commands: |
|||
|
|||
````csharp |
|||
public class MyAppDbContextFactory : IDesignTimeDbContextFactory<MyAppDbContext> |
|||
{ |
|||
public MyAppDbContext CreateDbContext(string[] args) |
|||
{ |
|||
var configuration = BuildConfiguration(); |
|||
|
|||
MyAppEfCoreEntityExtensionMappings.Configure(); |
|||
|
|||
// ----- Ensure Low-Code system is initialized before running migrations --- |
|||
LowCodeEfCoreTypeBuilderExtensions.Configure(); |
|||
AsyncHelper.RunSync(MyAppLowCodeInitializer.InitializeAsync); |
|||
// ------------------------------- |
|||
|
|||
var builder = new DbContextOptionsBuilder<MyAppDbContext>() |
|||
.UseSqlServer(configuration.GetConnectionString("Default")); |
|||
|
|||
return new MyAppDbContext(builder.Options); |
|||
} |
|||
|
|||
// ... BuildConfiguration method ... |
|||
} |
|||
|
|||
This gives you four auto-generated pages (Customers, Products, Orders with nested OrderLines), complete with permissions, menu items, foreign key lookups, and interceptor-based business rules. |
|||
|
|||
## See Also |
|||
|
|||
* [model.json Structure](model-json.md) |
|||
* [Reference Entities](reference-entities.md) |
|||
* [Interceptors](interceptors.md) |
|||
@ -0,0 +1,148 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Control access to related entities through foreign key relationships using Foreign Access in the ABP Low-Code System." |
|||
} |
|||
``` |
|||
|
|||
# Foreign Access |
|||
|
|||
Foreign Access controls how related **dynamic entities** can be accessed through foreign key relationships. It determines whether users can view or manage related data directly from the **target entity's** UI. |
|||
|
|||
> **Important:** Foreign Access only works between **dynamic entities**. It does not apply to [reference entities](reference-entities.md) because they are read-only and don't have UI pages. |
|||
|
|||
## Access Levels |
|||
|
|||
The `ForeignAccess` enum defines three levels: |
|||
|
|||
| Level | Value | Description | |
|||
|-------|-------|-------------| |
|||
| `None` | 0 | No access from the target entity side. The relationship exists only for lookups. | |
|||
| `View` | 1 | Read-only access. Users can view related records from the target entity's action menu. | |
|||
| `Edit` | 2 | Full CRUD access. Users can create, update, and delete related records from the target entity's action menu. | |
|||
|
|||
## Configuring with Attributes |
|||
|
|||
Use the third parameter of `[DynamicForeignKey]`: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
public class Order |
|||
{ |
|||
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
|||
public Guid CustomerId { get; set; } |
|||
} |
|||
```` |
|||
|
|||
## Configuring with Fluent API |
|||
|
|||
````csharp |
|||
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
|||
"MyApp.Orders.Order", |
|||
entity => |
|||
{ |
|||
var customerIdProperty = entity.AddOrGetProperty("CustomerId"); |
|||
customerIdProperty.ForeignKey = new ForeignKeyDescriptor |
|||
{ |
|||
EntityName = "MyApp.Customers.Customer", |
|||
DisplayPropertyName = "Name", |
|||
Access = ForeignAccess.Edit |
|||
}; |
|||
} |
|||
); |
|||
```` |
|||
|
|||
## Configuring in model.json |
|||
|
|||
Set the `access` field on a foreign key property: |
|||
|
|||
```json |
|||
{ |
|||
"name": "CustomerId", |
|||
"foreignKey": { |
|||
"entityName": "LowCodeDemo.Customers.Customer", |
|||
"displayPropertyName": "Name", |
|||
"access": "edit" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### Examples from the Demo Application |
|||
|
|||
**Edit access** — Orders can be managed from the Customer page: |
|||
|
|||
```json |
|||
{ |
|||
"name": "LowCodeDemo.Orders.Order", |
|||
"properties": [ |
|||
{ |
|||
"name": "CustomerId", |
|||
"foreignKey": { |
|||
"entityName": "LowCodeDemo.Customers.Customer", |
|||
"access": "edit" |
|||
} |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
**View access** — Visited countries are viewable from the Country page: |
|||
|
|||
```json |
|||
{ |
|||
"name": "LowCodeDemo.Customers.VisitedCountry", |
|||
"parent": "LowCodeDemo.Customers.Customer", |
|||
"properties": [ |
|||
{ |
|||
"name": "CountryId", |
|||
"foreignKey": { |
|||
"entityName": "LowCodeDemo.Countries.Country", |
|||
"access": "view" |
|||
} |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
## UI Behavior |
|||
|
|||
When foreign access is configured between two **dynamic entities**: |
|||
|
|||
 |
|||
|
|||
### `ForeignAccess.View` |
|||
|
|||
An **action menu item** appears on the target entity's data grid row (e.g., a "Visited Countries" item on the Country row). Clicking it opens a read-only modal showing related records. |
|||
|
|||
### `ForeignAccess.Edit` |
|||
|
|||
An **action menu item** appears on the target entity's data grid row (e.g., an "Orders" item on the Customer row). Clicking it opens a fully functional CRUD modal where users can create, edit, and delete related records. |
|||
|
|||
 |
|||
|
|||
### `ForeignAccess.None` |
|||
|
|||
No action menu item is added. The foreign key exists only for data integrity and lookup display. |
|||
|
|||
## Permission Control |
|||
|
|||
Foreign access actions respect the **entity permissions** of the source entity (the entity with the foreign key). For example, if a user does not have the `Delete` permission for `Order`, the delete button will not appear in the foreign access modal, even if the access level is `Edit`. |
|||
|
|||
## How It Works |
|||
|
|||
The `ForeignAccessRelation` class stores the relationship metadata: |
|||
|
|||
* **Source entity** — the dynamic entity with the foreign key (e.g., `Order`) |
|||
* **Target entity** — the dynamic entity being referenced (e.g., `Customer`) |
|||
* **Foreign key property** — the property name (e.g., `CustomerId`) |
|||
* **Access level** — `None`, `View`, or `Edit` |
|||
|
|||
The `DynamicEntityAppService` checks these relations when building entity actions and filtering data. |
|||
|
|||
> **Terminology:** In foreign access context, "target entity" refers to the entity whose UI shows the action menu (the entity being pointed to by the foreign key). This is different from "reference entity" which specifically means an existing C# entity registered for read-only access. |
|||
|
|||
## See Also |
|||
|
|||
* [model.json Structure](model-json.md) |
|||
* [Reference Entities](reference-entities.md) |
|||
* [Attributes & Fluent API](fluent-api.md) |
|||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@ -0,0 +1,365 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "ABP Low-Code System: Build admin panels with auto-generated CRUD UI, APIs, and permissions using C# attributes and Fluent API. No boilerplate code needed." |
|||
} |
|||
``` |
|||
|
|||
# Low-Code System |
|||
|
|||
> You must have an ABP Team or a higher license to use this module. |
|||
|
|||
The ABP Low-Code System allows you to define entities using C# attributes or Fluent API and automatically generates: |
|||
|
|||
* **Database tables** (via EF Core migrations) |
|||
* **CRUD REST APIs** (Get, GetList, Create, Update, Delete) |
|||
* **Permissions** (View, Create, Update, Delete per entity) |
|||
* **Menu items** (auto-added to the admin sidebar) |
|||
* **Full Blazor UI** (data grid, create/edit modals, filters, foreign key lookups) |
|||
|
|||
No need to write DTOs, application services, repositories, or UI pages manually. |
|||
|
|||
 |
|||
|
|||
## Why Low-Code? |
|||
|
|||
Traditionally, adding a new entity with full CRUD functionality to an ABP application requires: |
|||
|
|||
* Entity class in Domain |
|||
* DbContext configuration in EF Core |
|||
* DTOs in Application.Contracts |
|||
* AppService in Application |
|||
* Controller in HttpApi |
|||
* Razor/Blazor pages in UI |
|||
* Permissions, menu items, localization |
|||
|
|||
**With Low-Code, a single C# class replaces all of the above:** |
|||
|
|||
````csharp |
|||
[DynamicEntity(DefaultDisplayPropertyName = "Name")] |
|||
[DynamicEntityUI(PageTitle = "Products")] |
|||
public class Product : DynamicEntityBase |
|||
{ |
|||
[DynamicPropertyUnique] |
|||
public string Name { get; set; } |
|||
|
|||
[DynamicPropertyUI(DisplayName = "Unit Price")] |
|||
public decimal Price { get; set; } |
|||
|
|||
public int StockCount { get; set; } |
|||
|
|||
[DynamicForeignKey("MyApp.Categories.Category", "Name")] |
|||
public Guid? CategoryId { get; set; } |
|||
} |
|||
```` |
|||
|
|||
Run `dotnet ef migrations add Added_Product` and start your application. You get a complete Product management page with search, filtering, sorting, pagination, create/edit forms, and foreign key dropdown — all auto-generated. |
|||
|
|||
 |
|||
|
|||
 |
|||
|
|||
## Getting Started |
|||
|
|||
### 1. Create a Low-Code Initializer |
|||
|
|||
Create a static initializer class in your Domain project's `_Dynamic` folder that registers your assembly and calls `DynamicModelManager.Instance.InitializeAsync()`: |
|||
|
|||
````csharp |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.LowCode.Configuration; |
|||
using Volo.Abp.LowCode.Modeling; |
|||
using Volo.Abp.Threading; |
|||
|
|||
namespace MyApp._Dynamic; |
|||
|
|||
public static class MyAppLowCodeInitializer |
|||
{ |
|||
private static readonly AsyncOneTimeRunner Runner = new(); |
|||
|
|||
public static async Task InitializeAsync() |
|||
{ |
|||
await Runner.RunAsync(async () => |
|||
{ |
|||
// Register reference entities (optional — for linking to existing C# entities) |
|||
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>( |
|||
nameof(IdentityUser.UserName), |
|||
nameof(IdentityUser.Email) |
|||
); |
|||
|
|||
// Register assemblies containing [DynamicEntity] classes and model.json |
|||
var sourcePath = ResolveDomainSourcePath(); |
|||
AbpDynamicEntityConfig.SourceAssemblies.Add( |
|||
new DynamicEntityAssemblyInfo( |
|||
typeof(MyAppDomainModule).Assembly, |
|||
rootNamespace: "MyApp", |
|||
projectRootPath: sourcePath // Required for model.json hot-reload in development |
|||
) |
|||
); |
|||
|
|||
// Fluent API configurations (optional — highest priority) |
|||
AbpDynamicEntityConfig.EntityConfigurations.Configure("MyApp.Products.Product", entity => |
|||
{ |
|||
entity.AddOrGetProperty("InternalNotes").AsServerOnly(); |
|||
}); |
|||
|
|||
// Initialize the dynamic model manager |
|||
await DynamicModelManager.Instance.InitializeAsync(); |
|||
}); |
|||
} |
|||
|
|||
private static string ResolveDomainSourcePath() |
|||
{ |
|||
// Traverse up from bin folder to find the Domain project source |
|||
var baseDir = AppContext.BaseDirectory; |
|||
var current = new DirectoryInfo(baseDir); |
|||
|
|||
for (int i = 0; i < 10 && current != null; i++) |
|||
{ |
|||
var candidate = Path.Combine(current.FullName, "src", "MyApp.Domain"); |
|||
if (Directory.Exists(Path.Combine(candidate, "_Dynamic"))) |
|||
{ |
|||
return candidate; |
|||
} |
|||
current = current.Parent; |
|||
} |
|||
|
|||
// Fallback for production (embedded resource will be used instead) |
|||
return string.Empty; |
|||
} |
|||
} |
|||
```` |
|||
|
|||
> The `projectRootPath` parameter enables hot-reload of `model.json` during development. When the path is empty or the file doesn't exist, the module falls back to reading `model.json` as an embedded resource. |
|||
|
|||
### 2. Call the Initializer in Program.cs |
|||
|
|||
The initializer must be called **before** the application starts. Add it to `Program.cs`: |
|||
|
|||
````csharp |
|||
public static async Task<int> Main(string[] args) |
|||
{ |
|||
// Initialize Low-Code before building the application |
|||
await MyAppLowCodeInitializer.InitializeAsync(); |
|||
|
|||
var builder = WebApplication.CreateBuilder(args); |
|||
// ... rest of your startup code |
|||
} |
|||
```` |
|||
|
|||
> **Important:** The initializer must also be called in your `DbMigrator` project and any other entry points (AuthServer, HttpApi.Host, etc.) that use dynamic entities. This ensures EF Core migrations can discover the entity schema. |
|||
|
|||
### 3. Configure DbContext |
|||
|
|||
Call `ConfigureDynamicEntities()` in your `DbContext`: |
|||
|
|||
````csharp |
|||
protected override void OnModelCreating(ModelBuilder builder) |
|||
{ |
|||
builder.ConfigureDynamicEntities(); |
|||
base.OnModelCreating(builder); |
|||
} |
|||
```` |
|||
|
|||
### 3. Define Your First Entity |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Customers")] |
|||
public class Customer : DynamicEntityBase |
|||
{ |
|||
public string Name { get; set; } |
|||
|
|||
[DynamicPropertyUI(DisplayName = "Phone Number")] |
|||
public string Telephone { get; set; } |
|||
|
|||
[DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")] |
|||
public Guid? UserId { get; set; } |
|||
} |
|||
```` |
|||
|
|||
### 4. Add Migration and Run |
|||
|
|||
```bash |
|||
dotnet ef migrations add Added_Customer |
|||
dotnet ef database update |
|||
``` |
|||
|
|||
Start your application — the Customer page is ready. |
|||
|
|||
## Two Ways to Define Entities |
|||
|
|||
### C# Attributes (Recommended) |
|||
|
|||
Define entities as C# classes with attributes. You get compile-time checking, IntelliSense, and refactoring support: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityUI(PageTitle = "Orders")] |
|||
public class Order : DynamicEntityBase |
|||
{ |
|||
[DynamicForeignKey("MyApp.Customers.Customer", "Name", ForeignAccess.Edit)] |
|||
public Guid CustomerId { get; set; } |
|||
|
|||
public decimal TotalAmount { get; set; } |
|||
public bool IsDelivered { get; set; } |
|||
} |
|||
|
|||
[DynamicEntity(Parent = "MyApp.Orders.Order")] |
|||
public class OrderLine : DynamicEntityBase |
|||
{ |
|||
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
|||
public Guid ProductId { get; set; } |
|||
|
|||
public int Quantity { get; set; } |
|||
public decimal Amount { get; set; } |
|||
} |
|||
```` |
|||
|
|||
See [Attributes & Fluent API](fluent-api.md) for the full attribute reference. |
|||
|
|||
### model.json (Declarative) |
|||
|
|||
Alternatively, define entities in a JSON file without writing C# classes: |
|||
|
|||
```json |
|||
{ |
|||
"entities": [ |
|||
{ |
|||
"name": "MyApp.Customers.Customer", |
|||
"displayProperty": "Name", |
|||
"properties": [ |
|||
{ "name": "Name", "isRequired": true }, |
|||
{ "name": "Telephone", "ui": { "displayName": "Phone Number" } } |
|||
], |
|||
"ui": { "pageTitle": "Customers" } |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
See [model.json Structure](model-json.md) for the full specification. |
|||
|
|||
> Both approaches can be combined. The [three-layer configuration system](fluent-api.md#three-layer-configuration-system) merges Attributes, JSON, and Fluent API with clear priority rules. |
|||
|
|||
## Key Features |
|||
|
|||
| Feature | Description | Documentation | |
|||
|---------|-------------|---------------| |
|||
| **Attributes & Fluent API** | Define dynamic entities with C# attributes and configure programmatically | [Attributes & Fluent API](fluent-api.md) | |
|||
| **model.json** | Declarative dynamic entity definitions in JSON | [model.json Structure](model-json.md) | |
|||
| **Reference Entities** | Read-only access to existing C# entities (e.g., `IdentityUser`) for foreign key lookups | [Reference Entities](reference-entities.md) | |
|||
| **Interceptors** | Pre/Post hooks for Create, Update, Delete with JavaScript | [Interceptors](interceptors.md) | |
|||
| **Scripting API** | Server-side JavaScript for database queries and CRUD | [Scripting API](scripting-api.md) | |
|||
| **Custom Endpoints** | REST APIs with JavaScript handlers | [Custom Endpoints](custom-endpoints.md) | |
|||
| **Foreign Access** | View/Edit related dynamic entities from the target entity's UI | [Foreign Access](foreign-access.md) | |
|||
| **Export** | Export dynamic entity data to Excel (XLSX) or CSV | See below | |
|||
|
|||
## Export (Excel / CSV) |
|||
|
|||
The Low-Code System provides built-in export functionality for all dynamic entities. Users can export filtered data to **Excel (XLSX)** or **CSV** directly from the Blazor UI. |
|||
|
|||
### How It Works |
|||
|
|||
1. The client calls `GET /api/low-code/entities/{entityName}/download-token` to obtain a single-use download token (valid for 30 seconds). |
|||
2. The client calls `GET /api/low-code/entities/{entityName}/export-as-excel` or `GET /api/low-code/entities/{entityName}/export-as-csv` with the token and optional filters. |
|||
|
|||
### API Endpoints |
|||
|
|||
| Endpoint | Description | |
|||
|----------|-------------| |
|||
| `GET /api/low-code/entities/{entityName}/download-token` | Get a single-use download token | |
|||
| `GET /api/low-code/entities/{entityName}/export-as-excel` | Export as Excel (.xlsx) | |
|||
| `GET /api/low-code/entities/{entityName}/export-as-csv` | Export as CSV (.csv) | |
|||
|
|||
Export requests accept the same filtering, sorting, and search parameters as the list endpoint. Server-only properties are automatically excluded, and foreign key columns display the referenced entity's display value instead of the raw ID. |
|||
|
|||
## Custom Commands and Queries |
|||
|
|||
The Low-Code System allows you to replace or extend the default CRUD operations by implementing custom command and query handlers in C#. |
|||
|
|||
### Custom Commands |
|||
|
|||
Create a class that implements `ILcCommand<TResult>` and decorate it with `[CustomCommand]`: |
|||
|
|||
````csharp |
|||
[CustomCommand("Create", "MyApp.Products.Product")] |
|||
public class CustomProductCreateCommand : CreateCommand<Product> |
|||
{ |
|||
public override async Task<Guid> ExecuteWithResultAsync(DynamicCommandArgs commandArgs) |
|||
{ |
|||
// Your custom create logic here |
|||
// ... |
|||
} |
|||
} |
|||
```` |
|||
|
|||
| Parameter | Description | |
|||
|-----------|-------------| |
|||
| `commandName` | The command to replace: `"Create"`, `"Update"`, or `"Delete"` | |
|||
| `entityName` | Full entity name (e.g., `"MyApp.Products.Product"`) | |
|||
|
|||
### Custom Queries |
|||
|
|||
Create a class that implements `ILcQuery<TResult>` and decorate it with `[CustomQuery]`: |
|||
|
|||
````csharp |
|||
[CustomQuery("List", "MyApp.Products.Product")] |
|||
public class CustomProductListQuery : ILcQuery<DynamicQueryResult> |
|||
{ |
|||
public async Task<DynamicQueryResult> ExecuteAsync(DynamicQueryArgs queryArgs) |
|||
{ |
|||
// Your custom list query logic here |
|||
// ... |
|||
} |
|||
} |
|||
```` |
|||
|
|||
````csharp |
|||
[CustomQuery("Single", "MyApp.Products.Product")] |
|||
public class CustomProductListQuery : ILcQuery<DynamicEntityDto> |
|||
{ |
|||
public async Task<DynamicEntityDto> ExecuteAsync(DynamicQueryArgs queryArgs) |
|||
{ |
|||
// Your custom single query logic here |
|||
// ... |
|||
} |
|||
} |
|||
```` |
|||
|
|||
| Parameter | Description | |
|||
|-----------|-------------| |
|||
| `queryName` | The query to replace: `"List"` or `"Single"` | |
|||
| `entityName` | Full entity name (e.g., `"MyApp.Products.Product"`) | |
|||
|
|||
Custom commands and queries are automatically discovered and registered at startup. They completely replace the default handler for the specified entity and operation. |
|||
|
|||
## Internals |
|||
|
|||
### Domain Layer |
|||
|
|||
* `DynamicModelManager`: Singleton managing all entity metadata with a layered configuration architecture (Code > JSON > Fluent > Defaults). |
|||
* `EntityDescriptor`: Entity definition with properties, foreign keys, interceptors, and UI configuration. |
|||
* `EntityPropertyDescriptor`: Property definition with type, validation, UI settings, and foreign key info. |
|||
* `IDynamicEntityRepository`: Repository for dynamic entity CRUD operations. |
|||
|
|||
### Application Layer |
|||
|
|||
* `DynamicEntityAppService`: CRUD operations for all dynamic entities (Get, GetList, Create, Update, Delete, Export). |
|||
* `DynamicEntityUIAppService`: UI definitions, menu items, and page configurations. Provides: |
|||
* `GetUiDefinitionAsync(entityName)` — Full UI definition (filters, columns, forms, children, foreign access actions, permissions) |
|||
* `GetUiCreationFormDefinitionAsync(entityName)` — Creation form fields with validation rules |
|||
* `GetUiEditFormDefinitionAsync(entityName)` — Edit form fields with validation rules |
|||
* `GetMenuItemsAsync()` — Menu items for all entities that have a `pageTitle` configured (filtered by permissions) |
|||
* `DynamicPermissionDefinitionProvider`: Auto-generates permissions per entity. |
|||
* `CustomEndpointExecutor`: Executes JavaScript-based custom endpoints. |
|||
|
|||
### Database Providers |
|||
|
|||
**Entity Framework Core**: Dynamic entities are configured as EF Core [shared-type entities](https://learn.microsoft.com/en-us/ef/core/modeling/entity-types?tabs=fluent-api#shared-type-entity-types) via the `ConfigureDynamicEntities()` extension method. |
|||
|
|||
## See Also |
|||
|
|||
* [Attributes & Fluent API](fluent-api.md) |
|||
* [model.json Structure](model-json.md) |
|||
* [Scripting API](scripting-api.md) |
|||
@ -0,0 +1,249 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Add custom business logic to dynamic entity CRUD operations using Interceptors in the ABP Low-Code System. Validate, transform, and react to data changes with JavaScript." |
|||
} |
|||
``` |
|||
|
|||
# Interceptors |
|||
|
|||
Interceptors allow you to run custom JavaScript code before, after, or instead of Create, Update, and Delete operations on dynamic entities. |
|||
|
|||
## Interceptor Types |
|||
|
|||
| Command | Type | When Executed | |
|||
|---------|------|---------------| |
|||
| `Create` | `Pre` | Before entity creation — validation, default values | |
|||
| `Create` | `Post` | After entity creation — notifications, related data | |
|||
| `Create` | `Replace` | Instead of entity creation — **must return the new entity's Id** (see below) | |
|||
| `Update` | `Pre` | Before entity update — validation, authorization | |
|||
| `Update` | `Post` | After entity update — sync, notifications | |
|||
| `Update` | `Replace` | Instead of entity update — no return value needed | |
|||
| `Delete` | `Pre` | Before entity deletion — dependency checks | |
|||
| `Delete` | `Post` | After entity deletion — cleanup | |
|||
| `Delete` | `Replace` | Instead of entity deletion — no return value needed | |
|||
|
|||
## Defining Interceptors with Attributes |
|||
|
|||
Use the `[DynamicEntityCommandInterceptor]` attribute on a C# class: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
[DynamicEntityCommandInterceptor( |
|||
"Create", |
|||
InterceptorType.Pre, |
|||
"if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
|||
)] |
|||
[DynamicEntityCommandInterceptor( |
|||
"Create", |
|||
InterceptorType.Post, |
|||
"context.log('Entity created: ' + context.commandArgs.entityId);" |
|||
)] |
|||
public class Organization |
|||
{ |
|||
public string Name { get; set; } |
|||
} |
|||
```` |
|||
|
|||
The `Name` parameter must be one of: `"Create"`, `"Update"`, or `"Delete"`. The `InterceptorType` can be `Pre`, `Post`, or `Replace`. When `Replace` is used, the default database operation is completely skipped and only your JavaScript handler executes. Multiple interceptors can be added to the same class (`AllowMultiple = true`). |
|||
|
|||
## Defining Interceptors with Fluent API |
|||
|
|||
Use the `Interceptors` list on an `EntityDescriptor` to add interceptors programmatically in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer): |
|||
|
|||
````csharp |
|||
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
|||
"MyApp.Organizations.Organization", |
|||
entity => |
|||
{ |
|||
entity.Interceptors.Add(new CommandInterceptorDescriptor("Create") |
|||
{ |
|||
Type = InterceptorType.Pre, |
|||
Javascript = "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
|||
}); |
|||
|
|||
entity.Interceptors.Add(new CommandInterceptorDescriptor("Delete") |
|||
{ |
|||
Type = InterceptorType.Post, |
|||
Javascript = "context.log('Deleted: ' + context.commandArgs.entityId);" |
|||
}); |
|||
} |
|||
); |
|||
```` |
|||
|
|||
See [Attributes & Fluent API](fluent-api.md#adding-interceptors) for more details on Fluent API configuration. |
|||
|
|||
## Defining Interceptors in model.json |
|||
|
|||
Add interceptors to the `interceptors` array of an entity: |
|||
|
|||
```json |
|||
{ |
|||
"name": "LowCodeDemo.Customers.Customer", |
|||
"interceptors": [ |
|||
{ |
|||
"commandName": "Create", |
|||
"type": "Pre", |
|||
"javascript": "if(context.commandArgs.data['Name'] == 'Invalid') {\n globalError = 'Invalid Customer Name!';\n}" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
### Interceptor Descriptor |
|||
|
|||
| Field | Type | Description | |
|||
|-------|------|-------------| |
|||
| `commandName` | string | `"Create"`, `"Update"`, or `"Delete"` | |
|||
| `type` | string | `"Pre"`, `"Post"`, or `"Replace"` | |
|||
| `javascript` | string | JavaScript code to execute | |
|||
|
|||
## JavaScript Context |
|||
|
|||
Inside interceptor scripts, you have access to: |
|||
|
|||
### `context.commandArgs` |
|||
|
|||
| Property / Method | Type | Description | |
|||
|----------|------|-------------| |
|||
| `data` | object | Entity data dictionary (for Create/Update) | |
|||
| `entityId` | string | Entity ID (for Update/Delete) | |
|||
| `commandName` | string | Command name (`"Create"`, `"Update"`, or `"Delete"`) | |
|||
| `entityName` | string | Full entity name | |
|||
| `getValue(name)` | function | Get a property value | |
|||
| `setValue(name, value)` | function | Set a property value (Pre-interceptors only) | |
|||
| `hasValue(name)` | function | Check if a property exists in the data | |
|||
| `removeValue(name)` | function | Remove a property from the data | |
|||
|
|||
### `context.currentUser` |
|||
|
|||
| Property / Method | Type | Description | |
|||
|----------|------|-------------| |
|||
| `isAuthenticated` | bool | Whether user is logged in | |
|||
| `id` | string | User ID | |
|||
| `userName` | string | Username | |
|||
| `email` | string | Email address | |
|||
| `name` | string | First name | |
|||
| `surName` | string | Last name | |
|||
| `phoneNumber` | string | Phone number | |
|||
| `phoneNumberVerified` | bool | Whether phone is verified | |
|||
| `emailVerified` | bool | Whether email is verified | |
|||
| `tenantId` | string | Tenant ID (for multi-tenant apps) | |
|||
| `roles` | string[] | User's role names | |
|||
| `isInRole(roleName)` | function | Check if user has a specific role | |
|||
|
|||
### `context.emailSender` |
|||
|
|||
| Property / Method | Description | |
|||
|--------|-------------| |
|||
| `isAvailable` | Whether the email sender is configured and available | |
|||
| `sendAsync(to, subject, body)` | Send a plain-text email | |
|||
| `sendHtmlAsync(to, subject, htmlBody)` | Send an HTML email | |
|||
|
|||
### Logging |
|||
|
|||
| Method | Description | |
|||
|--------|-------------| |
|||
| `context.log(message)` | Log an informational message | |
|||
| `context.logWarning(message)` | Log a warning message | |
|||
| `context.logError(message)` | Log an error message | |
|||
|
|||
> Use these methods instead of `console.log` (which is blocked in the sandbox). |
|||
|
|||
### `db` (Database API) |
|||
|
|||
Full access to the [Scripting API](scripting-api.md) for querying and mutating data. |
|||
|
|||
### `globalError` |
|||
|
|||
Set this variable to a string to **abort** the operation and return an error: |
|||
|
|||
```javascript |
|||
globalError = 'Cannot delete this entity!'; |
|||
``` |
|||
|
|||
 |
|||
|
|||
## Examples |
|||
|
|||
### Pre-Create: Validation |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Create", |
|||
"type": "Pre", |
|||
"javascript": "if(!context.commandArgs.data['Name']) {\n globalError = 'Organization name is required!';\n}" |
|||
} |
|||
``` |
|||
|
|||
### Post-Create: Email Notification |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Create", |
|||
"type": "Post", |
|||
"javascript": "if(context.currentUser.isAuthenticated && context.emailSender) {\n await context.emailSender.sendAsync(\n context.currentUser.email,\n 'New Order Created',\n 'Order total: $' + context.commandArgs.data['TotalAmount']\n );\n}" |
|||
} |
|||
``` |
|||
|
|||
### Pre-Update: Role-Based Authorization |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Update", |
|||
"type": "Pre", |
|||
"javascript": "if(context.commandArgs.data['IsDelivered']) {\n if(!context.currentUser.roles.includes('admin')) {\n globalError = 'Only administrators can mark orders as delivered!';\n }\n}" |
|||
} |
|||
``` |
|||
|
|||
### Pre-Delete: Business Rule Check |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Delete", |
|||
"type": "Pre", |
|||
"javascript": "var project = await db.get('LowCodeDemo.Projects.Project', context.commandArgs.entityId);\nif(project.Budget > 100000) {\n globalError = 'Cannot delete high-budget projects!';\n}" |
|||
} |
|||
``` |
|||
|
|||
### Pre-Update: Negative Value Check |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Update", |
|||
"type": "Pre", |
|||
"javascript": "if(context.commandArgs.data['Quantity'] < 0) {\n globalError = 'Quantity cannot be negative!';\n}" |
|||
} |
|||
``` |
|||
|
|||
### Replace-Create: Custom Insert Logic |
|||
|
|||
When you need to completely replace the default create operation with custom logic: |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Create", |
|||
"type": "Replace", |
|||
"javascript": "var data = context.commandArgs.data;\ndata['Code'] = 'PRD-' + Date.now();\nvar result = await db.insert('LowCodeDemo.Products.Product', data);\ncontext.log('Product created with custom code: ' + data['Code']);\nreturn result.Id;" |
|||
} |
|||
``` |
|||
|
|||
> **Important:** `Replace-Create` interceptors **must** return the new entity's `Id` (Guid). The system uses this value to fetch and return the created entity. Use `return result.Id;` after `db.insert(...)`. |
|||
> |
|||
> `Replace-Update` and `Replace-Delete` interceptors do not need to return a value. |
|||
|
|||
### Pre-Update: Self-Reference Check |
|||
|
|||
```json |
|||
{ |
|||
"commandName": "Update", |
|||
"type": "Pre", |
|||
"javascript": "if(context.commandArgs.data.ParentCategoryId === context.commandArgs.entityId) {\n globalError = 'A category cannot be its own parent!';\n}" |
|||
} |
|||
``` |
|||
|
|||
## See Also |
|||
|
|||
* [Scripting API](scripting-api.md) |
|||
* [model.json Structure](model-json.md) |
|||
* [Custom Endpoints](custom-endpoints.md) |
|||
@ -0,0 +1,422 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Define dynamic entities using model.json in the ABP Low-Code System. Learn about entity properties, enums, foreign keys, validators, UI configuration, and migration requirements." |
|||
} |
|||
``` |
|||
|
|||
# model.json Structure |
|||
|
|||
The `model.json` file defines all your dynamic entities, their properties, enums, relationships, interceptors, custom endpoints, and UI configurations. It is an alternative configuration source to [C# Attributes and Fluent API](fluent-api.md), ideal when you prefer a declarative JSON approach. |
|||
|
|||
## File Location |
|||
|
|||
Place your `model.json` in a `_Dynamic` folder inside your **Domain** project: |
|||
|
|||
``` |
|||
YourApp.Domain/ |
|||
└── _Dynamic/ |
|||
└── model.json |
|||
``` |
|||
|
|||
The module automatically discovers and loads this file at application startup. |
|||
|
|||
> A JSON Schema file (`model.schema.json`) is available in the module source for IDE IntelliSense. Reference it using the `$schema` property: |
|||
|
|||
```json |
|||
{ |
|||
"$schema": "path/to/model.schema.json", |
|||
"entities": [] |
|||
} |
|||
``` |
|||
|
|||
## Top-Level Structure |
|||
|
|||
The `model.json` file has three root sections: |
|||
|
|||
```json |
|||
{ |
|||
"$schema": "...", |
|||
"enums": [], |
|||
"entities": [], |
|||
"endpoints": [] |
|||
} |
|||
``` |
|||
|
|||
| Section | Description | |
|||
|---------|-------------| |
|||
| `enums` | Enum type definitions | |
|||
| `entities` | Entity definitions with properties, foreign keys, interceptors, and UI | |
|||
| `endpoints` | Custom REST API endpoints with JavaScript handlers | |
|||
|
|||
## Enum Definitions |
|||
|
|||
Define enums that can be used as property types: |
|||
|
|||
```json |
|||
{ |
|||
"enums": [ |
|||
{ |
|||
"name": "LowCodeDemo.Organizations.OrganizationType", |
|||
"values": [ |
|||
{ "name": "Corporate", "value": 0 }, |
|||
{ "name": "Enterprise", "value": 1 }, |
|||
{ "name": "Startup", "value": 2 }, |
|||
{ "name": "Consulting", "value": 3 } |
|||
] |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
Reference enums in entity properties using the `enumType` field: |
|||
|
|||
```json |
|||
{ |
|||
"name": "OrganizationType", |
|||
"enumType": "LowCodeDemo.Organizations.OrganizationType" |
|||
} |
|||
``` |
|||
|
|||
## Entity Definition |
|||
|
|||
Each entity has the following structure: |
|||
|
|||
```json |
|||
{ |
|||
"name": "LowCodeDemo.Products.Product", |
|||
"displayProperty": "Name", |
|||
"parent": null, |
|||
"properties": [], |
|||
"interceptors": [], |
|||
"ui": {} |
|||
} |
|||
``` |
|||
|
|||
### Entity Attributes |
|||
|
|||
| Attribute | Type | Description | |
|||
|-----------|------|-------------| |
|||
| `name` | string | **Required.** Full entity name with namespace (e.g., `"MyApp.Products.Product"`) | |
|||
| `displayProperty` | string | Property to display in lookups and foreign key dropdowns | |
|||
| `parent` | string | Parent entity name for parent-child (master-detail) relationships | |
|||
| `properties` | array | Property definitions | |
|||
| `interceptors` | array | CRUD lifecycle interceptors | |
|||
| `ui` | object | UI configuration | |
|||
|
|||
### Parent-Child Relationships |
|||
|
|||
Use the `parent` field to create nested entities. Children are managed through the parent entity's UI: |
|||
|
|||
```json |
|||
{ |
|||
"name": "LowCodeDemo.Orders.OrderLine", |
|||
"parent": "LowCodeDemo.Orders.Order", |
|||
"properties": [ |
|||
{ |
|||
"name": "ProductId", |
|||
"foreignKey": { |
|||
"entityName": "LowCodeDemo.Products.Product" |
|||
} |
|||
}, |
|||
{ "name": "Quantity", "type": "int" }, |
|||
{ "name": "Amount", "type": "decimal" } |
|||
] |
|||
} |
|||
``` |
|||
|
|||
Multi-level nesting is supported (e.g., `Order > OrderLine > ShipmentItem > ShipmentTracking`). |
|||
|
|||
## Property Definition |
|||
|
|||
```json |
|||
{ |
|||
"name": "Price", |
|||
"type": "decimal", |
|||
"isRequired": true, |
|||
"isUnique": false, |
|||
"isMappedToDbField": true, |
|||
"serverOnly": false, |
|||
"allowSetByClients": true, |
|||
"enumType": null, |
|||
"foreignKey": null, |
|||
"validators": [], |
|||
"ui": {} |
|||
} |
|||
``` |
|||
|
|||
### Property Types |
|||
|
|||
| Type | Description | |
|||
|------|-------------| |
|||
| `string` | Text (default if type is omitted) | |
|||
| `int` | 32-bit integer | |
|||
| `long` | 64-bit integer | |
|||
| `decimal` | Decimal number | |
|||
| `DateTime` | Date and time | |
|||
| `boolean` | True/false | |
|||
| `Guid` | GUID/UUID | |
|||
| `Enum` | Enum type (requires `enumType` field) | |
|||
|
|||
### Property Flags |
|||
|
|||
| Flag | Type | Default | Description | |
|||
|------|------|---------|-------------| |
|||
| `isRequired` | bool | `false` | Property must have a value | |
|||
| `isUnique` | bool | `false` | Value must be unique across all records | |
|||
| `isMappedToDbField` | bool | `true` | Property is stored in the database | |
|||
| `serverOnly` | bool | `false` | Property is hidden from API clients | |
|||
| `allowSetByClients` | bool | `true` | Whether clients can set this value | |
|||
|
|||
### Foreign Key Properties |
|||
|
|||
Define a foreign key relationship inline on a property: |
|||
|
|||
```json |
|||
{ |
|||
"name": "CustomerId", |
|||
"foreignKey": { |
|||
"entityName": "LowCodeDemo.Customers.Customer", |
|||
"displayPropertyName": "Name", |
|||
"access": "edit" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
| Attribute | Description | |
|||
|-----------|-------------| |
|||
| `entityName` | **Required.** Full name of the target entity — can be a **dynamic entity** (e.g., `"LowCodeDemo.Customers.Customer"`) or a **[reference entity](reference-entities.md)** (e.g., `"Volo.Abp.Identity.IdentityUser"`) | |
|||
| `displayPropertyName` | Property to display in lookups (defaults to entity's `displayProperty`) | |
|||
| `access` | [Foreign access](foreign-access.md) level: `"none"`, `"view"`, or `"edit"` | |
|||
|
|||
> **Note:** [Reference entities](reference-entities.md) are existing C# entities (like ABP's `IdentityUser`) that are registered for read-only access. Unlike dynamic entities, they don't get CRUD pages — they're used only for foreign key lookups and display values. |
|||
|
|||
### Validators |
|||
|
|||
Add validation rules to properties: |
|||
|
|||
```json |
|||
{ |
|||
"name": "EmailAddress", |
|||
"validators": [ |
|||
{ "type": "required" }, |
|||
{ "type": "emailAddress" }, |
|||
{ "type": "minLength", "length": 5 }, |
|||
{ "type": "maxLength", "length": 255 } |
|||
] |
|||
} |
|||
``` |
|||
|
|||
Additional validator examples: |
|||
|
|||
```json |
|||
{ |
|||
"name": "Website", |
|||
"validators": [ |
|||
{ "type": "url", "message": "Please enter a valid URL" } |
|||
] |
|||
}, |
|||
{ |
|||
"name": "PhoneNumber", |
|||
"validators": [ |
|||
{ "type": "phone" } |
|||
] |
|||
}, |
|||
{ |
|||
"name": "ProductCode", |
|||
"validators": [ |
|||
{ "type": "regularExpression", "pattern": "^[A-Z]{3}-\\d{4}$", "message": "Code must be in format ABC-1234" } |
|||
] |
|||
}, |
|||
{ |
|||
"name": "Price", |
|||
"type": "decimal", |
|||
"validators": [ |
|||
{ "type": "range", "minimum": 0.01, "maximum": 99999.99 } |
|||
] |
|||
} |
|||
``` |
|||
|
|||
| Validator | Parameters | Applies To | Description | |
|||
|-----------|------------|------------|-------------| |
|||
| `required` | `allowEmptyStrings` (optional) | All types | Value is required | |
|||
| `minLength` | `length` | String | Minimum string length | |
|||
| `maxLength` | `length` | String | Maximum string length | |
|||
| `stringLength` | `minimumLength`, `maximumLength` | String | String length range (min and max together) | |
|||
| `emailAddress` | — | String | Must be a valid email | |
|||
| `phone` | — | String | Must be a valid phone number | |
|||
| `url` | — | String | Must be a valid URL | |
|||
| `creditCard` | — | String | Must be a valid credit card number | |
|||
| `regularExpression` | `pattern` | String | Must match the regex pattern | |
|||
| `range` | `minimum`, `maximum` | Numeric | Numeric range | |
|||
| `min` | `minimum` | Numeric | Minimum numeric value | |
|||
| `max` | `maximum` | Numeric | Maximum numeric value | |
|||
|
|||
> All validators accept an optional `message` parameter for a custom error message. The `regularExpression` validator also accepts the alias `pattern`, and `emailAddress` also accepts `email`. |
|||
|
|||
## UI Configuration |
|||
|
|||
### Entity-Level UI |
|||
|
|||
```json |
|||
{ |
|||
"ui": { |
|||
"pageTitle": "Products" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
> Only entities with `ui.pageTitle` get a menu item and a dedicated page in the UI. |
|||
|
|||
### Property-Level UI |
|||
|
|||
```json |
|||
{ |
|||
"name": "RegistrationNumber", |
|||
"ui": { |
|||
"displayName": "Registration Number", |
|||
"isAvailableOnDataTable": true, |
|||
"isAvailableOnDataTableFiltering": true, |
|||
"creationFormAvailability": "Hidden", |
|||
"editingFormAvailability": "NotAvailable", |
|||
"quickLookOrder": 100 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
| Attribute | Type | Default | Description | |
|||
|-----------|------|---------|-------------| |
|||
| `displayName` | string | Property name | Display label in UI | |
|||
| `isAvailableOnDataTable` | bool | `true` | Show in data grid | |
|||
| `isAvailableOnDataTableFiltering` | bool | `true` | Show in filter panel | |
|||
| `creationFormAvailability` | string | `"Available"` | Visibility in create form | |
|||
| `editingFormAvailability` | string | `"Available"` | Visibility in edit form | |
|||
| `quickLookOrder` | int | -2 | Order in quick-look panel (-2 = not shown) | |
|||
|
|||
#### Form Availability Values |
|||
|
|||
| Value | Description | |
|||
|-------|-------------| |
|||
| `Available` | Visible and editable | |
|||
| `Hidden` | Not visible in the form | |
|||
| `NotAvailable` | Visible but disabled/read-only | |
|||
|
|||
## Interceptors |
|||
|
|||
Define JavaScript interceptors for CRUD lifecycle hooks: |
|||
|
|||
```json |
|||
{ |
|||
"interceptors": [ |
|||
{ |
|||
"commandName": "Create", |
|||
"type": "Pre", |
|||
"javascript": "if(!context.commandArgs.data['Name']) { globalError = 'Name is required!'; }" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
See [Interceptors](interceptors.md) for details. |
|||
|
|||
## Endpoints |
|||
|
|||
Define custom REST endpoints with JavaScript handlers: |
|||
|
|||
```json |
|||
{ |
|||
"endpoints": [ |
|||
{ |
|||
"name": "GetProductStats", |
|||
"route": "/api/custom/products/stats", |
|||
"method": "GET", |
|||
"requireAuthentication": false, |
|||
"javascript": "var count = await db.count('Products.Product'); return ok({ total: count });" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
See [Custom Endpoints](custom-endpoints.md) for details. |
|||
|
|||
## Complete Example |
|||
|
|||
```json |
|||
{ |
|||
"enums": [ |
|||
{ |
|||
"name": "ShipmentStatus", |
|||
"values": [ |
|||
{ "name": "Pending", "value": 0 }, |
|||
{ "name": "Shipped", "value": 2 }, |
|||
{ "name": "Delivered", "value": 4 } |
|||
] |
|||
} |
|||
], |
|||
"entities": [ |
|||
{ |
|||
"name": "LowCodeDemo.Products.Product", |
|||
"displayProperty": "Name", |
|||
"properties": [ |
|||
{ "name": "Name", "isUnique": true, "isRequired": true }, |
|||
{ "name": "Price", "type": "decimal" }, |
|||
{ "name": "StockCount", "type": "int" }, |
|||
{ "name": "ReleaseDate", "type": "DateTime" } |
|||
], |
|||
"ui": { "pageTitle": "Products" } |
|||
}, |
|||
{ |
|||
"name": "LowCodeDemo.Orders.Order", |
|||
"displayProperty": "Id", |
|||
"properties": [ |
|||
{ |
|||
"name": "CustomerId", |
|||
"foreignKey": { |
|||
"entityName": "LowCodeDemo.Customers.Customer", |
|||
"access": "edit" |
|||
} |
|||
}, |
|||
{ "name": "TotalAmount", "type": "decimal" }, |
|||
{ "name": "IsDelivered", "type": "boolean" } |
|||
], |
|||
"interceptors": [ |
|||
{ |
|||
"commandName": "Create", |
|||
"type": "Post", |
|||
"javascript": "context.log('Order created: ' + context.commandArgs.entityId);" |
|||
} |
|||
], |
|||
"ui": { "pageTitle": "Orders" } |
|||
}, |
|||
{ |
|||
"name": "LowCodeDemo.Orders.OrderLine", |
|||
"parent": "LowCodeDemo.Orders.Order", |
|||
"properties": [ |
|||
{ |
|||
"name": "ProductId", |
|||
"foreignKey": { "entityName": "LowCodeDemo.Products.Product" } |
|||
}, |
|||
{ "name": "Quantity", "type": "int" }, |
|||
{ "name": "Amount", "type": "decimal" } |
|||
] |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
## Migration Requirements |
|||
|
|||
When you modify `model.json`, you need database migrations for the changes to take effect: |
|||
|
|||
* **New entity**: `dotnet ef migrations add Added_{EntityName}` |
|||
* **New property**: `dotnet ef migrations add Added_{PropertyName}_To_{EntityName}` |
|||
* **Type change**: `dotnet ef migrations add Changed_{PropertyName}_In_{EntityName}` |
|||
|
|||
> The same migration requirement applies when using [C# Attributes](fluent-api.md). Any change to entity structure requires an EF Core migration. |
|||
|
|||
## See Also |
|||
|
|||
* [Attributes & Fluent API](fluent-api.md) |
|||
* [Interceptors](interceptors.md) |
|||
* [Custom Endpoints](custom-endpoints.md) |
|||
* [Scripting API](scripting-api.md) |
|||
@ -0,0 +1,147 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Link dynamic entities to existing C# entities like IdentityUser using Reference Entities in the ABP Low-Code System." |
|||
} |
|||
``` |
|||
|
|||
# Reference Entities |
|||
|
|||
Reference Entities allow you to create foreign key relationships from **dynamic entities** to **existing C# entities** that live outside the Low-Code System. |
|||
|
|||
## Dynamic Entities vs Reference Entities |
|||
|
|||
| | Dynamic Entities | Reference Entities | |
|||
|---|-----------------|-------------------| |
|||
| **Definition** | Defined via `[DynamicEntity]` attribute or `model.json` | Existing C# classes (e.g., `IdentityUser`, `Tenant`) | |
|||
| **CRUD Operations** | Full CRUD (Create, Read, Update, Delete) | **Read-only** — no create/update/delete | |
|||
| **UI Pages** | Auto-generated pages with data grids and forms | No UI pages | |
|||
| **Permissions** | Auto-generated permissions | No permissions | |
|||
| **Purpose** | Primary data management | Foreign key lookups and display values | |
|||
| **Registration** | `AbpDynamicEntityConfig.SourceAssemblies` | `AbpDynamicEntityConfig.ReferencedEntityList` | |
|||
|
|||
## Overview |
|||
|
|||
Dynamic entities defined via [Attributes](fluent-api.md) or [model.json](model-json.md) can reference **other dynamic entities** using foreign keys. However, you may also need to link to entities that exist **outside** the Low-Code System — such as ABP's `IdentityUser`, `Tenant`, or your own C# entity classes. |
|||
|
|||
**Reference entities** make this possible by exposing existing entities for: |
|||
|
|||
* **Foreign key lookups** — dropdown selection in UI forms |
|||
* **Display values** — showing the entity's display property in grids instead of raw GUIDs |
|||
* **Read-only queries** — querying via the [Scripting API](scripting-api.md) |
|||
|
|||
> **Key distinction:** When you define a foreign key with `entityName`, the system checks if it's a registered **reference entity** first. If not found, it assumes it's a **dynamic entity**. |
|||
|
|||
## Registering Reference Entities |
|||
|
|||
Register reference entities in your [Low-Code Initializer](index.md#1-create-a-low-code-initializer) using `AbpDynamicEntityConfig.ReferencedEntityList`: |
|||
|
|||
````csharp |
|||
public static async Task InitializeAsync() |
|||
{ |
|||
await Runner.RunAsync(async () => |
|||
{ |
|||
// Register reference entity with default display property only |
|||
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>( |
|||
"UserName" |
|||
); |
|||
|
|||
// Register reference entity with additional exposed properties |
|||
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>( |
|||
"UserName", // Default display property |
|||
"UserName", // Exposed properties (for queries and display) |
|||
"Email", |
|||
"PhoneNumber" |
|||
); |
|||
|
|||
// ... rest of initialization |
|||
await DynamicModelManager.Instance.InitializeAsync(); |
|||
}); |
|||
} |
|||
```` |
|||
|
|||
### `Add<TEntity>` Method |
|||
|
|||
````csharp |
|||
public void Add<TEntity>( |
|||
string defaultDisplayProperty, |
|||
params string[] properties |
|||
) where TEntity : class, IEntity<Guid> |
|||
```` |
|||
|
|||
| Parameter | Description | |
|||
|-----------|-------------| |
|||
| `defaultDisplayProperty` | Property name used as display value in lookups | |
|||
| `properties` | Additional properties to expose (optional) | |
|||
|
|||
> The entity type must implement `IEntity<Guid>`. |
|||
|
|||
## Using Reference Entities in model.json |
|||
|
|||
Reference a registered entity in a foreign key definition: |
|||
|
|||
```json |
|||
{ |
|||
"name": "UserId", |
|||
"foreignKey": { |
|||
"entityName": "Volo.Abp.Identity.IdentityUser" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
The entity name must match the CLR type's full name. The module automatically detects that this is a reference entity and uses the registered `ReferenceEntityDescriptor`. |
|||
|
|||
## Using Reference Entities with Attributes |
|||
|
|||
Use the `[DynamicForeignKey]` attribute on a Guid property: |
|||
|
|||
````csharp |
|||
[DynamicEntity] |
|||
public class Customer |
|||
{ |
|||
[DynamicForeignKey("Volo.Abp.Identity.IdentityUser", "UserName")] |
|||
public Guid? UserId { get; set; } |
|||
} |
|||
```` |
|||
|
|||
## How It Works |
|||
|
|||
The `ReferenceEntityDescriptor` class stores metadata about the reference entity: |
|||
|
|||
* `Name` — Full CLR type name |
|||
* `Type` — The actual CLR type |
|||
* `DefaultDisplayPropertyName` — Display property for lookups |
|||
* `Properties` — List of `ReferenceEntityPropertyDescriptor` entries |
|||
|
|||
When a foreign key points to a reference entity, the `ForeignKeyDescriptor` populates its `ReferencedEntityDescriptor` and `ReferencedDisplayPropertyDescriptor` instead of the standard `EntityDescriptor` fields. |
|||
|
|||
## Querying Reference Entities in Scripts |
|||
|
|||
Reference entities can be queried via the [Scripting API](scripting-api.md): |
|||
|
|||
```javascript |
|||
// Query reference entity in interceptor or custom endpoint |
|||
var user = await db.get('Volo.Abp.Identity.IdentityUser', userId); |
|||
if (user) { |
|||
context.log('User: ' + user.UserName); |
|||
} |
|||
``` |
|||
|
|||
## Limitations |
|||
|
|||
* **Read-only**: Reference entities do not get CRUD operations, permissions, or UI pages. |
|||
* **No child entities**: You cannot define a reference entity as a parent in parent-child relationships. |
|||
* **Guid keys only**: Reference entities must have `Guid` primary keys (`IEntity<Guid>`). |
|||
* **Explicit registration required**: Each reference entity must be registered in code before use. |
|||
|
|||
## Common Reference Entities |
|||
|
|||
| Entity | Name for `entityName` | Typical Display Property | |
|||
|--------|----------------------|--------------------------| |
|||
| ABP Identity User | `Volo.Abp.Identity.IdentityUser` | `UserName` | |
|||
|
|||
## See Also |
|||
|
|||
* [model.json Structure](model-json.md) |
|||
* [Foreign Access](foreign-access.md) |
|||
* [Attributes & Fluent API](fluent-api.md) |
|||
@ -0,0 +1,455 @@ |
|||
```json |
|||
//[doc-seo] |
|||
{ |
|||
"Description": "Server-side JavaScript Scripting API for ABP Low-Code System. Query, filter, aggregate data and perform CRUD operations with database-level execution." |
|||
} |
|||
``` |
|||
|
|||
# Scripting API |
|||
|
|||
The Low-Code System provides a server-side JavaScript scripting engine for executing custom business logic within [interceptors](interceptors.md) and [custom endpoints](custom-endpoints.md). Scripts run in a sandboxed environment with access to a database API backed by EF Core. |
|||
|
|||
## Unified Database API (`db`) |
|||
|
|||
The `db` object is the main entry point for all data operations. |
|||
|
|||
### Key Design Principles |
|||
|
|||
* **Immutable Query Builder** — each query method returns a new builder instance. The original is never modified. |
|||
* **Database-Level Execution** — all operations (filters, aggregations, joins, set operations) translate to SQL via EF Core and Dynamic LINQ. |
|||
* **No in-memory processing** of large datasets. |
|||
|
|||
```javascript |
|||
// Immutable pattern — each call creates a new builder |
|||
var baseQuery = db.query('Entity').where(x => x.Active); |
|||
var cheap = baseQuery.where(x => x.Price < 100); // baseQuery unchanged |
|||
var expensive = baseQuery.where(x => x.Price > 500); // baseQuery unchanged |
|||
``` |
|||
|
|||
## Query API |
|||
|
|||
### Basic Queries |
|||
|
|||
```javascript |
|||
var products = await db.query('LowCodeDemo.Products.Product') |
|||
.where(x => x.Price > 100) |
|||
.orderBy(x => x.Price) |
|||
.take(10) |
|||
.toList(); |
|||
|
|||
var result = await db.query('LowCodeDemo.Products.Product') |
|||
.where(x => x.Price > 100 && x.Price < 500) |
|||
.where(x => x.StockCount > 0) |
|||
.orderByDescending(x => x.Price) |
|||
.skip(10) |
|||
.take(20) |
|||
.toList(); |
|||
``` |
|||
|
|||
### Query Methods |
|||
|
|||
| Method | Description | Returns | |
|||
|--------|-------------|---------| |
|||
| `where(x => condition)` | Filter results | `QueryBuilder` | |
|||
| `orderBy(x => x.Property)` | Sort ascending | `QueryBuilder` | |
|||
| `orderByDescending(x => x.Property)` | Sort descending | `QueryBuilder` | |
|||
| `thenBy(x => x.Property)` | Secondary sort ascending | `QueryBuilder` | |
|||
| `thenByDescending(x => x.Property)` | Secondary sort descending | `QueryBuilder` | |
|||
| `skip(n)` | Skip n records | `QueryBuilder` | |
|||
| `take(n)` | Take n records | `QueryBuilder` | |
|||
| `toList()` | Execute and return array | `Promise<object[]>` | |
|||
| `count()` | Return count | `Promise<number>` | |
|||
| `any()` | Check if any matches exist | `Promise<boolean>` | |
|||
| `all(x => condition)` | Check if all records match | `Promise<boolean>` | |
|||
| `isEmpty()` | Check if no results | `Promise<boolean>` | |
|||
| `isSingle()` | Check if exactly one result | `Promise<boolean>` | |
|||
| `first()` / `firstOrDefault()` | Return first match or null | `Promise<object\|null>` | |
|||
| `last()` / `lastOrDefault()` | Return last match or null | `Promise<object\|null>` | |
|||
| `single()` / `singleOrDefault()` | Return single match or null | `Promise<object\|null>` | |
|||
| `elementAt(index)` | Return element at index | `Promise<object\|null>` | |
|||
| `select(x => projection)` | Project to custom shape | `QueryBuilder` | |
|||
| `join(entity, alias, condition)` | Inner join | `QueryBuilder` | |
|||
| `leftJoin(entity, alias, condition)` | Left join | `QueryBuilder` | |
|||
|
|||
### Supported Operators in Lambda |
|||
|
|||
| Category | Operators | |
|||
|----------|-----------| |
|||
| Comparison | `===`, `!==`, `>`, `>=`, `<`, `<=` | |
|||
| Logical | `&&`, `\|\|`, `!` | |
|||
| Arithmetic | `+`, `-`, `*`, `/`, `%` | |
|||
| String | `startsWith()`, `endsWith()`, `includes()`, `trim()`, `toLowerCase()`, `toUpperCase()` | |
|||
| Array | `array.includes(x.Property)` — translates to SQL `IN` | |
|||
| Math | `Math.round()`, `Math.floor()`, `Math.ceil()`, `Math.abs()`, `Math.sqrt()`, `Math.pow()`, `Math.sign()`, `Math.truncate()` | |
|||
| Null | `!= null`, `=== null` | |
|||
|
|||
### Variable Capture |
|||
|
|||
External variables are captured and passed as parameters: |
|||
|
|||
```javascript |
|||
var minPrice = 100; |
|||
var config = { minStock: 10 }; |
|||
var nested = { range: { min: 50, max: 200 } }; |
|||
|
|||
var result = await db.query('Entity').where(x => x.Price > minPrice).toList(); |
|||
var result2 = await db.query('Entity').where(x => x.StockCount > config.minStock).toList(); |
|||
var result3 = await db.query('Entity').where(x => x.Price >= nested.range.min).toList(); |
|||
``` |
|||
|
|||
### Contains / IN Operator |
|||
|
|||
```javascript |
|||
var targetPrices = [50, 100, 200]; |
|||
var products = await db.query('Entity') |
|||
.where(x => targetPrices.includes(x.Price)) |
|||
.toList(); |
|||
``` |
|||
|
|||
### Select Projection |
|||
|
|||
```javascript |
|||
var projected = await db.query('LowCodeDemo.Products.Product') |
|||
.where(x => x.Price > 0) |
|||
.select(x => ({ ProductName: x.Name, ProductPrice: x.Price })) |
|||
.toList(); |
|||
``` |
|||
|
|||
## Joins |
|||
|
|||
### Explicit Joins |
|||
|
|||
```javascript |
|||
var orderLines = await db.query('LowCodeDemo.Orders.OrderLine') |
|||
.join('LowCodeDemo.Products.Product', 'p', (ol, p) => ol.ProductId === p.Id) |
|||
.take(10) |
|||
.toList(); |
|||
|
|||
// Access joined data via alias |
|||
orderLines.forEach(line => { |
|||
var product = line.p; |
|||
context.log(product.Name + ': $' + line.Amount); |
|||
}); |
|||
``` |
|||
|
|||
### Left Join |
|||
|
|||
```javascript |
|||
var orders = await db.query('LowCodeDemo.Orders.Order') |
|||
.leftJoin('LowCodeDemo.Products.Product', 'p', (o, p) => o.CustomerId === p.Id) |
|||
.toList(); |
|||
|
|||
orders.forEach(order => { |
|||
if (order.p) { |
|||
context.log('Has match: ' + order.p.Name); |
|||
} |
|||
}); |
|||
``` |
|||
|
|||
### LINQ-Style Join |
|||
|
|||
```javascript |
|||
db.query('Order') |
|||
.join('LowCodeDemo.Products.Product', |
|||
o => o.ProductId, |
|||
p => p.Id) |
|||
``` |
|||
|
|||
### Join with Filtered Query |
|||
|
|||
```javascript |
|||
var expensiveProducts = db.query('Product').where(p => p.Price > 100); |
|||
|
|||
var orders = await db.query('OrderLine') |
|||
.join(expensiveProducts, |
|||
ol => ol.ProductId, |
|||
p => p.Id) |
|||
.toList(); |
|||
``` |
|||
|
|||
## Set Operations |
|||
|
|||
Set operations execute at the database level using SQL: |
|||
|
|||
| Method | SQL Equivalent | Description | |
|||
|--------|---------------|-------------| |
|||
| `union(query)` | `UNION` | Combine, remove duplicates | |
|||
| `concat(query)` | `UNION ALL` | Combine, keep duplicates | |
|||
| `intersect(query)` | `INTERSECT` | Elements in both | |
|||
| `except(query)` | `EXCEPT` | Elements in first, not second | |
|||
|
|||
```javascript |
|||
var cheap = db.query('Product').where(x => x.Price <= 100); |
|||
var popular = db.query('Product').where(x => x.Rating > 4); |
|||
|
|||
var bestDeals = await cheap.intersect(popular).toList(); |
|||
var underrated = await cheap.except(popular).toList(); |
|||
``` |
|||
|
|||
## Aggregation Methods |
|||
|
|||
All aggregations execute as SQL statements: |
|||
|
|||
| Method | SQL | Returns | |
|||
|--------|-----|---------| |
|||
| `sum(x => x.Property)` | `SELECT SUM(...)` | `Promise<number>` | |
|||
| `average(x => x.Property)` | `SELECT AVG(...)` | `Promise<number>` | |
|||
| `min(x => x.Property)` | `SELECT MIN(...)` | `Promise<any>` | |
|||
| `max(x => x.Property)` | `SELECT MAX(...)` | `Promise<any>` | |
|||
| `distinct(x => x.Property)` | `SELECT DISTINCT ...` | `Promise<any[]>` | |
|||
| `groupBy(x => x.Property)` | `GROUP BY ...` | `Promise<GroupResult[]>` | |
|||
|
|||
```javascript |
|||
var totalValue = await db.query('Product').sum(x => x.Price); |
|||
var avgPrice = await db.query('Product').where(x => x.InStock).average(x => x.Price); |
|||
var cheapest = await db.query('Product').min(x => x.Price); |
|||
``` |
|||
|
|||
### GroupBy with Select |
|||
|
|||
```javascript |
|||
var grouped = await db.query('Product') |
|||
.groupBy(x => x.Category) |
|||
.select(g => ({ |
|||
Category: g.Key, |
|||
Count: g.count(), |
|||
TotalPrice: g.sum(x => x.Price), |
|||
AvgPrice: g.average(x => x.Price), |
|||
MinPrice: g.min(x => x.Price), |
|||
MaxPrice: g.max(x => x.Price) |
|||
})) |
|||
.toList(); |
|||
``` |
|||
|
|||
### GroupBy Aggregation Methods |
|||
|
|||
| Method | SQL | |
|||
|--------|-----| |
|||
| `g.Key` | Group key value | |
|||
| `g.count()` | `COUNT(*)` | |
|||
| `g.sum(x => x.Prop)` | `SUM(prop)` | |
|||
| `g.average(x => x.Prop)` | `AVG(prop)` | |
|||
| `g.min(x => x.Prop)` | `MIN(prop)` | |
|||
| `g.max(x => x.Prop)` | `MAX(prop)` | |
|||
| `g.toList()` | Get group items | |
|||
| `g.take(n).toList()` | Get first n items | |
|||
|
|||
### GroupBy with Items |
|||
|
|||
```javascript |
|||
var grouped = await db.query('Product') |
|||
.groupBy(x => x.Category) |
|||
.select(g => ({ |
|||
Category: g.Key, |
|||
Count: g.count(), |
|||
Items: g.take(10).toList() |
|||
})) |
|||
.toList(); |
|||
``` |
|||
|
|||
### GroupBy Security Limits |
|||
|
|||
| Limit | Default | Description | |
|||
|-------|---------|-------------| |
|||
| `MaxGroupCount` | No limit | Maximum groups | |
|||
|
|||
## Math Functions |
|||
|
|||
Math functions translate to SQL functions (ROUND, FLOOR, CEILING, ABS, etc.): |
|||
|
|||
```javascript |
|||
var products = await db.query('Product') |
|||
.where(x => Math.round(x.Price) > 100) |
|||
.toList(); |
|||
|
|||
var result = await db.query('Product') |
|||
.where(x => Math.abs(x.Balance) < 10 && Math.floor(x.Rating) >= 4) |
|||
.toList(); |
|||
``` |
|||
|
|||
## CRUD API |
|||
|
|||
Direct CRUD methods on the `db` object: |
|||
|
|||
| Method | Description | Returns | |
|||
|--------|-------------|---------| |
|||
| `db.get(entityName, id)` | Get by ID | `Promise<object\|null>` | |
|||
| `db.getCount(entityName)` | Get count | `Promise<number>` | |
|||
| `db.exists(entityName)` | Check if any records exist | `Promise<boolean>` | |
|||
| `db.insert(entityName, entity)` | Insert new | `Promise<object>` | |
|||
| `db.update(entityName, entity)` | Update existing | `Promise<object>` | |
|||
| `db.delete(entityName, id)` | Delete by ID | `Promise<void>` | |
|||
|
|||
> **Note:** The `entityName` parameter can be either a **dynamic entity** (e.g., `"LowCodeDemo.Products.Product"`) or a **[reference entity](reference-entities.md)** (e.g., `"Volo.Abp.Identity.IdentityUser"`). However, `insert`, `update`, and `delete` operations only work on dynamic entities — reference entities are read-only. |
|||
|
|||
```javascript |
|||
// Get by ID |
|||
var product = await db.get('LowCodeDemo.Products.Product', id); |
|||
|
|||
// Insert |
|||
var newProduct = await db.insert('LowCodeDemo.Products.Product', { |
|||
Name: 'New Product', |
|||
Price: 99.99, |
|||
StockCount: 100 |
|||
}); |
|||
|
|||
// Update |
|||
var updated = await db.update('LowCodeDemo.Products.Product', { |
|||
Id: existingId, |
|||
Name: 'Updated Name', |
|||
Price: 149.99 |
|||
}); |
|||
|
|||
// Delete |
|||
await db.delete('LowCodeDemo.Products.Product', id); |
|||
``` |
|||
|
|||
## Context Object |
|||
|
|||
Available in [interceptors](interceptors.md): |
|||
|
|||
| Property | Type | Description | |
|||
|----------|------|-------------| |
|||
| `context.commandArgs` | object | Command arguments (data, entityId, commandName, entityName) | |
|||
| `context.commandArgs.getValue(name)` | function | Get property value | |
|||
| `context.commandArgs.setValue(name, value)` | function | Set property value | |
|||
| `context.commandArgs.hasValue(name)` | function | Check if a property exists | |
|||
| `context.commandArgs.removeValue(name)` | function | Remove a property value | |
|||
| `context.currentUser` | object | Current user info (see [Interceptors](interceptors.md) for full list) | |
|||
| `context.emailSender` | object | Email sending (`sendAsync`, `sendHtmlAsync`) | |
|||
| `context.log(msg)` | function | Log an informational message | |
|||
| `context.logWarning(msg)` | function | Log a warning message | |
|||
| `context.logError(msg)` | function | Log an error message | |
|||
|
|||
## Configuration |
|||
|
|||
You can configure scripting limits using `AbpLowCodeScriptingOptions` in your module's `ConfigureServices` method: |
|||
|
|||
```csharp |
|||
Configure<AbpLowCodeScriptingOptions>(options => |
|||
{ |
|||
// Script execution limits (null = no limit) |
|||
options.Script.Timeout = TimeSpan.FromMinutes(1); |
|||
options.Script.MaxStatements = 100_000; |
|||
options.Script.MaxMemoryBytes = 512 * 1024 * 1024; // 512 MB |
|||
options.Script.MaxRecursionDepth = 500; |
|||
|
|||
// Query API limits (null = no limit) |
|||
options.Query.MaxLimit = 10_000; |
|||
options.Query.DefaultLimit = 1000; |
|||
options.Query.MaxExpressionNodes = 200; |
|||
options.Query.MaxExpressionDepth = 20; |
|||
options.Query.MaxArraySize = 500; |
|||
options.Query.MaxGroupCount = 500; |
|||
}); |
|||
``` |
|||
|
|||
All limits default to `null` (no limit). Configure them based on your security requirements and expected workload. |
|||
|
|||
## Security |
|||
|
|||
### Sandbox Constraints |
|||
|
|||
| Constraint | Default | Configurable | |
|||
|------------|---------|--------------| |
|||
| Script Timeout | No limit | Yes | |
|||
| Max Statements | No limit | Yes | |
|||
| Memory Limit | No limit | Yes | |
|||
| Recursion Depth | No limit | Yes | |
|||
| CLR Access | Disabled | No | |
|||
|
|||
### Query Security Limits |
|||
|
|||
| Limit | Default | Description | |
|||
|-------|---------|-------------| |
|||
| MaxExpressionNodes | No limit | Max AST nodes per expression | |
|||
| MaxExpressionDepth | No limit | Max nesting depth | |
|||
| MaxLimit (take) | No limit | Max records per query | |
|||
| DefaultLimit | No limit | Default if `take()` not specified | |
|||
| MaxArraySize (includes) | No limit | Max array size for IN operations | |
|||
| MaxGroupCount | No limit | Max groups in GroupBy | |
|||
|
|||
### Property Whitelist |
|||
|
|||
Only properties defined in the entity model can be queried. Accessing undefined properties throws a `SecurityException`. |
|||
|
|||
### SQL Injection Protection |
|||
|
|||
All values are parameterized: |
|||
|
|||
```javascript |
|||
var malicious = "'; DROP TABLE Products;--"; |
|||
// Safely treated as a literal string — no injection |
|||
var result = await db.query('Entity').where(x => x.Name.includes(malicious)).count(); |
|||
``` |
|||
|
|||
### Blocked Features |
|||
|
|||
The following are **not allowed** inside lambda expressions: `typeof`, `instanceof`, `in`, bitwise operators, `eval()`, `Function()`, `new RegExp()`, `new Date()`, `console.log()`, `setTimeout()`, `globalThis`, `window`, `__proto__`, `constructor`, `prototype`, `Reflect`, `Proxy`, `Symbol`. |
|||
|
|||
## Error Handling |
|||
|
|||
```javascript |
|||
// Abort operation with error |
|||
if (!context.commandArgs.getValue('Email').includes('@')) { |
|||
throw new Error('Valid email is required'); |
|||
} |
|||
|
|||
// Try-catch for safe execution |
|||
try { |
|||
var products = await db.query('Entity').where(x => x.Price > 0).toList(); |
|||
} catch (error) { |
|||
context.log('Query failed: ' + error.message); |
|||
} |
|||
``` |
|||
|
|||
## Best Practices |
|||
|
|||
1. **Use specific filters** — avoid querying all records without `where()` |
|||
2. **Set limits** — always use `take()` to limit results |
|||
3. **Validate early** — check inputs at the start of scripts |
|||
4. **Use `first()` for single results** — instead of `toList()[0]` |
|||
5. **Keep scripts focused** — one responsibility per interceptor |
|||
6. **Use `context.log()`** — never `console.log()` |
|||
7. **Handle nulls** — check for null before property access |
|||
|
|||
## Examples |
|||
|
|||
### Inventory Check on Order Creation |
|||
|
|||
```javascript |
|||
// Pre-create interceptor for Order |
|||
var productId = context.commandArgs.getValue('ProductId'); |
|||
var quantity = context.commandArgs.getValue('Quantity'); |
|||
|
|||
var product = await db.query('LowCodeDemo.Products.Product') |
|||
.where(x => x.Id === productId) |
|||
.first(); |
|||
|
|||
if (!product) { throw new Error('Product not found'); } |
|||
if (product.StockCount < quantity) { throw new Error('Insufficient stock'); } |
|||
|
|||
context.commandArgs.setValue('TotalAmount', product.Price * quantity); |
|||
``` |
|||
|
|||
### Sales Dashboard (Custom Endpoint) |
|||
|
|||
```javascript |
|||
var totalOrders = await db.query('LowCodeDemo.Orders.Order').count(); |
|||
var delivered = await db.query('LowCodeDemo.Orders.Order') |
|||
.where(x => x.IsDelivered === true).count(); |
|||
var revenue = await db.query('LowCodeDemo.Orders.Order') |
|||
.where(x => x.IsDelivered === true).sum(x => x.TotalAmount); |
|||
|
|||
return ok({ |
|||
orders: totalOrders, |
|||
delivered: delivered, |
|||
revenue: revenue |
|||
}); |
|||
``` |
|||
|
|||
## See Also |
|||
|
|||
* [Interceptors](interceptors.md) |
|||
* [Custom Endpoints](custom-endpoints.md) |
|||
* [model.json Structure](model-json.md) |
|||