From ff0714c51d4e66c75a57dd70caeb852971c7fbea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?SAL=C4=B0H=20=C3=96ZKARA?= Date: Tue, 17 Feb 2026 16:04:45 +0300 Subject: [PATCH] Add Low-Code System documentation 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. --- docs/en/docs-nav.json | 38 +++ docs/en/low-code/custom-endpoints.md | 131 ++++++++ docs/en/low-code/fluent-api.md | 439 +++++++++++++++++++++++++ docs/en/low-code/foreign-access.md | 137 ++++++++ docs/en/low-code/index.md | 228 +++++++++++++ docs/en/low-code/interceptors.md | 183 +++++++++++ docs/en/low-code/model-json.md | 382 +++++++++++++++++++++ docs/en/low-code/reference-entities.md | 128 +++++++ docs/en/low-code/scripting-api.md | 428 ++++++++++++++++++++++++ 9 files changed, 2094 insertions(+) create mode 100644 docs/en/low-code/custom-endpoints.md create mode 100644 docs/en/low-code/fluent-api.md create mode 100644 docs/en/low-code/foreign-access.md create mode 100644 docs/en/low-code/index.md create mode 100644 docs/en/low-code/interceptors.md create mode 100644 docs/en/low-code/model-json.md create mode 100644 docs/en/low-code/reference-entities.md create mode 100644 docs/en/low-code/scripting-api.md diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 3f1abf424f..ee3d0eae76 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -1981,6 +1981,44 @@ } ] }, + { + "text": "Low-Code System", + "items": [ + { + "text": "Overview", + "path": "low-code", + "isIndex": true + }, + { + "text": "Fluent API & Attributes", + "path": "low-code/fluent-api.md" + }, + { + "text": "model.json Structure", + "path": "low-code/model-json.md" + }, + { + "text": "Reference Entities", + "path": "low-code/reference-entities.md" + }, + { + "text": "Interceptors", + "path": "low-code/interceptors.md" + }, + { + "text": "Scripting API", + "path": "low-code/scripting-api.md" + }, + { + "text": "Custom Endpoints", + "path": "low-code/custom-endpoints.md" + }, + { + "text": "Foreign Access", + "path": "low-code/foreign-access.md" + } + ] + }, { "text": "Solution Templates", "items": [ diff --git a/docs/en/low-code/custom-endpoints.md b/docs/en/low-code/custom-endpoints.md new file mode 100644 index 0000000000..d80d8e8802 --- /dev/null +++ b/docs/en/low-code/custom-endpoints.md @@ -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) diff --git a/docs/en/low-code/fluent-api.md b/docs/en/low-code/fluent-api.md new file mode 100644 index 0000000000..8509cd8823 --- /dev/null +++ b/docs/en/low-code/fluent-api.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("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) diff --git a/docs/en/low-code/foreign-access.md b/docs/en/low-code/foreign-access.md new file mode 100644 index 0000000000..795463bb3c --- /dev/null +++ b/docs/en/low-code/foreign-access.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) diff --git a/docs/en/low-code/index.md b/docs/en/low-code/index.md new file mode 100644 index 0000000000..4687658eb7 --- /dev/null +++ b/docs/en/low-code/index.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) diff --git a/docs/en/low-code/interceptors.md b/docs/en/low-code/interceptors.md new file mode 100644 index 0000000000..eec25ef019 --- /dev/null +++ b/docs/en/low-code/interceptors.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) diff --git a/docs/en/low-code/model-json.md b/docs/en/low-code/model-json.md new file mode 100644 index 0000000000..1b82f76797 --- /dev/null +++ b/docs/en/low-code/model-json.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) diff --git a/docs/en/low-code/reference-entities.md b/docs/en/low-code/reference-entities.md new file mode 100644 index 0000000000..453eca27f2 --- /dev/null +++ b/docs/en/low-code/reference-entities.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( + "UserName" // Default display property + ); + + AbpDynamicEntityConfig.ReferencedEntityList.Add( + "UserName", // Default display property + "UserName", // Exposed properties + "Email", + "PhoneNumber" + ); +} +```` + +### `Add` Method + +````csharp +public void Add( + string defaultDisplayProperty, + params string[] properties +) where TEntity : class, IEntity +```` + +| Parameter | Description | +|-----------|-------------| +| `defaultDisplayProperty` | Property name used as display value in lookups | +| `properties` | Additional properties to expose (optional) | + +> The entity type must implement `IEntity`. + +## 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`). +* **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) diff --git a/docs/en/low-code/scripting-api.md b/docs/en/low-code/scripting-api.md new file mode 100644 index 0000000000..2fe714145b --- /dev/null +++ b/docs/en/low-code/scripting-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` | +| `count()` | Return count | `Promise` | +| `any()` | Check if any matches exist | `Promise` | +| `all(x => condition)` | Check if all records match | `Promise` | +| `isEmpty()` | Check if no results | `Promise` | +| `isSingle()` | Check if exactly one result | `Promise` | +| `first()` / `firstOrDefault()` | Return first match or null | `Promise` | +| `last()` / `lastOrDefault()` | Return last match or null | `Promise` | +| `single()` / `singleOrDefault()` | Return single match or null | `Promise` | +| `elementAt(index)` | Return element at index | `Promise` | +| `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` | + +### 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` | +| `average(x => x.Property)` | `SELECT AVG(...)` | `Promise` | +| `min(x => x.Property)` | `SELECT MIN(...)` | `Promise` | +| `max(x => x.Property)` | `SELECT MAX(...)` | `Promise` | +| `distinct(x => x.Property)` | `SELECT DISTINCT ...` | `Promise` | +| `groupBy(x => x.Property)` | `GROUP BY ...` | `Promise` | + +```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` | +| `db.getList(entityName, take?)` | Get list | `Promise` | +| `db.getCount(entityName)` | Get count | `Promise` | +| `db.insert(entityName, entity)` | Insert new | `Promise` | +| `db.update(entityName, entity)` | Update existing | `Promise` | +| `db.delete(entityName, id)` | Delete by ID | `Promise` | + +```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)