mirror of https://github.com/abpframework/abp.git
Browse Source
Add comprehensive Low-Code System documentation and navigation. Introduces new docs under docs/en/low-code (index.md, fluent-api.md, model-json.md, scripting-api.md, interceptors.md, custom-endpoints.md, reference-entities.md, foreign-access.md) and updates docs/en/docs-nav.json to include a "Low-Code System" section linking these pages.pull/24925/head
9 changed files with 2094 additions and 0 deletions
@ -0,0 +1,131 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Define custom REST API endpoints with JavaScript handlers in the ABP Low-Code Module. 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 | |
||||
|
|----------|-------------| |
||||
|
| `route` | Route parameter values (e.g., `route.id`) | |
||||
|
| `query` | Query string parameters (e.g., `query.q`, `query.page`) | |
||||
|
| `body` | Request body (for POST/PUT) | |
||||
|
| `user` | Current user (same as `context.currentUser` in interceptors) | |
||||
|
|
||||
|
### Response Helpers |
||||
|
|
||||
|
| Function | HTTP Status | Description | |
||||
|
|----------|-------------|-------------| |
||||
|
| `ok(data)` | 200 | Success response with data | |
||||
|
| `notFound(message)` | 404 | Not found response | |
||||
|
| `badRequest(message)` | 400 | Bad request response | |
||||
|
|
||||
|
### 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,439 @@ |
|||||
|
```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." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Fluent API & Attributes |
||||
|
|
||||
|
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 |
||||
|
{ |
||||
|
[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: Register the Assembly |
||||
|
|
||||
|
````csharp |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo(typeof(YourDomainModule).Assembly) |
||||
|
); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### Step 3: 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 4: Add Relationships |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Orders")] |
||||
|
public class Order |
||||
|
{ |
||||
|
[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 |
||||
|
{ |
||||
|
[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 `DefaultsLayer` 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 |
||||
|
{ |
||||
|
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 |
||||
|
{ |
||||
|
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 |
||||
|
{ |
||||
|
// ... |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### `[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 referenced entity (or [reference entity](reference-entities.md)) | |
||||
|
| `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 | |
||||
|
|
||||
|
### `[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(Allow = 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 |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
> The `Name` parameter must be one of: `"Create"`, `"Update"`, or `"Delete"`. 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 |
||||
|
{ |
||||
|
public string Name { get; set; } |
||||
|
public OrganizationType OrganizationType { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
## 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 Domain module's `ConfigureServices`: |
||||
|
|
||||
|
````csharp |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
AbpDynamicEntityConfig.EntityConfigurations.Configure( |
||||
|
"MyApp.Products.Product", |
||||
|
entity => |
||||
|
{ |
||||
|
entity.SetDisplayProperty("Name"); |
||||
|
|
||||
|
entity.ConfigureProperty("Price", prop => |
||||
|
{ |
||||
|
prop.SetRequired(true); |
||||
|
prop.SetUI(ui => |
||||
|
{ |
||||
|
ui.SetDisplayName("Unit Price"); |
||||
|
ui.SetCreationFormAvailability(EntityPropertyUIFormAvailability.Available); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
entity.ConfigureProperty("InternalNotes", prop => |
||||
|
{ |
||||
|
prop.SetServerOnly(true); |
||||
|
}); |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### Entity Configuration Methods |
||||
|
|
||||
|
| Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `SetDisplayProperty(name)` | Set the display property for lookups | |
||||
|
| `SetParent(entityName)` | Set parent entity for nesting | |
||||
|
| `SetUI(action)` | Configure entity-level UI | |
||||
|
| `ConfigureProperty(name, action)` | Configure a specific property | |
||||
|
| `AddInterceptor(name, type, js)` | Add a JavaScript interceptor | |
||||
|
|
||||
|
### Property Configuration Methods |
||||
|
|
||||
|
| Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `SetRequired(bool)` | Mark as required | |
||||
|
| `SetUnique(bool)` | Mark as unique | |
||||
|
| `SetServerOnly(bool)` | Hide from clients | |
||||
|
| `SetAllowSetByClients(bool)` | Allow client writes | |
||||
|
| `SetForeignKey(entityName, displayProp, access)` | Configure foreign key | |
||||
|
| `SetUI(action)` | Configure property UI | |
||||
|
|
||||
|
## Assembly Registration |
||||
|
|
||||
|
Register assemblies containing `[DynamicEntity]` classes: |
||||
|
|
||||
|
````csharp |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo(typeof(MyDomainModule).Assembly) |
||||
|
); |
||||
|
```` |
||||
|
|
||||
|
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 |
||||
|
{ |
||||
|
[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 |
||||
|
{ |
||||
|
[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 |
||||
|
{ |
||||
|
[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 |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
||||
|
public Guid ProductId { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
public decimal Amount { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
Register everything in your Domain module: |
||||
|
|
||||
|
````csharp |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo(typeof(MyDomainModule).Assembly) |
||||
|
); |
||||
|
|
||||
|
// Reference existing ABP entities |
||||
|
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>("UserName"); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
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,137 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Control access to related entities through foreign key relationships using Foreign Access in the ABP Low-Code Module." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Foreign Access |
||||
|
|
||||
|
Foreign Access controls how related entities can be accessed through foreign key relationships. It determines whether users can view or manage related data directly from the referenced entity's UI. |
||||
|
|
||||
|
## Access Levels |
||||
|
|
||||
|
The `ForeignAccess` enum defines three levels: |
||||
|
|
||||
|
| Level | Value | Description | |
||||
|
|-------|-------|-------------| |
||||
|
| `None` | 0 | No access from the referenced entity side. The relationship exists only for lookups. | |
||||
|
| `View` | 1 | Read-only access. Users can view related records from the referenced entity's action menu. | |
||||
|
| `Edit` | 2 | Full CRUD access. Users can create, update, and delete related records from the referenced 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 => |
||||
|
{ |
||||
|
entity.ConfigureProperty("CustomerId", prop => |
||||
|
{ |
||||
|
prop.SetForeignKey("MyApp.Customers.Customer", "Name", 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: |
||||
|
|
||||
|
### `ForeignAccess.View` |
||||
|
|
||||
|
An **action menu item** appears on the referenced 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 referenced 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 related entity. 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 entity with the foreign key) |
||||
|
* Target entity (the referenced entity) |
||||
|
* Foreign key property name |
||||
|
* Access level |
||||
|
|
||||
|
The `DynamicEntityAppService` checks these relations when building entity actions and filtering data. |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Reference Entities](reference-entities.md) |
||||
|
* [Fluent API & Attributes](fluent-api.md) |
||||
@ -0,0 +1,228 @@ |
|||||
|
```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 |
||||
|
|
||||
|
The ABP Low-Code System lets you 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 entity classes, DTOs, application services, repositories, or UI pages manually. |
||||
|
|
||||
|
## Why Low-Code? |
||||
|
|
||||
|
Traditionally, adding a new entity to an ABP application requires: |
||||
|
|
||||
|
1. Entity class in Domain |
||||
|
2. DbContext configuration in EF Core |
||||
|
3. DTOs in Application.Contracts |
||||
|
4. AppService in Application |
||||
|
5. Controller in HttpApi |
||||
|
6. Razor/Blazor pages in UI |
||||
|
7. Permissions, menu items, localization |
||||
|
|
||||
|
**With Low-Code, a single C# class replaces all of the above:** |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Products")] |
||||
|
public class Product |
||||
|
{ |
||||
|
[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. Install NuGet Packages |
||||
|
|
||||
|
| Package | Layer | |
||||
|
|---------|-------| |
||||
|
| `Volo.Abp.LowCode.Domain.Shared` | Domain.Shared | |
||||
|
| `Volo.Abp.LowCode.Domain` | Domain | |
||||
|
| `Volo.Abp.LowCode.Application.Contracts` | Application.Contracts | |
||||
|
| `Volo.Abp.LowCode.Application` | Application | |
||||
|
| `Volo.Abp.LowCode.HttpApi` | HttpApi | |
||||
|
| `Volo.Abp.LowCode.HttpApi.Client` | HttpApi.Client | |
||||
|
| `Volo.Abp.LowCode.EntityFrameworkCore` | EF Core | |
||||
|
| `Volo.Abp.LowCode.Blazor` | Blazor UI (SSR) | |
||||
|
| `Volo.Abp.LowCode.Blazor.Server` | Blazor Server | |
||||
|
| `Volo.Abp.LowCode.Blazor.WebAssembly` | Blazor WebAssembly | |
||||
|
| `Volo.Abp.LowCode.Installer` | Auto module discovery | |
||||
|
|
||||
|
### 2. Add Module Dependencies |
||||
|
|
||||
|
````csharp |
||||
|
[DependsOn( |
||||
|
typeof(AbpLowCodeApplicationModule), |
||||
|
typeof(AbpLowCodeEntityFrameworkCoreModule), |
||||
|
typeof(AbpLowCodeHttpApiModule), |
||||
|
typeof(AbpLowCodeBlazorModule) |
||||
|
)] |
||||
|
public class YourModule : AbpModule |
||||
|
{ |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### 3. Register Your Assembly |
||||
|
|
||||
|
In your Domain module, register the assembly that contains your `[DynamicEntity]` classes: |
||||
|
|
||||
|
````csharp |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
AbpDynamicEntityConfig.SourceAssemblies.Add( |
||||
|
new DynamicEntityAssemblyInfo(typeof(YourDomainModule).Assembly) |
||||
|
); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### 4. Configure DbContext |
||||
|
|
||||
|
Call `ConfigureDynamicEntities()` in your `DbContext`: |
||||
|
|
||||
|
````csharp |
||||
|
protected override void OnModelCreating(ModelBuilder builder) |
||||
|
{ |
||||
|
base.OnModelCreating(builder); |
||||
|
builder.ConfigureDynamicEntities(); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### 5. Define Your First Entity |
||||
|
|
||||
|
````csharp |
||||
|
[DynamicEntity] |
||||
|
[DynamicEntityUI(PageTitle = "Customers")] |
||||
|
public class Customer |
||||
|
{ |
||||
|
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; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### 6. 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 |
||||
|
{ |
||||
|
[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 |
||||
|
{ |
||||
|
[DynamicForeignKey("MyApp.Products.Product", "Name")] |
||||
|
public Guid ProductId { get; set; } |
||||
|
|
||||
|
public int Quantity { get; set; } |
||||
|
public decimal Amount { get; set; } |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
See [Fluent API & Attributes](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#configuration-priority) merges Attributes, JSON, and Fluent API with clear priority rules. |
||||
|
|
||||
|
## Key Features |
||||
|
|
||||
|
| Feature | Description | Documentation | |
||||
|
|---------|-------------|---------------| |
||||
|
| **Attributes & Fluent API** | Define entities with C# attributes and configure programmatically | [Fluent API & Attributes](fluent-api.md) | |
||||
|
| **model.json** | Declarative entity definitions in JSON | [model.json Structure](model-json.md) | |
||||
|
| **Reference Entities** | Link to existing entities like `IdentityUser` | [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 entities from the parent's UI | [Foreign Access](foreign-access.md) | |
||||
|
|
||||
|
## 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. |
||||
|
* `DynamicEntityUIAppService`: UI definitions, menu items, and page configurations. |
||||
|
* `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 |
||||
|
|
||||
|
* [Fluent API & Attributes](fluent-api.md) |
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Scripting API](scripting-api.md) |
||||
@ -0,0 +1,183 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Add custom business logic to dynamic entity CRUD operations using Interceptors in the ABP Low-Code Module. Validate, transform, and react to data changes with JavaScript." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Interceptors |
||||
|
|
||||
|
Interceptors allow you to run custom JavaScript code before or after 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 | |
||||
|
| `Update` | `Pre` | Before entity update — validation, authorization | |
||||
|
| `Update` | `Post` | After entity update — sync, notifications | |
||||
|
| `Delete` | `Pre` | Before entity deletion — dependency checks | |
||||
|
| `Delete` | `Post` | After entity deletion — cleanup | |
||||
|
|
||||
|
## 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"`. This maps directly to the CRUD command being intercepted. Multiple interceptors can be added to the same class (`AllowMultiple = true`). |
||||
|
|
||||
|
## 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"` or `"Post"` | |
||||
|
| `javascript` | string | JavaScript code to execute | |
||||
|
|
||||
|
## JavaScript Context |
||||
|
|
||||
|
Inside interceptor scripts, you have access to: |
||||
|
|
||||
|
### `context.commandArgs` |
||||
|
|
||||
|
| Property | Type | Description | |
||||
|
|----------|------|-------------| |
||||
|
| `data` | object | Entity data dictionary (for Create/Update) | |
||||
|
| `entityId` | string | Entity ID (for Update/Delete) | |
||||
|
| `getValue(name)` | function | Get a property value | |
||||
|
| `setValue(name, value)` | function | Set a property value (Pre-interceptors only) | |
||||
|
|
||||
|
### `context.currentUser` |
||||
|
|
||||
|
| Property | Type | Description | |
||||
|
|----------|------|-------------| |
||||
|
| `isAuthenticated` | bool | Whether user is logged in | |
||||
|
| `userName` | string | Username | |
||||
|
| `email` | string | Email address | |
||||
|
| `roles` | string[] | User's role names | |
||||
|
| `id` | string | User ID | |
||||
|
|
||||
|
### `context.emailSender` |
||||
|
|
||||
|
| Method | Description | |
||||
|
|--------|-------------| |
||||
|
| `sendAsync(to, subject, body)` | Send an email | |
||||
|
|
||||
|
### `context.log(message)` |
||||
|
|
||||
|
Log a message (use instead of `console.log`). |
||||
|
|
||||
|
### `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}" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Pre-Create: 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,382 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Define dynamic entities using model.json in the ABP Low-Code Module. 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": [], |
||||
|
"foreignKeys": [], |
||||
|
"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 referenced entity | |
||||
|
| `displayPropertyName` | Property to display in lookups (defaults to entity's `displayProperty`) | |
||||
|
| `access` | [Foreign access](foreign-access.md) level: `"none"`, `"view"`, or `"edit"` | |
||||
|
|
||||
|
### Validators |
||||
|
|
||||
|
Add validation rules to properties: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"name": "EmailAddress", |
||||
|
"validators": [ |
||||
|
{ "type": "required" }, |
||||
|
{ "type": "emailAddress" }, |
||||
|
{ "type": "minLength", "length": 5 }, |
||||
|
{ "type": "maxLength", "length": 255 } |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
| Validator | Parameters | Description | |
||||
|
|-----------|------------|-------------| |
||||
|
| `required` | — | Value is required | |
||||
|
| `minLength` | `length` | Minimum string length | |
||||
|
| `maxLength` | `length` | Maximum string length | |
||||
|
| `emailAddress` | — | Must be a valid email | |
||||
|
| `range` | `min`, `max` | Numeric range | |
||||
|
|
||||
|
## 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 |
||||
|
|
||||
|
* [Fluent API & Attributes](fluent-api.md) |
||||
|
* [Interceptors](interceptors.md) |
||||
|
* [Custom Endpoints](custom-endpoints.md) |
||||
|
* [Scripting API](scripting-api.md) |
||||
@ -0,0 +1,128 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Link dynamic entities to existing C# entities like IdentityUser using Reference Entities in the ABP Low-Code Module." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Reference Entities |
||||
|
|
||||
|
Reference Entities allow you to create foreign key relationships from dynamic entities to **existing (static) C# entities** that are already defined in your application or in ABP modules. |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
Dynamic entities defined via [Attributes](fluent-api.md) or [model.json](model-json.md) normally reference other dynamic entities. However, you may need to link to entities like ABP's `IdentityUser`, `Tenant`, or your own C# entity classes. Reference entities make this possible. |
||||
|
|
||||
|
Unlike dynamic entities, reference entities are **read-only** from the Low-Code Module's perspective — they don't get CRUD pages or APIs. They are used solely for: |
||||
|
|
||||
|
* **Foreign key lookups** — dropdown selection in UI forms |
||||
|
* **Display values** — showing the referenced entity's display property in grids |
||||
|
* **Query support** — querying via the [Scripting API](scripting-api.md) |
||||
|
|
||||
|
## Registering Reference Entities |
||||
|
|
||||
|
Register reference entities in your Domain module's `ConfigureServices` using `AbpDynamicEntityConfig.ReferencedEntityList`: |
||||
|
|
||||
|
````csharp |
||||
|
public override void ConfigureServices(ServiceConfigurationContext context) |
||||
|
{ |
||||
|
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>( |
||||
|
"UserName" // Default display property |
||||
|
); |
||||
|
|
||||
|
AbpDynamicEntityConfig.ReferencedEntityList.Add<IdentityUser>( |
||||
|
"UserName", // Default display property |
||||
|
"UserName", // Exposed properties |
||||
|
"Email", |
||||
|
"PhoneNumber" |
||||
|
); |
||||
|
} |
||||
|
```` |
||||
|
|
||||
|
### `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` | |
||||
|
| ABP Identity Role | `Volo.Abp.Identity.IdentityRole` | `Name` | |
||||
|
| ABP Tenant | `Volo.Saas.Tenant` | `Name` | |
||||
|
|
||||
|
## See Also |
||||
|
|
||||
|
* [model.json Structure](model-json.md) |
||||
|
* [Foreign Access](foreign-access.md) |
||||
|
* [Fluent API & Attributes](fluent-api.md) |
||||
@ -0,0 +1,428 @@ |
|||||
|
```json |
||||
|
//[doc-seo] |
||||
|
{ |
||||
|
"Description": "Server-side JavaScript Scripting API for ABP Low-Code Module. Query, filter, aggregate data and perform CRUD operations with database-level execution." |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
# Scripting API |
||||
|
|
||||
|
The Low-Code Module 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` | |
||||
|
| `reverse()` | Reverse sort order | `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` | |
||||
|
| `chunk(size)` | Split into chunks | `Promise<object[][]>` | |
||||
|
|
||||
|
### 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` | 100 | Maximum groups | |
||||
|
| `MaxItemsPerGroup` | 50 | Items per group | |
||||
|
| `MaxTotalGroupedItems` | 1000 | Total items across 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.getList(entityName, take?)` | Get list | `Promise<object[]>` | |
||||
|
| `db.getCount(entityName)` | Get count | `Promise<number>` | |
||||
|
| `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>` | |
||||
|
|
||||
|
```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) | |
||||
|
| `context.commandArgs.getValue(name)` | function | Get property value | |
||||
|
| `context.commandArgs.setValue(name, value)` | function | Set property value | |
||||
|
| `context.currentUser` | object | Current user (isAuthenticated, userName, email, roles, id) | |
||||
|
| `context.emailSender` | object | Email sending (`sendAsync(to, subject, body)`) | |
||||
|
| `context.log(msg)` | function | Logging | |
||||
|
|
||||
|
## Security |
||||
|
|
||||
|
### Sandbox Constraints |
||||
|
|
||||
|
| Constraint | Default | Configurable | |
||||
|
|------------|---------|--------------| |
||||
|
| Script Timeout | 10 seconds | Yes | |
||||
|
| Max Statements | 10,000 | Yes | |
||||
|
| Memory Limit | 10 MB | Yes | |
||||
|
| Recursion Depth | 100 levels | Yes | |
||||
|
| CLR Access | Disabled | No | |
||||
|
|
||||
|
### Query Security Limits |
||||
|
|
||||
|
| Limit | Default | Description | |
||||
|
|-------|---------|-------------| |
||||
|
| MaxExpressionNodes | 100 | Max AST nodes per expression | |
||||
|
| MaxExpressionDepth | 10 | Max nesting depth | |
||||
|
| MaxNavigationDepth | 5 | Max navigation property depth | |
||||
|
| MaxLimit (take) | 1000 | Max records per query | |
||||
|
| DefaultLimit | 100 | Default if `take()` not specified | |
||||
|
| MaxArraySize (includes) | 100 | Max array size for IN operations | |
||||
|
|
||||
|
### 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) |
||||
Loading…
Reference in new issue