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