Browse Source

Merge pull request #25021 from abpframework/salihozkara/lowcode-docs

Low code docs
pull/25028/head
Berkan Sasmaz 4 weeks ago
committed by GitHub
parent
commit
e9ce05ed3e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 38
      docs/en/docs-nav.json
  2. 149
      docs/en/low-code/custom-endpoints.md
  3. 572
      docs/en/low-code/fluent-api.md
  4. 148
      docs/en/low-code/foreign-access.md
  5. BIN
      docs/en/low-code/images/actions-menu.png
  6. BIN
      docs/en/low-code/images/create-modal.png
  7. BIN
      docs/en/low-code/images/data-grid.png
  8. BIN
      docs/en/low-code/images/foreign-access-modal.png
  9. BIN
      docs/en/low-code/images/interceptor-error.png
  10. BIN
      docs/en/low-code/images/menu-items.png
  11. BIN
      docs/en/low-code/images/quick-look.png
  12. 365
      docs/en/low-code/index.md
  13. 249
      docs/en/low-code/interceptors.md
  14. 422
      docs/en/low-code/model-json.md
  15. 147
      docs/en/low-code/reference-entities.md
  16. 477
      docs/en/low-code/scripting-api.md

38
docs/en/docs-nav.json

@ -1989,6 +1989,44 @@
}
]
},
{
"text": "Low-Code System",
"items": [
{
"text": "Overview",
"path": "low-code",
"isIndex": true
},
{
"text": "Attributes & Fluent API",
"path": "low-code/fluent-api.md"
},
{
"text": "model.json Structure",
"path": "low-code/model-json.md"
},
{
"text": "Reference Entities",
"path": "low-code/reference-entities.md"
},
{
"text": "Interceptors",
"path": "low-code/interceptors.md"
},
{
"text": "Scripting API",
"path": "low-code/scripting-api.md"
},
{
"text": "Custom Endpoints",
"path": "low-code/custom-endpoints.md"
},
{
"text": "Foreign Access",
"path": "low-code/foreign-access.md"
}
]
},
{
"text": "Solution Templates",
"items": [

149
docs/en/low-code/custom-endpoints.md

@ -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 productTable = await db.query('LowCodeDemo.Products.Product');\nvar avgPrice = totalCount > 0 ? await productTable.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 customerTable = await db.query('LowCodeDemo.Customers.Customer');\nvar customers = await customerTable\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)

572
docs/en/low-code/fluent-api.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:
![Quick-look panel showing entity details](images/quick-look.png)
### `[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)

148
docs/en/low-code/foreign-access.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**:
![Actions menu showing foreign access items (Order, Visited Country, etc.)](images/actions-menu.png)
### `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.
![Foreign access modal with full CRUD capabilities](images/foreign-access-modal.png)
### `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)

BIN
docs/en/low-code/images/actions-menu.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
docs/en/low-code/images/create-modal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
docs/en/low-code/images/data-grid.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
docs/en/low-code/images/foreign-access-modal.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
docs/en/low-code/images/interceptor-error.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
docs/en/low-code/images/menu-items.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
docs/en/low-code/images/quick-look.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

365
docs/en/low-code/index.md

@ -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.
![Auto-generated menu items in the sidebar](images/menu-items.png)
## 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.
![Auto-generated data grid with search, filters, and actions](images/data-grid.png)
![Auto-generated create/edit modal with form fields and foreign key lookups](images/create-modal.png)
## 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)

249
docs/en/low-code/interceptors.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!';
```
![Interceptor validation error displayed in the UI](images/interceptor-error.png)
## 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)

422
docs/en/low-code/model-json.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)

147
docs/en/low-code/reference-entities.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)

477
docs/en/low-code/scripting-api.md

@ -0,0 +1,477 @@
```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 entityTable = await db.query('Entity');
var baseQuery = await entityTable.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 productTable = await db.query('LowCodeDemo.Products.Product');
var products = await productTable
.where(x => x.Price > 100)
.orderBy(x => x.Price)
.take(10)
.toList();
var resultTable = await db.query('LowCodeDemo.Products.Product');
var result = await resultTable
.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()` | Return first match, throws if empty | `Promise<object>` |
| `firstOrDefault()` | Return first match or null | `Promise<object\|null>` |
| `last()` | Return last match, throws if empty | `Promise<object>` |
| `lastOrDefault()` | Return last match or null | `Promise<object\|null>` |
| `single()` | Return single match, throws if empty/multiple | `Promise<object>` |
| `singleOrDefault()` | Return single match or null (throws if multiple) | `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 entityTable = await db.query('Entity');
var result = await entityTable.where(x => x.Price > minPrice).toList();
var result2 = await entityTable.where(x => x.StockCount > config.minStock).toList();
var result3 = await entityTable.where(x => x.Price >= nested.range.min).toList();
```
### Contains / IN Operator
```javascript
var targetPrices = [50, 100, 200];
var entityTable = await db.query('Entity');
var products = await entityTable
.where(x => targetPrices.includes(x.Price))
.toList();
```
### Select Projection
```javascript
var productTable = await db.query('LowCodeDemo.Products.Product');
var projected = await productTable
.where(x => x.Price > 0)
.select(x => ({ ProductName: x.Name, ProductPrice: x.Price }))
.toList();
```
## Joins
### Explicit Joins
```javascript
var orderLineTable = await db.query('LowCodeDemo.Orders.OrderLine');
var orderLines = await orderLineTable
.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 orderTable = await db.query('LowCodeDemo.Orders.Order');
var orders = await orderTable
.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
var orderTable = await db.query('Order');
await orderTable.join(
'LowCodeDemo.Products.Product',
o => o.ProductId,
p => p.Id
);
```
### Join with Filtered Query
```javascript
var productTable = await db.query('Product');
var expensiveProducts = await productTable.where(p => p.Price > 100);
var orderLineTable = await db.query('OrderLine');
var orders = await orderLineTable
.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 productTable = await db.query('Product');
var cheap = await productTable.where(x => x.Price <= 100);
var popular = await productTable.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 productTable = await db.query('Product');
var totalValue = await productTable.sum(x => x.Price);
var avgPrice = await (await productTable.where(x => x.InStock)).average(x => x.Price);
var cheapest = await productTable.min(x => x.Price);
```
### GroupBy with Select
```javascript
var productTable = await db.query('Product');
var grouped = await productTable
.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 productTable = await db.query('Product');
var grouped = await productTable
.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 productTable = await db.query('Product');
var products = await productTable
.where(x => Math.round(x.Price) > 100)
.toList();
var result = await productTable
.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 entityTable = await db.query('Entity');
var result = await (await entityTable.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 entityTable = await db.query('Entity');
var products = await entityTable.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 productTable = await db.query('LowCodeDemo.Products.Product');
var product = await productTable
.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 orderTable = await db.query('LowCodeDemo.Orders.Order');
var totalOrders = await orderTable.count();
var delivered = await (await orderTable.where(x => x.IsDelivered === true)).count();
var revenue = await (await orderTable.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)
Loading…
Cancel
Save