spiderly 19.8.3 → 19.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/docs/angular-customization/SKILL.md +389 -0
- package/agent/docs/angular-customization/references/controls.generated.md +23 -0
- package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
- package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
- package/agent/docs/angular-customization/references/validators.generated.md +13 -0
- package/agent/docs/authorization/SKILL.md +385 -0
- package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
- package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
- package/agent/docs/backend-hooks/SKILL.md +231 -0
- package/agent/docs/backend-localization/SKILL.md +170 -0
- package/agent/docs/backend-testing/SKILL.md +65 -0
- package/agent/docs/custom-endpoints/SKILL.md +409 -0
- package/agent/docs/e2e-testing/SKILL.md +139 -0
- package/agent/docs/entity-design/SKILL.md +346 -0
- package/agent/docs/entity-design/references/attributes.generated.md +53 -0
- package/agent/docs/file-storage/SKILL.md +262 -0
- package/agent/docs/filtering-patterns/SKILL.md +127 -0
- package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
- package/agent/docs/frontend-localization/SKILL.md +120 -0
- package/agent/docs/mapper-customization/SKILL.md +105 -0
- package/agent/manifest.json +34 -0
- package/agent/skills/add-entity/SKILL.md +158 -0
- package/agent/skills/deployment/SKILL.md +551 -0
- package/agent/skills/ef-migrations/SKILL.md +49 -0
- package/agent/skills/report-gap/SKILL.md +110 -0
- package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
- package/agent/skills/spiderly-upgrade/SKILL.md +165 -0
- package/agent/skills/verify-ui/SKILL.md +148 -0
- package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
- package/fesm2022/spiderly.mjs +14 -8
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/auth/login/login.component.d.ts +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
- package/lib/errors/api-error-codes.d.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: e2e-testing
|
|
3
|
+
description: End-to-end testing a Spiderly app with Playwright — log in via the dev-mode verification-code helper, navigate PrimeNG v19 selector quirks, debug failing CI runs from trace artifacts, and seed/clean test data. Use when writing Playwright tests against a Spiderly app, automating login from tests, debugging selectors that won't match, or pulling trace screenshots from a failed CI run.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# E2E Testing
|
|
7
|
+
|
|
8
|
+
## Logging in from a test (no SMTP needed)
|
|
9
|
+
|
|
10
|
+
Spiderly's `SendLoginVerificationEmail` endpoint returns the verification code in the response body when `ShouldShowVerificationCodeInNotification()` returns true. The gate is `IWebHostEnvironment.IsDevelopment() && !emailingService.IsConfigured()` — it's on **only** when the backend runs in the Development environment **and** SMTP is not fully configured. `IsConfigured()` requires all four of `EmailSender.Email`, `EmailSenderPassword`, `SmtpHost`, and `SmtpPort > 0`; if all four are present, the backend does a **real email send** instead — even in Development — and `verificationCode` is absent from the response. So run the test backend with `ASPNETCORE_ENVIRONMENT=Development` and SMTP left unconfigured. That lets a test complete the 2FA flow without ever sending an email.
|
|
11
|
+
|
|
12
|
+
The two conditions above are the only levers — `ShouldShowVerificationCodeInNotification()` is `private`, so you can't override it on your `SecurityService`. To **get** the code in the response (tests, local dev): run with `ASPNETCORE_ENVIRONMENT=Development` and no complete SMTP config. To **turn it off** (production, or any environment that must send real emails): run a non-Development environment, or fully configure SMTP. Without `ASPNETCORE_ENVIRONMENT=Development` the code is never returned, regardless of SMTP config.
|
|
13
|
+
|
|
14
|
+
### Two endpoints, two helpers — don't mix them
|
|
15
|
+
|
|
16
|
+
Spiderly ships paired login endpoints. Browser tests and API-only tests need different ones; choosing the wrong one looks like a successful login that mysteriously fails on the next navigation.
|
|
17
|
+
|
|
18
|
+
| Endpoint | Response body | Cookies set | Use for |
|
|
19
|
+
|---|---|---|---|
|
|
20
|
+
| `/Security/Login` | `AuthResultDTO` — `{ accessToken, refreshToken }` | none | API-only tests that pass `Authorization: Bearer <token>` |
|
|
21
|
+
| `/Security/LoginWithCookies` | `AuthResultWithCookiesDTO` — `{ userId, email, accessTokenExpiresAt }` (**no tokens in body**) | `access_token` + `refresh_token` (HttpOnly) + `AuthResult` (JS-readable) | Browser tests — the admin's bootstrap reads these cookies to restore the session |
|
|
22
|
+
|
|
23
|
+
The Spiderly admin's session restoration is **cookie-based**: on bootstrap it calls `POST /Security/RefreshTokenWithCookies?browserId=X`, which reads the refresh token from the HttpOnly cookie and uses `browserId` as the binding key. Three things must be true before the first navigation for that call to succeed:
|
|
24
|
+
|
|
25
|
+
1. Hit `LoginWithCookies` (not `Login`) so the backend issues `Set-Cookie` for the refresh token.
|
|
26
|
+
2. Issue it through `page.request` — **not the standalone `request` fixture**. `request` has its own cookie jar that the page does not see; `Set-Cookie` from a `request.post(...)` will never reach the browser.
|
|
27
|
+
3. Seed `browser_id` into `localStorage` via `page.addInitScript` so the very first bootstrap call sends `?browserId=e2e-browser` (matching what the cookie was issued for; otherwise the app generates a fresh GUID and the server rejects the refresh).
|
|
28
|
+
|
|
29
|
+
Reference helpers (the spiderly e2e fixtures themselves use this — `tests/e2e-fixtures/frontend/tests/e2e/helpers/auth.ts`):
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { APIRequestContext, Page, expect } from '@playwright/test';
|
|
33
|
+
|
|
34
|
+
// Scaffold default only. The authoritative backend URL is the origin of `apiUrl`
|
|
35
|
+
// in Frontend/src/environments/environment.ts (strip the trailing /api); the bound
|
|
36
|
+
// port is in Backend/<App>.WebAPI/Properties/launchSettings.json -> applicationUrl.
|
|
37
|
+
const API_BASE_URL = 'http://localhost:5000';
|
|
38
|
+
const TEST_EMAIL = 'test@e2e.com';
|
|
39
|
+
const TEST_BROWSER_ID = 'e2e-browser';
|
|
40
|
+
|
|
41
|
+
async function sendVerificationCode(request: APIRequestContext): Promise<string> {
|
|
42
|
+
const res = await request.post(`${API_BASE_URL}/api/Security/SendLoginVerificationEmail`, {
|
|
43
|
+
data: { email: TEST_EMAIL, browserId: TEST_BROWSER_ID },
|
|
44
|
+
});
|
|
45
|
+
expect(res.ok()).toBeTruthy();
|
|
46
|
+
const { verificationCode } = await res.json();
|
|
47
|
+
expect(verificationCode).toBeTruthy();
|
|
48
|
+
return verificationCode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// API-only: body tokens, no cookies. Use the Authorization header.
|
|
52
|
+
export async function login(request: APIRequestContext): Promise<{ accessToken: string; refreshToken: string }> {
|
|
53
|
+
const verificationCode = await sendVerificationCode(request);
|
|
54
|
+
const res = await request.post(`${API_BASE_URL}/api/Security/Login`, {
|
|
55
|
+
data: { email: TEST_EMAIL, browserId: TEST_BROWSER_ID, verificationCode },
|
|
56
|
+
});
|
|
57
|
+
expect(res.ok()).toBeTruthy();
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
expect(body.accessToken).toBeTruthy();
|
|
60
|
+
return { accessToken: body.accessToken, refreshToken: body.refreshToken };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Browser: cookie session. The unused `_request` param is kept for call-site
|
|
64
|
+
// stability — the standalone fixture has the wrong cookie jar; we use page.request.
|
|
65
|
+
export async function authenticateBrowser(page: Page, _request: APIRequestContext): Promise<void> {
|
|
66
|
+
// (3) Seed browser_id before any page bootstrap.
|
|
67
|
+
await page.addInitScript((id) => {
|
|
68
|
+
localStorage.setItem('browser_id', id);
|
|
69
|
+
}, TEST_BROWSER_ID);
|
|
70
|
+
|
|
71
|
+
// (1) + (2) LoginWithCookies through page.request so Set-Cookie lands in the
|
|
72
|
+
// BrowserContext jar the page uses on subsequent navigations.
|
|
73
|
+
const verificationCode = await sendVerificationCode(page.request);
|
|
74
|
+
const res = await page.request.post(`${API_BASE_URL}/api/Security/LoginWithCookies`, {
|
|
75
|
+
data: { email: TEST_EMAIL, browserId: TEST_BROWSER_ID, verificationCode },
|
|
76
|
+
});
|
|
77
|
+
expect(res.ok()).toBeTruthy();
|
|
78
|
+
// Body is AuthResultWithCookiesDTO — { userId, email, accessTokenExpiresAt }.
|
|
79
|
+
// Tokens are in cookies, not body; don't assert body.accessToken.
|
|
80
|
+
|
|
81
|
+
await page.goto('/');
|
|
82
|
+
await page.locator('sidebar-menu').waitFor({ state: 'visible', timeout: 15000 });
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Anti-pattern that used to "work":** seeding `access_token` / `refresh_token` into `localStorage` and reloading. This stopped working when Spiderly moved the refresh token to an HttpOnly cookie — the localStorage values are ignored on bootstrap, the cookie isn't set, `RefreshTokenWithCookies` returns 401, app redirects to `/login`, every browser test times out on `sidebar-menu`. Symptom-to-cause: if all API tests pass but every UI test times out on `sidebar-menu`, you're on the old token-only path; switch to `LoginWithCookies` + `page.request` + `addInitScript`.
|
|
87
|
+
|
|
88
|
+
## CORS origin mismatch looks like an auth failure
|
|
89
|
+
|
|
90
|
+
The backend allows one CORS origin — `Spiderly.Shared:FrontendUrl` in `appsettings.json` (default `:4200`). If the admin is served on a different port, the browser blocks every API call as `status 0`, which surfaces as a **"Connection Lost"** toast + redirect to `/login` — looking like an auth failure. If the API login helper works but the browser session bounces to `/login`, check the served port matches `FrontendUrl` before suspecting tokens or roles.
|
|
91
|
+
|
|
92
|
+
## PrimeNG v19 selector pitfalls
|
|
93
|
+
|
|
94
|
+
Spiderly's admin UI is built on PrimeNG v19. A few selectors that look obvious from the docs do not work — match what's actually rendered.
|
|
95
|
+
|
|
96
|
+
- **Filter Apply / Clear buttons have no identifying class.** PrimeNG's documented `pcFilterApplyButton` / `pcFilterClearButton` style classes are not applied to the rendered `<p-button>` elements. Match by accessible name:
|
|
97
|
+
```ts
|
|
98
|
+
overlay.getByRole('button', { name: 'Apply' })
|
|
99
|
+
```
|
|
100
|
+
- **Match-mode dropdown is `<p-select>`, not `<p-dropdown>`** — PrimeNG renamed Dropdown to Select in v19. Spiderly's `<spiderly-dropdown>` wraps `<p-select>` internally.
|
|
101
|
+
- **Boolean filter is `<p-checkbox [binary]="true" [indeterminate]="value === null">`**, not `pTriStateCheckbox`. Initial state is `null` (rendered as a horizontal dash); each click cycles `null → true → false → null`.
|
|
102
|
+
- **Filter overlays for the rightmost column get clipped against the viewport.** PrimeNG repositions the overlay frame-by-frame, so Playwright's stability check on inner elements fails (`waiting for element to be visible, enabled and stable`). Pass `click({ force: true })` to bypass the stability gate. Apply/Clear buttons (matched by role) do not need this — only the elements *inside* the overlay (e.g. `.p-checkbox-box`).
|
|
103
|
+
|
|
104
|
+
## Match-mode column configuration
|
|
105
|
+
|
|
106
|
+
For column-config behavior (when the match-mode dropdown renders, how labels resolve), see `Angular/projects/spiderly/src/lib/components/spiderly-data-table/CLAUDE.md`. Two points that commonly bite test authors:
|
|
107
|
+
|
|
108
|
+
- **Numeric and date columns need `showMatchModes: true`** on the `Column<T>` for the match-mode `<p-select>` to render at all. Without it the match-mode UI is silently absent and Playwright selectors for "More than" / "Less than" will time out.
|
|
109
|
+
- **Match-mode option labels are transloco output** (`'More than'`, `'Less than'`), not `MatchModeCodes` keys. Match Playwright selectors against the value in your `en.json` (or the locale your test runs under).
|
|
110
|
+
|
|
111
|
+
## Test data: seed and clean
|
|
112
|
+
|
|
113
|
+
Tests should own their data. Two patterns:
|
|
114
|
+
|
|
115
|
+
- **Per-suite seed in `beforeAll` / cleanup in `afterAll`** — when multiple tests in the same `describe` reuse the same fixtures.
|
|
116
|
+
- **In-test seed + describe-scoped cleanup array + `afterAll`** — when only one test needs the data. Track inserted IDs in an array and tear them down at the end.
|
|
117
|
+
|
|
118
|
+
Always use `Promise.all` for seed and cleanup batches. Sequential 40× HTTP round-trips noticeably slow CI; the database has no problem with the concurrency.
|
|
119
|
+
|
|
120
|
+
## Generated lists ship with the Id column only
|
|
121
|
+
|
|
122
|
+
`spiderly add-new-entity` produces a list component with a single numeric Id column plus Details/Delete actions. If your test needs to drive text/numeric/boolean filters, you have two options:
|
|
123
|
+
|
|
124
|
+
1. **Extend the list component** in your app — add the columns you want to filter on (text, numeric, boolean). The Spiderly admin then has the filter UI your test can target.
|
|
125
|
+
2. **Drive filtering through the API directly** — call the paginated-list endpoint with a `FilterDTO` payload and assert on the response, skipping the UI. Faster, less brittle, but doesn't exercise the column-config code path.
|
|
126
|
+
|
|
127
|
+
## Debugging a failing Playwright test in CI
|
|
128
|
+
|
|
129
|
+
When a selector times out the failure log rarely shows enough context. Pull the trace artifact and look at the last screenshot — it reveals whether the element is missing, off-screen, occluded, or labeled differently than expected.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
gh run download <run-id> --dir /tmp/ci-<run-id>
|
|
133
|
+
cd /tmp/ci-<run-id>/playwright-report/data
|
|
134
|
+
for z in *.zip; do unzip -o -d /tmp/traces/"${z%.zip}" "$z"; done
|
|
135
|
+
# Find the trace folder for the failing test (replace <spec-file>:<line>):
|
|
136
|
+
for d in /tmp/traces/*/; do grep -lc "<spec-file>:<line>" "$d"*.trace 2>/dev/null | head -1; done
|
|
137
|
+
# View the last screenshot in that folder:
|
|
138
|
+
ls /tmp/traces/<picked>/resources/ | grep jpeg | sort | tail -1
|
|
139
|
+
```
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: entity-design
|
|
3
|
+
description: Design Spiderly entities with correct attributes, relationships, and UI mappings. Use when creating or modifying entity classes, choosing entity attributes, setting up relationships (M2O, 1-1 via [WithOne], M2M, ordered O2M), configuring file uploads on entities, or asking about UI control mapping.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Entity Design
|
|
7
|
+
|
|
8
|
+
## Required attribute
|
|
9
|
+
|
|
10
|
+
Every hand-written entity class must carry `[SpiderlyEntity]`. Without it, the source generators ignore the class — no generated DTO, mapper, controller, validator, or Angular form.
|
|
11
|
+
|
|
12
|
+
```csharp
|
|
13
|
+
using Spiderly.Shared.Attributes.Entity;
|
|
14
|
+
using Spiderly.Shared.BaseEntities;
|
|
15
|
+
|
|
16
|
+
namespace Foo.Business.Entities
|
|
17
|
+
{
|
|
18
|
+
[SpiderlyEntity]
|
|
19
|
+
public class Product : BusinessObject<long>
|
|
20
|
+
{
|
|
21
|
+
public string Name { get; set; }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Hand-written DTOs use `[SpiderlyDTO]`. Generated DTOs (`{Entity}DTO`, `{Entity}SaveBodyDTO`, `{Entity}MainUIFormDTO`) need no marker. The `spiderly add-new-entity` CLI emits `[SpiderlyEntity]` automatically.
|
|
27
|
+
|
|
28
|
+
## Base Classes
|
|
29
|
+
|
|
30
|
+
| Base Class | Use When | Generated |
|
|
31
|
+
| ------------------- | ---------------------- | ------------------------------------------------ |
|
|
32
|
+
| `BusinessObject<T>` | Full CRUD entity | Id, Version, CreatedAt, ModifiedAt + CRUD UI/API |
|
|
33
|
+
| `ReadonlyObject<T>` | Lookup/reference table | Id only, read-only operations |
|
|
34
|
+
|
|
35
|
+
`Version` is an optimistic-concurrency token you get for free on every `BusinessObject<T>` — `ReadonlyObject<T>` has none. It's a `[ConcurrencyCheck]` column, auto-set to `1` on insert and incremented on every update inside `SaveChanges` (you never touch it), and it round-trips to the client on the DTO. On update the generated `Save{Entity}` reloads the row via `GetInstanceAsync(id, dto.Version)`, which throws a localized `ConcurrencyException` (a `BusinessException`) when the incoming version is stale — so two users editing the same record can't silently overwrite each other. No per-entity wiring required. (The guard is on **update only**: deletes go through `ExecuteDeleteAsync` and are not version-checked, and inserts have no version to race — duplicate-creation races need a DB unique index, not the version column.)
|
|
36
|
+
|
|
37
|
+
`T` = `long` (default), `int`, or `byte`. **Anything else is rejected at compile time by [SPIDERLY018](/docs/build-diagnostics#spiderly018)** — including `Guid`, `decimal`, `short`, `DateTime`, etc. Ordinary `Guid` scalar properties on entities are fully supported; only the PK type argument is restricted. For public, non-enumerable identifiers (UUID-style URLs) keep the numeric `Id` and add a separate `Guid PublicId` property.
|
|
38
|
+
|
|
39
|
+
**Validation attributes on a `ReadonlyObject<T>` apply to the schema, not to input.** Spiderly skips `Save{Entity}` generation for readonly objects, so the generated `{Entity}DTOValidationRules` validator is never invoked. Keep `[Required]` / `[StringLength]` on readonly-entity properties for the EF Core column shape (non-null, length) and — for `[Required]` — the Swagger/TypeScript contract; just don't expect them to *run* as validation. Same principle for hand-written read-only/output DTOs: drop every validation attribute except `[Required]` (see `spiderly:custom-endpoints` → *Custom DTOs*).
|
|
40
|
+
|
|
41
|
+
## Operational tables
|
|
42
|
+
|
|
43
|
+
Tables that exist as operational state (outbox, audit log, sync cursors, dispatcher queues) are still `[SpiderlyEntity]` — do **not** reach for `[UIDoNotGenerate]` at the class level and a parallel custom Angular page; you lose the generated table, mappers, validators, and DTOs for no gain. Restrict mutations by not granting `Insert{Entity}` / `Update{Entity}` / `Delete{Entity}` permissions to any role in your seed setup; default-filter the admin table to "interesting" rows (e.g. pending, failed) via a paginated-list override (see `spiderly:filtering-patterns`); expose semantic row actions like Retry/Dismiss via a custom controller alongside the generated one (see `spiderly:custom-endpoints`).
|
|
44
|
+
|
|
45
|
+
## Property Rules
|
|
46
|
+
|
|
47
|
+
- Navigation properties **must** be `virtual`: `public virtual Brand Brand { get; set; }`
|
|
48
|
+
- Collections use `List<T>` (not `IList<T>`), initialized inline: `public virtual List<Comment> Comments { get; } = new();`
|
|
49
|
+
- Explicit FK properties (`BrandId` alongside `Brand`) are **supported and recommended for hot paths** — see *Explicit FK properties* below
|
|
50
|
+
- `[StringLength(X)]` without `MinimumLength` = **max-length** validation (minimum defaults to 0, standard .NET semantics). Use `[StringLength(X, MinimumLength = Y)]` for a range; `[StringLength(X, MinimumLength = X)]` (min == max) for exact length.
|
|
51
|
+
- On properties that aren't effectively required (no `[Required]`, not an `[M2MWithMany]` junction), all validation rules wrap in `.Unless(string.IsNullOrEmpty(x))` on strings — or `== null` on other types — so the validator skips null/empty entirely. Consequence: `MinimumLength = 1` is a no-op on non-required strings; use `MinimumLength ≥ 2` or add `[Required]` to actually reject empty values.
|
|
52
|
+
- `[Required]` on navigation properties makes the relationship required (non-nullable FK)
|
|
53
|
+
- The `[Index]` attribute lives in `Microsoft.EntityFrameworkCore` and is **not** in Spiderly's default using-block. Add `using Microsoft.EntityFrameworkCore;` to the entity file or you get the misleading `'Index' is not an attribute class` error.
|
|
54
|
+
|
|
55
|
+
## Explicit FK properties
|
|
56
|
+
|
|
57
|
+
Default: declare only the navigation (`public virtual Brand Brand { get; set; }`). Spiderly uses EF Core's shadow FK convention (`"BrandId"` column) and generated mappers read it via `EF.Property<>()`. For most admin entities this is fine.
|
|
58
|
+
|
|
59
|
+
**Declare an explicit FK scalar** when the entity is in a hot path:
|
|
60
|
+
|
|
61
|
+
```csharp
|
|
62
|
+
public long? BrandId { get; set; }
|
|
63
|
+
[WithMany(nameof(Brand.Products))]
|
|
64
|
+
public virtual Brand Brand { get; set; }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### When to use it
|
|
68
|
+
|
|
69
|
+
- **Hand-written save/sync code** that builds the entity directly (`new Order { BrandId = id, ... }`) — skips the `FindAsync` + navigation-attach roundtrip that the naive pattern requires
|
|
70
|
+
- **Hot read paths** with `ProjectToDTO` — the mapper emits `x.BrandId` instead of the `EF.Property<long>(x, "BrandId")` workaround for [EF Core #15826](https://github.com/dotnet/efcore/issues/15826), which otherwise still forces a JOIN in some queries
|
|
71
|
+
|
|
72
|
+
### Rules
|
|
73
|
+
|
|
74
|
+
- Naming convention: `{NavigationName}Id` — resolved automatically. Use `[ForeignKey(nameof(OtherName))]` only when you need a different scalar name.
|
|
75
|
+
- Nullability must match the relationship: `[Required]` navigation → non-nullable scalar (`long BrandId`); optional nav (`[SetNull]`) → nullable scalar (`long? BrandId`). Mismatch raises **SPID001**.
|
|
76
|
+
- Scalar type must match the parent's `Id` type (`byte`/`int`/`long`). Mismatch raises **SPID003**.
|
|
77
|
+
|
|
78
|
+
### Caveat — generated CRUD still loads the nav
|
|
79
|
+
|
|
80
|
+
The generated `Save{Entity}AndReturnDTO` keeps loading the parent via `FindAsync` even when an explicit FK is declared, because the returned DTO's `{Nav}DisplayName` fields read `poco.Nav.DisplayProperty`. Declaring the explicit FK does **not** speed up generated admin CRUD saves — it only helps when you write the save/sync logic yourself and never round-trip through `Save{Entity}AndReturnDTO`.
|
|
81
|
+
|
|
82
|
+
### Don't bother when
|
|
83
|
+
|
|
84
|
+
Small admin-only entities with low write volume (banners, announcements, lookup tables without hot reads). The boilerplate isn't worth it — shadow FK stays idiomatic.
|
|
85
|
+
|
|
86
|
+
## Relationships Quick Reference
|
|
87
|
+
|
|
88
|
+
### Many-to-One
|
|
89
|
+
|
|
90
|
+
```csharp
|
|
91
|
+
public class Comment : BusinessObject<long>
|
|
92
|
+
{
|
|
93
|
+
[CascadeDelete] // or [SetNull] for optional
|
|
94
|
+
[WithMany(nameof(Post.Comments))]
|
|
95
|
+
public virtual Post Post { get; set; }
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The target entity **must** have a back-collection matching the `[WithMany(nameof(...))]` name. Forgetting `[WithMany]`, naming a target collection that doesn't exist, or declaring the back-collection with the wrong element type all surface at `dotnet build` time as `SPIDERLY015` / `SPIDERLY016` / `SPIDERLY017` respectively — no runtime explosion in `DbContext.OnModelCreating`. Two options:
|
|
100
|
+
|
|
101
|
+
1. Add the back-collection on the target (`public virtual List<Comment> Comments { get; } = new();` on `Post`) — preferred when both directions are useful.
|
|
102
|
+
2. **Drop the nav property** and keep only the explicit FK scalar (`public long PostId { get; set; }`). Then configure the relationship + delete behavior manually in `OnModelCreating`:
|
|
103
|
+
```csharp
|
|
104
|
+
modelBuilder.Entity<Comment>()
|
|
105
|
+
.HasOne<Post>().WithMany().HasForeignKey(c => c.PostId)
|
|
106
|
+
.OnDelete(DeleteBehavior.Cascade);
|
|
107
|
+
```
|
|
108
|
+
Use this when an FK exists only as a pointer (e.g. `LastReadMessage`, `ParentMessage`) and a back-collection on the target would be noise.
|
|
109
|
+
|
|
110
|
+
**Delete behavior:**
|
|
111
|
+
|
|
112
|
+
| Attribute | FK nullable? | On parent delete |
|
|
113
|
+
| ----------------- | ------------ | --------------------------------------------------------- |
|
|
114
|
+
| `[CascadeDelete]` | No | Children deleted by generated service code |
|
|
115
|
+
| `[SetNull]` | Yes | DB sets FK to null (`OnDelete(SetNull)`) |
|
|
116
|
+
| Neither | No | DB throws FK violation at runtime (`OnDelete(NoAction)`) |
|
|
117
|
+
|
|
118
|
+
#### How `[CascadeDelete]` actually works
|
|
119
|
+
|
|
120
|
+
`[CascadeDelete]` is **application-layer**, not EF Core `OnDelete(Cascade)`. The source generator scans many-to-one navigations marked with it and emits explicit `ExecuteDeleteAsync()` calls inside the generated `Delete{Entity}` / `Delete{Entity}List` methods, recursing through dependents in child→parent order inside a single transaction.
|
|
121
|
+
|
|
122
|
+
Although the cascade is untracked bulk `ExecuteDeleteAsync`, the generated delete still **flushes the change tracker right after `OnBefore{Entity}Delete`** — so a delete hook can stage tracked writes (e.g. `IOutbox.Enqueue`) and they persist atomically with the delete, no manual `SaveChangesAsync`. See the `backend-hooks` skill.
|
|
123
|
+
|
|
124
|
+
**Why app-layer instead of `OnDelete(Cascade)`.** SQL Server refuses cascading FKs whenever the schema has any potential cycle or multiple cascade paths. App-layer cascade sidesteps that entirely and gives transaction control, `OnBefore{Entity}Delete` hooks, authorization checks, and audit visibility — so it stays the idiom even on Postgres.
|
|
125
|
+
|
|
126
|
+
**Placement vs. semantics gotcha.** The attribute sits on the **child's** FK navigation but fires on **parent** deletion. `[CascadeDelete] public virtual Post Post` on `Comment` means *"when the `Post` is deleted, this `Comment` is deleted with it"* — not the other direction.
|
|
127
|
+
|
|
128
|
+
**No DB safety net.** Because the relationship is `NoAction`, forgetting `[CascadeDelete]` on a required FK causes a runtime FK violation at parent deletion. Either add the attribute, or delete the dependent rows explicitly with `ExecuteDeleteAsync` before the parent delete.
|
|
129
|
+
|
|
130
|
+
**Collection-side placement is a no-op.** The generator only scans many-to-one navigations on the child side; `[CascadeDelete]` on a parent's `List<Child>` collection does nothing — it must go on `Child.ParentNav`.
|
|
131
|
+
|
|
132
|
+
**Intentional omission** requires an inline `// no cascade because …` comment on the FK and a manual `ExecuteDeleteAsync` in the entity's `OnBefore{Entity}Delete` hook. Use this only when a dependent must outlive its parent (e.g. an audit or loyalty row that should survive the order it references), so future readers don't flag it as a bug.
|
|
133
|
+
|
|
134
|
+
### One-to-One
|
|
135
|
+
|
|
136
|
+
Use `[WithOne]` for a true 1-1 between **two independent aggregate roots** (each is queried and edited on its own). For a value object that only ever lives inside its parent (an address, a money amount), don't use 1-1 — either inline the fields on the parent or model an EF owned type; a separate `[SpiderlyEntity]` is overkill.
|
|
137
|
+
|
|
138
|
+
`[WithOne(nameof(Principal.InverseNav))]` goes on the **dependent** (foreign-key-holding) side's single-valued reference nav. Its *presence* designates that side as the dependent; the other side is the principal and is a plain nav with no attribute.
|
|
139
|
+
|
|
140
|
+
```csharp
|
|
141
|
+
public class Conversation : BusinessObject<long> // dependent — owns the FK
|
|
142
|
+
{
|
|
143
|
+
public long? OwningTaskItemId { get; set; } // explicit FK; nullable => optional 1-1
|
|
144
|
+
[WithOne(nameof(TaskItem.Conversation))]
|
|
145
|
+
[CascadeDelete] // delete the TaskItem => delete its Conversation
|
|
146
|
+
public virtual TaskItem OwningTaskItem { get; set; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public class TaskItem : BusinessObject<long> // principal
|
|
150
|
+
{
|
|
151
|
+
public virtual Conversation Conversation { get; set; } // inverse nav, no attribute
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This generates: single-valued navs both ways, an automatic **unique index** on the FK, the correct EF `HasOne().WithOne().HasForeignKey()` mapping, dependent-side DTO flattening (`{Nav}Id` + `{Nav}DisplayName`, same as M2O), and an autocomplete control + endpoint on the dependent's page.
|
|
156
|
+
|
|
157
|
+
**Required vs optional — dependent-FK nullability only.**
|
|
158
|
+
|
|
159
|
+
| Declaration | Meaning | DB |
|
|
160
|
+
| --- | --- | --- |
|
|
161
|
+
| `[Required]` on the `[WithOne]` nav (non-nullable FK) | the dependent must have a principal | plain unique index |
|
|
162
|
+
| no `[Required]` (nullable FK) | optional; the dependent may have no principal | unique index that **allows many NULLs** (Postgres `NULLS DISTINCT` / SQL Server auto `IS NOT NULL` filter — handled by provider conventions, no manual work) |
|
|
163
|
+
|
|
164
|
+
The schema **cannot** enforce "every principal has a dependent" — that direction is always 0..1. `[Required]` on the **principal**-side nav is a hard build error (**SPIDERLY021**); if you truly need that invariant, create the dependent in the principal's `OnAfter{Entity}Insert` hook.
|
|
165
|
+
|
|
166
|
+
**Other rules & diagnostics:**
|
|
167
|
+
|
|
168
|
+
- **Explicit FK recommended for code-managed 1-1s.** Shadow FK is allowed (symmetric with `[WithMany]`), but if you create the dependent in hand-written code (`new Conversation { OwningTaskItemId = taskId }`) you need the explicit scalar — there's no scalar to set on a shadow FK.
|
|
169
|
+
- **Cascade is app-layer**, exactly like M2O: `[CascadeDelete]` (deleted with the principal, walker-ordered), `[SetNull]` (nullable FK), or neither. No DB-level `ON DELETE CASCADE` is emitted.
|
|
170
|
+
- **Unidirectional**: parameterless `[WithOne]` when the principal has no back-nav.
|
|
171
|
+
- **Self-referential 1-1 is unsupported** → **SPIDERLY022**. Both-sides `[WithOne]` → **SPIDERLY019**; a `[WithOne]` inverse-nav name that doesn't exist → **SPIDERLY020**.
|
|
172
|
+
- **Duplicate guard for free**: a second dependent pointing at an already-taken principal violates the unique index and surfaces as a clean localized 409 (the generic constraint handler), not a 500.
|
|
173
|
+
- **The principal inverse renders nothing by default** — it's excluded from the principal's DTO and UI automatically (the FK lives on the dependent). For a fully code-managed 1-1 (the dependent is created/edited in code, never picked in the admin), put `[UIDoNotGenerate]` on the dependent's `[WithOne]` nav to suppress the autocomplete too.
|
|
174
|
+
|
|
175
|
+
### Simple Many-to-Many
|
|
176
|
+
|
|
177
|
+
`[M2MWithMany]` is treated as an implicit `[Required]` — junction rows must have both sides, so do **not** add `[Required]` on these navigations. If you declare an explicit FK scalar alongside, it must be non-nullable (e.g. `long CartId`, not `long? CartId`).
|
|
178
|
+
|
|
179
|
+
```csharp
|
|
180
|
+
[M2M]
|
|
181
|
+
[SpiderlyEntity]
|
|
182
|
+
public class RolePermission
|
|
183
|
+
{
|
|
184
|
+
[CascadeDelete]
|
|
185
|
+
[M2MWithMany(nameof(Role.Permissions))]
|
|
186
|
+
public virtual Role Role { get; set; }
|
|
187
|
+
|
|
188
|
+
[CascadeDelete]
|
|
189
|
+
[M2MWithMany(nameof(Permission.Roles))]
|
|
190
|
+
public virtual Permission Permission { get; set; }
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Junction entity must have exactly 2 `[M2MWithMany]` properties and both `[M2M]` and `[SpiderlyEntity]` markers. `[M2M]` flags the class as a junction; `[SpiderlyEntity]` enrolls it in the generator pipeline — missing it breaks the parent entity's generated service. Always add `[CascadeDelete]` on both navigations — otherwise the parent delete throws an FK violation at runtime (see *How `[CascadeDelete]` actually works* under Many-to-One). Parent collections:
|
|
195
|
+
|
|
196
|
+
```csharp
|
|
197
|
+
public class Role : BusinessObject<long>
|
|
198
|
+
{
|
|
199
|
+
public virtual List<Permission> Permissions { get; } = new();
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Complex Many-to-Many (junction with extra fields)
|
|
204
|
+
|
|
205
|
+
Keep `[M2M]` and `[SpiderlyEntity]` on the junction and add additional properties beside the two `[M2MWithMany]` navigations. Use `[ComplexManyToManyList]` on the parent collection for editable junction UI, or `[ComplexManyToManyReadonlyTable]` for read-only display.
|
|
206
|
+
|
|
207
|
+
### Ordered One-to-Many
|
|
208
|
+
|
|
209
|
+
```csharp
|
|
210
|
+
public class Course : BusinessObject<long>
|
|
211
|
+
{
|
|
212
|
+
[UIOrderedOneToMany]
|
|
213
|
+
public virtual List<CourseItem> CourseItems { get; } = new();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
public class CourseItem : BusinessObject<long>
|
|
217
|
+
{
|
|
218
|
+
[UIDoNotGenerate] [Required]
|
|
219
|
+
public int OrderNumber { get; set; }
|
|
220
|
+
|
|
221
|
+
[WithMany(nameof(Course.CourseItems))]
|
|
222
|
+
public virtual Course Course { get; set; }
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Child **must** have `[UIDoNotGenerate] [Required] public int OrderNumber { get; set; }`.
|
|
227
|
+
|
|
228
|
+
## UI Control Auto-Mapping
|
|
229
|
+
|
|
230
|
+
| C# Type | Default Control | Override With |
|
|
231
|
+
| --------------- | --------------- | ------------------------------------------------------------------------ |
|
|
232
|
+
| `string` | TextBox | `[UIControlType(nameof(UIControlTypeCodes.TextArea))]`, `Editor`, `Markdown`, `File` |
|
|
233
|
+
| `int`, `long` | Number | — |
|
|
234
|
+
| `decimal` | Decimal | — |
|
|
235
|
+
| `bool` | CheckBox | — |
|
|
236
|
+
| `DateTime` | Calendar | — |
|
|
237
|
+
| `[SpiderlyEnum]` enum | Dropdown (translated, auto-populated) | model the value as `int`/`byte`/`long` instead if you do **not** want a dropdown |
|
|
238
|
+
| Navigation prop | Autocomplete | `[UIControlType(nameof(UIControlTypeCodes.Dropdown))]` |
|
|
239
|
+
|
|
240
|
+
Other `UIControlTypeCodes`: `ColorPicker`, `MultiAutocomplete`, `MultiSelect`, `Password`, `Table`.
|
|
241
|
+
|
|
242
|
+
Width: `[UIControlWidth("col-8 md:col-4")]` (default). TextArea/Editor/Markdown default to `"col-8"`.
|
|
243
|
+
|
|
244
|
+
`Editor` stores HTML (Quill WYSIWYG); `Markdown` stores raw Markdown (textarea + live preview). Both support inline image upload (paste, in Markdown's case) when combined with `[S3PublicStorage]`.
|
|
245
|
+
|
|
246
|
+
### Enum properties → translated dropdown
|
|
247
|
+
|
|
248
|
+
A property typed as a C# `enum` marked `[SpiderlyEnum]` auto-renders as a **dropdown**, populated client-side from the generated TS enum (no API round-trip) and labeled through Transloco.
|
|
249
|
+
|
|
250
|
+
```csharp
|
|
251
|
+
[SpiderlyEnum]
|
|
252
|
+
public enum AnnouncementSeverityCodes { Info = 1, Warning = 2, Critical = 3 }
|
|
253
|
+
|
|
254
|
+
public class Announcement : BusinessObject<long>
|
|
255
|
+
{
|
|
256
|
+
public AnnouncementSeverityCodes Severity { get; set; } // -> translated dropdown
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Rule of thumb: **a fixed set the user picks from → `[SpiderlyEnum]` enum** (you get a translated dropdown for free). **A coded value never shown as a choice → a raw numeric** (`int`/`byte`/`long`), which renders as a number field, or hide it with `[UIDoNotGenerate]`.
|
|
261
|
+
|
|
262
|
+
- **Translation key = the enum member name** (`Info`, `Warning`, `Critical`). The generator emits a `get{Enum}NamebookList(translocoService)` builder in `enums.generated.ts`; run `npm run i18n:extract` and fill each locale's value (e.g. `"Critical": "Kritično"`). A missing value renders the raw key, so don't skip this.
|
|
263
|
+
- **Break a label collision by renaming the member.** Two enums that both have `Pending` share one `Pending` key; if they need different wording, rename one member (e.g. `PendingReview`). The key follows the member name — no attribute required.
|
|
264
|
+
- **List-table enum column filter** reuses the same builder, wrapped in the `spiderly` helper `getPrimengNamebookOptions` (`Namebook[]` → the table's `{ label, code }[]`):
|
|
265
|
+
```ts
|
|
266
|
+
{ name: t('Severity'), filterType: 'multiselect', field: 'severity',
|
|
267
|
+
dropdownOrMultiselectValues:
|
|
268
|
+
getPrimengNamebookOptions(getAnnouncementSeverityCodesNamebookList(this.translocoService)) }
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
> Class-based enums (a `static class` of string constants, like `PermissionCodes`) are **not** usable as a dropdown property type — you can't type a property as a static class, so the field would be a bare `string` the generator can't recognize. Use a real `enum` for dropdown fields.
|
|
272
|
+
|
|
273
|
+
## Key Attributes Checklist
|
|
274
|
+
|
|
275
|
+
The complete list of every Spiderly attribute and its valid target is generated from the attribute classes themselves: see [references/attributes.generated.md](references/attributes.generated.md). The curated highlights below are the ones you'll reach for most.
|
|
276
|
+
|
|
277
|
+
| Attribute | Level | Purpose |
|
|
278
|
+
| --------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------- |
|
|
279
|
+
| `[DisplayName]` | Property | Marks the property shown in dropdowns/autocompletes |
|
|
280
|
+
| `[DisplayName("Entity.Prop")]` | Class | Display name from a related entity (e.g., `"User.Email"` — use plain string, **not** `nameof()`) |
|
|
281
|
+
| `[UIDoNotGenerate]` | Property/Class | Exclude from generated UI (template, frontend validators). Backend DTO + validation still generated. |
|
|
282
|
+
| `[UIControlWidth("col-X")]` | Property | Set form field width |
|
|
283
|
+
| `[UIOrderedOneToMany]` | Property | Enable drag-and-drop ordered child list |
|
|
284
|
+
| `[UIPropertyBlockOrder("N")]` | Property | Control field display order |
|
|
285
|
+
| `[UISection("Name")]` | Property | Group fields into named sections (cards) on the details page |
|
|
286
|
+
| `[DiskStorage]` | Property | File stored on local filesystem (dev only). Marks the property as a blob. |
|
|
287
|
+
| `[S3PublicStorage]` | Property | File stored in S3 with public CDN URL. Marks the property as a blob. |
|
|
288
|
+
| `[S3PrivateStorage]` | Property | File stored in S3 with private (signed-URL) access. Marks the property as a blob. |
|
|
289
|
+
| `[AcceptedFileTypes("mime/type", ...)]` | Property | **Required on every blob property** — whitelist upload MIME types. Build error `SPIDERLY014` if missing. No default. |
|
|
290
|
+
| `[MaxFileSize(N)]` | Property | Max upload size in bytes (default: 20MB) |
|
|
291
|
+
| `[ImageWidth(N)]` / `[ImageHeight(N)]` | Property | Validate exact image dimensions |
|
|
292
|
+
| `[DoNotAuthorize]` | Class | Skip authorization checks for this entity |
|
|
293
|
+
| `[Controller("Name")]` | Class | Group entity under a custom controller |
|
|
294
|
+
| `[ExcludeFromDTO]` | Property | Exclude from generated DTO |
|
|
295
|
+
| `[IncludeInDTO]` | Property | Force-include in DTO (e.g., collections) |
|
|
296
|
+
| `[ExcludeServiceMethodsFromGeneration]` | Property | Skip generated service methods (implement custom logic) |
|
|
297
|
+
| `[GreaterThanOrEqualTo(N)]` | Property | Numeric minimum validation |
|
|
298
|
+
| `[Email]` | Property | Email format validation |
|
|
299
|
+
| `[ProjectToDTO(".Map(...)")]` | Class | Custom Mapster projection |
|
|
300
|
+
| `[GenerateCommaSeparatedDisplayName]` | Property | Add comma-separated display names to DTO |
|
|
301
|
+
| `[ComplexManyToManyList]` | Property | Editable list UI for complex M2M junction (small sets only) |
|
|
302
|
+
| `[ComplexManyToManyReadonlyTable]` | Property | Read-only table for complex M2M junction |
|
|
303
|
+
| `[SimpleManyToManyTableLazyLoad]` | Property | Lazy-load M2M with table columns |
|
|
304
|
+
| `[UITableColumn(nameof(DTO.Field))]` | Property | Define columns for M2M table (use with above) |
|
|
305
|
+
|
|
306
|
+
## Complete Entity Example
|
|
307
|
+
|
|
308
|
+
```csharp
|
|
309
|
+
public class Product : BusinessObject<long>
|
|
310
|
+
{
|
|
311
|
+
[DisplayName]
|
|
312
|
+
[Required]
|
|
313
|
+
[StringLength(200, MinimumLength = 1)]
|
|
314
|
+
public string Title { get; set; }
|
|
315
|
+
|
|
316
|
+
[UIControlType(nameof(UIControlTypeCodes.Editor))]
|
|
317
|
+
[StringLength(10000, MinimumLength = 1)]
|
|
318
|
+
public string Description { get; set; }
|
|
319
|
+
|
|
320
|
+
[Required]
|
|
321
|
+
[GreaterThanOrEqualTo(0)]
|
|
322
|
+
public decimal Price { get; set; }
|
|
323
|
+
|
|
324
|
+
[Required]
|
|
325
|
+
[WithMany(nameof(Category.Products))]
|
|
326
|
+
public virtual Category Category { get; set; }
|
|
327
|
+
|
|
328
|
+
[WithMany(nameof(Brand.Products))]
|
|
329
|
+
public virtual Brand Brand { get; set; }
|
|
330
|
+
|
|
331
|
+
[S3PublicStorage]
|
|
332
|
+
[AcceptedFileTypes("image/*")]
|
|
333
|
+
[MaxFileSize(2_000_000)]
|
|
334
|
+
[StringLength(1000, MinimumLength = 1)]
|
|
335
|
+
public string MainImage { get; set; }
|
|
336
|
+
|
|
337
|
+
public virtual List<Tag> Tags { get; } = new();
|
|
338
|
+
|
|
339
|
+
[UIOrderedOneToMany]
|
|
340
|
+
public virtual List<ProductVariant> ProductVariants { get; } = new();
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## Diagnosing build failures
|
|
345
|
+
|
|
346
|
+
When the build dumps **hundreds of CS0246 errors about missing `*DTO` types**, scroll up and find the **SPIDERLY-prefixed error first**. Violating any contract (unsupported PK type, missing `[WithMany]` target, unsupported scalar, broken `[DisplayName]` path) makes `MapperGenerator` bail, which in turn deletes every entity's generated DTO — and *that* is what produces the CS0246 flood. The downstream errors are noise. Full diagnostic code reference: https://www.spiderly.dev/docs/build-diagnostics
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!-- GENERATED FROM framework-metadata.json — DO NOT EDIT.
|
|
2
|
+
Regenerate: `dotnet run --project Spiderly.MetadataExporter -- --out framework-metadata.json && node tools/extract-ts-metadata.mjs && node tools/gen-skill-docs.mjs` -->
|
|
3
|
+
|
|
4
|
+
# Spiderly attributes
|
|
5
|
+
|
|
6
|
+
| Attribute | Target | Description |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| `[AcceptedFileTypes]` | Property | Specifies the accepted file types for a blob property. When not applied, the file upload defaults to accepting images only (image/*). Use this attribute alongside any StorageAttribute subclass (e.g. [S3PublicStorage]) for non-image uploads (PDFs, Excel files, etc.). |
|
|
9
|
+
| `[AuthGuard]` | Class, Method | Provides authentication protection for API endpoints by validating JWT tokens in the request. |
|
|
10
|
+
| `[CascadeDelete]` | Property | Implements cascade delete behavior in many-to-one relationships. When the referenced entity is deleted, all entities that reference it will automatically be deleted as well. This attribute is useful when: - Child entities should not exist without their parent |
|
|
11
|
+
| `[ComplexManyToManyList]` | Property | Generates an editable list UI for complex many-to-many relationships (junction tables with additional fields). Shows ALL entities from the "other side" with editable junction fields. No add/remove/reorder controls. Warning: This loads all "other side" entities into the form. Suitable for small sets (e.g., 3 warehouses), not for large sets (e.g., thousands of entities). |
|
|
12
|
+
| `[ComplexManyToManyReadonlyTable]` | Property | Just showing the complex (with additional fields) M2M relationship in a table form |
|
|
13
|
+
| `[Controller]` | Class | Specifies a custom controller name for an entity, overriding the default naming convention. This attribute allows grouping multiple related entities under a single controller. Default behavior without 'Controller' attribute: Controllers are named as '{EntityName}Controller' |
|
|
14
|
+
| `[DiskStorage]` | Property | Routes upload/delete operations for the decorated string property through DiskStorageService, storing files under the local filesystem. Intended for local development; not recommended for production deployments where the host filesystem is ephemeral or shared across replicas. |
|
|
15
|
+
| `[DisplayName]` | Class, Property | Specifies which property should be used as the display name for an entity in UI elements: - When applied to a property: The property's value will be used to represent the entity - When applied to a class: The specified property's value will be used to represent the entity - If no property or class is marked with this attribute: The entity's 'Id' will be used |
|
|
16
|
+
| `[DoNotAuthorize]` | Class | Disables authorization checks for CRUD operations on the decorated entity. By default, all entities require authorization for CRUD operations. Warning: This attribute bypasses security checks and should be used with extreme caution. It is primarily intended for testing purposes and should generally be avoided in production environments. |
|
|
17
|
+
| `[Email]` | Property | Validates that a string property value is a valid email address. This attribute provides both server-side and client-side validation. |
|
|
18
|
+
| `[ExcludeFromDTO]` | Property | Specifies that a property should be excluded from the generated DTO. |
|
|
19
|
+
| `[ExcludeFromExcelExport]` | Property | Specifies that a property should be excluded from the generated Excel export (the Export{Entity}ListToExcel column set), while remaining present in the DTO and the rest of the API/UI. Use it for internal/technical columns that are noise in a human-readable sheet (raw foreign-key ids, gateway correlation data, sync plumbing, etc.). Place it on the property that produces the column: a scalar property excludes the matching DTO column; a many-to-one navigation property excludes the generated {Nav}DisplayName (and the synthesized {Nav}Id when there is no explicit foreign-key scalar). Differs from ExcludeFromDTOAttribute, which removes the property from the DTO entirely (no API exposure at all). This one only hides the column from the Excel export. Placement controls which column disappears, so for a many-to-one you can drop just the raw id while keeping the human-readable name (or vice-versa): Putting it on the navigation property instead (OrderStatus) hides OrderStatusDisplayName, and also OrderStatusId only when no explicit foreign-key scalar like the one above is declared. |
|
|
20
|
+
| `[ExcludeServiceMethodsFromGeneration]` | Property | Prevents the generation of standard service methods for the decorated property in the {Entity}ServiceGenerated class. Use this attribute when you want to: - Implement custom business logic instead of using generated methods - Override the default generated behavior with your own implementation - Exclude specific properties from the standard service method generation Note: The property will still be part of the entity, but no service methods will be generated for it in the {Entity}ServiceGenerated class. |
|
|
21
|
+
| `[GenerateCommaSeparatedDisplayName]` | Property | Generates a string property in the DTO containing comma-separated display names for a collection property in the entity. |
|
|
22
|
+
| `[GreaterThanOrEqualTo]` | Property | Validates that a numeric property value is greater than or equal to a specified number. This attribute provides both server-side and client-side validation. |
|
|
23
|
+
| `[ImageHeight]` | Property | Validates exact image height for a blob property. This attribute provides both server-side and client-side validation. Use this attribute alongside any StorageAttribute subclass for image uploads. |
|
|
24
|
+
| `[ImageWidth]` | Property | Validates exact image width for a blob property. This attribute provides both server-side and client-side validation. Use this attribute alongside any StorageAttribute subclass for image uploads. |
|
|
25
|
+
| `[IncludeInDTO]` | Property | Specifies that a property should be included in the generated DTO. This attribute is particularly useful for enumerable properties, which are not included in DTOs by default. Note: This attribute only affects DTO generation and does not influence the mapping behavior (Entity to DTO and vice versa). |
|
|
26
|
+
| `[M2M]` | Class | Indicates that the entity represents a helper table for a many-to-many (M2M) relationship. |
|
|
27
|
+
| `[M2MWithMany]` | Property | Marks a property in a many-to-many (M2M) relationship. |
|
|
28
|
+
| `[MaxFileSize]` | Property | Specifies the maximum allowed file size (in bytes) for a blob property. When not applied, the file upload defaults to 20 MB (20,000,000 bytes). Use this attribute alongside any StorageAttribute subclass for file uploads. |
|
|
29
|
+
| `[Output]` | All | Specifies the output configuration for the Source Generator. Note: This is a temporary solution and may be replaced in future versions. |
|
|
30
|
+
| `[ProjectToDTO]` | Class | Specifies custom mapping configuration when projecting an entity to its DTO. |
|
|
31
|
+
| `[S3PrivateStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PrivateStorageService. The column stores an opaque S3 key; access is expected to be mediated via signed URLs or a backend proxy rather than direct CDN retrieval. Intended for files that contain personal or compliance-sensitive data (warranty receipts, ID documents, customer-uploaded invoices). |
|
|
32
|
+
| `[S3PublicStorage]` | Property | Routes upload/delete operations for the decorated string property through S3PublicStorageService. The bucket is configured for public access and the column stores a fully-qualified CDN URL; objects are uploaded with Cache-Control: public, max-age=31536000, immutable. |
|
|
33
|
+
| `[SetNull]` | Property | Specifies that the property should be set to null when the parent entity is deleted. Apply this attribute to a many-to-one relationship property. |
|
|
34
|
+
| `[ShowSpinner]` | All | Forces the global full-screen loading spinner ON for the decorated controller method, overriding the generator's auto-skip inference. The explicit attribute always wins over inference. You rarely need this. The spinner is shown by default; you only reach for this attribute to re-enable it on a call the generator would otherwise auto-skip — namely a deliberately slow HttpGet that returns a bare scalar (which is auto-skipped because such reads are normally instant). If your endpoint does real work, it is usually a POST — and POSTs keep the spinner without any attribute, so prefer the correct verb over this. Do not put this on the read-shaped responses (NamebookDTO, CodebookDTO, PaginatedResultDTO, LazyLoadSelectedIdsResultDTO): those power autocomplete, dropdowns and table pagination, where a full-screen blackout on every keystroke / page change is a regression. (The attribute still wins there if you insist — it just shouldn't be used that way.) Example (a slow scalar read where the blocking overlay is wanted): |
|
|
35
|
+
| `[SimpleManyToManyTableLazyLoad]` | Property | Specifies that a table items for the many-to-many relationship administration should be loaded lazily (on-demand) rather than eagerly. |
|
|
36
|
+
| `[SkipSpinner]` | All | Indicates that the global full-screen loading spinner should be skipped for the decorated controller method. You usually don't need this. The generator already skips the spinner automatically for read-shaped responses (NamebookDTO, CodebookDTO, PaginatedResultDTO, LazyLoadSelectedIdsResultDTO) and for any HttpGet that returns a bare scalar (int, long, bool, decimal, DateTime, …). Reach for this attribute only when the inference can't see your intent. For the inverse — forcing the spinner back ON for a call the inference auto-skips — use ShowSpinnerAttribute. Use when: - A GET returns a full DTO but is polled / refreshed on a timer (e.g. a dashboard fetched every minute) - You want to implement custom loading behavior - The operation runs in the background Example (a polled DTO — a bare-scalar count GET like GetUnreadNotificationsCountForCurrentUser would be auto-skipped without this): |
|
|
37
|
+
| `[SpiderlyController]` | Class | Marks a class as a Spiderly custom controller. Source generators only enroll classes carrying this attribute. |
|
|
38
|
+
| `[SpiderlyDTO]` | Class | Marks a hand-written DTO class for inclusion in the Spiderly pipeline. Generated DTOs ({Entity}DTO, {Entity}SaveBodyDTO, {Entity}MainUIFormDTO) do not need this attribute. |
|
|
39
|
+
| `[SpiderlyDataMapper]` | Class | Marks a class as a hand-written Spiderly data mapper. Source generators enroll classes carrying this attribute when composing Mapster configuration with user-provided overrides. |
|
|
40
|
+
| `[SpiderlyEntity]` | Class | Marks a class as a Spiderly entity. Source generators only enroll classes carrying this attribute. |
|
|
41
|
+
| `[SpiderlyEnum]` | Class, Enum | Marks a C# enum or a class-based enum (static class of string constants) as a Spiderly enum. Source generators enroll enums carrying this attribute when emitting Angular enum definitions. |
|
|
42
|
+
| `[SpiderlyService]` | Class | Marks a hand-written entity service as a Spiderly service. Source generators enroll classes carrying this attribute when composing DI registration and dependency lookups. The class is still expected to extend the generated {Entity}ServiceGenerated base. |
|
|
43
|
+
| `[UIAdditionalPermissionCodeForInsert]` | Class | Specifies additional permission requirements for inserting entities in the UI. The user must have ONE of the specified permissions to perform the insert operation. Multiple instances of this attribute can be applied to a single entity. |
|
|
44
|
+
| `[UIAdditionalPermissionCodeForUpdate]` | Class | Specifies additional permission requirements for updating entities in the UI. The user must have ONE of the specified permissions to perform the update operation. Multiple instances of this attribute can be applied to a single entity. |
|
|
45
|
+
| `[UIControlType]` | Property | Specifies the UI control type for a property. If not specified, the control type is automatically determined based on the property type: - string: TextBox (or TextArea if [StringLength] value is large) - int/long: Number - decimal: Decimal - bool: CheckBox - DateTime: Calendar - many-to-one: Autocomplete |
|
|
46
|
+
| `[UIControlWidth]` | Property | Specifies the width of a UI field using Spiderly column classes (from spiderly-grid). Default values: - "col-8" for TextArea and Editor controls - "col-8 md:col-4" for all other controls |
|
|
47
|
+
| `[UIDoNotGenerate]` | Class, Method, Property | Apply to a property to exclude it from the UI form. Apply to an entity to exclude the entire UI form generation. Apply to a controller method to exclude the UI controller method generation. |
|
|
48
|
+
| `[UIOrderedOneToMany]` | Property | Enables management of child entities through an ordered list in the parent entity's main UI form component. |
|
|
49
|
+
| `[UIPropertyBlockOrder]` | Property | Specifies the display order of UI controls. Controls are displayed in the order of property declaration, except for: 'file', 'text-area', 'editor', and 'table' controls, which are always displayed last in their declaration order. |
|
|
50
|
+
| `[UISection]` | Property | Groups the property into a named section (card) on the generated details page. All properties sharing the same section name render together inside one panel, in the order the properties are declared; sections themselves are ordered by first appearance (the position of the first property that declares the section). Properties without this attribute collapse into a single implicit, headerless section positioned by the same first-appearance rule — so a newly added property always shows up automatically, either in its declared section or the implicit one. The argument is a Transloco translation key used as the section header (e.g. "Security" resolves through t('Security')), matching how other generated panel titles are translated. Backward compatibility: if no property on the entity declares this attribute, the details page renders as before (a single panel with one grid). Sectioning activates only when at least one property is annotated. |
|
|
51
|
+
| `[UITableColumn]` | Property | Specifies which columns should be displayed in a table view for a many-to-many relationship. Must be used in combination with [SimpleManyToManyTableLazyLoad] attribute. |
|
|
52
|
+
| `[WithMany]` | Property | Specifies the collection navigation property name in a related entity for establishing a bidirectional relationship in Entity Framework. Purpose: This attribute is used to define the inverse navigation property in a relationship, enabling proper relationship configuration and navigation in both directions. |
|
|
53
|
+
| `[WithOne]` | Property | Declares a one-to-one relationship. Place on the dependent (foreign-key-holding) side's single-valued reference navigation. Its presence designates this side as the dependent; the other side is the principal. Required vs optional: add [Required] to make the dependent's FK non-nullable ("dependent must have a principal"). Omit it for an optional 1-1 (nullable FK, many NULLs allowed). The schema cannot enforce "principal must have a dependent" — that direction is always 0..1. Unidirectional: use the parameterless constructor when the principal has no back-navigation. |
|