Skip to content

Commit 5ae3f10

Browse files
committed
增强钩子上下文,支持在更新和删除操作中访问先前数据
1 parent 94a316a commit 5ae3f10

3 files changed

Lines changed: 169 additions & 56 deletions

File tree

docs/guide/logic-hooks.md

Lines changed: 158 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,184 @@
1-
# Logic: Hooks (Triggers)
1+
# Logic Hooks
22

3-
Hooks allow you to inject business logic into the database lifecycle. They are powerful, typed, and context-aware.
3+
Hooks (often called "Triggers" in SQL databases) allow you to intercept database operations to inject custom logic. They are transaction-aware and fully typed.
44

5-
## 1. File Structure
6-
Hooks live in `*.hook.ts` files alongside your object definitions. The loader automatically binds them based on the filename.
5+
## 1. Registration Methods
76

8-
* `objects/todo.object.yml`
9-
* `objects/todo.hook.ts`
7+
You can define hooks in two ways: **File-based** (Static) or **Programmatic** (Dynamic).
108

11-
## 2. Defining Hooks
9+
### A. File-based (Recommended)
10+
Place a `*.hook.ts` file next to your object definition. The loader automatically discovers it.
1211

13-
Export a default object that satisfies the `ObjectHookDefinition<T>` interface.
12+
**File:** `src/objects/project.hook.ts`
1413

1514
```typescript
16-
// objects/todo.hook.ts
1715
import { ObjectHookDefinition } from '@objectql/types';
18-
import { Todo } from './types'; // Generated types
19-
20-
const hooks: ObjectHookDefinition<Todo> = {
21-
22-
// Validate data before insertion
23-
beforeCreate: async ({ data, user }) => {
24-
if (!data.title) {
25-
throw new Error("Title is required");
26-
}
27-
data.owner_id = user?.id;
28-
},
2916

30-
// Check state changes before update
31-
// 'previousData' is automatically fetched for you!
32-
beforeUpdate: async ({ id, data, previousData, isModified }) => {
33-
if (isModified('status')) {
34-
if (previousData.status === 'Archived') {
35-
throw new Error("Cannot modify archived todos");
36-
}
37-
}
17+
const hooks: ObjectHookDefinition = {
18+
beforeCreate: async (ctx) => {
19+
// ...
3820
},
39-
40-
// Side-effects after successful commitment
41-
afterCreate: async ({ result, api }) => {
42-
await api.create('log', {
43-
message: `New Todo Created: ${result.title}`
44-
});
21+
afterUpdate: async (ctx) => {
22+
// ...
4523
}
46-
}
24+
};
4725

4826
export default hooks;
4927
```
5028

29+
### B. Programmatic (Dynamic)
30+
Use the `app.on()` API, typically inside a [Plugin](./plugins.md).
31+
32+
```typescript
33+
app.on('before:create', 'project', async (ctx) => {
34+
// ...
35+
});
36+
37+
// Wildcard listener
38+
app.on('after:delete', '*', async (ctx) => {
39+
console.log(`Object ${ctx.objectName} deleted record ${ctx.id}`);
40+
});
41+
```
42+
43+
## 2. Event Lifecycle
44+
45+
| Event Name | Description | Common Use Case |
46+
| :--- | :--- | :--- |
47+
| `before:create` | Before inserting a new record. | Validation, Default Values, ID generation. |
48+
| `after:create` | After insertion is committed. | Notifications, downstream sync. |
49+
| `before:update` | Before modifying an existing record. | Permission checks, Immutable field protection. |
50+
| `after:update` | After modification is committed. | Audit logging, history tracking. |
51+
| `before:delete` | Before removing a record. | Referential integrity checks. |
52+
| `after:delete` | After removal is committed. | Clean up related resources (e.g. S3 files). |
53+
| `before:find` | Before executing a query. | **Row-Level Security (RLS)**, Force filters. |
54+
| `after:find` | After fetching results. | Decryption, Sensitive data masking. |
55+
5156
## 3. The Hook Context
5257

53-
The context passed to your function is **Operation-Specific**.
58+
The context object (`ctx`) changes based on the event type.
59+
60+
### Common Properties (Available Everywhere)
61+
62+
| Property | Type | Description |
63+
| :--- | :--- | :--- |
64+
| `objectName` | `string` | The name of the object being operated on. |
65+
| `user` | `ObjectQLUser` | Current user session/context. |
66+
| `broker` | `IStation` | (If Microservices enabled) Station broker instance. |
67+
68+
### Mutation Context (Create/Update/Delete)
69+
70+
| Property | Type | Available In | Description |
71+
| :--- | :--- | :--- | :--- |
72+
| `data` | `Any` | Create/Update | The data payload being written. **Mutable**. |
73+
| `id` | `string` | Update/Delete | The ID of the record being acted upon. |
74+
| `previousData` | `Any` | Update/Delete | The existing record fetched from DB before operation. |
75+
| `result` | `Any` | After * | The final result returned from the driver. |
5476

55-
| Property | Available In | Description |
77+
### Query Context (Find)
78+
79+
| Property | Type | Description |
5680
| :--- | :--- | :--- |
57-
| `api` | All | Restricted driver API to perform DB operations (`find`, `create`, etc). |
58-
| `user` | All | The current user session. |
59-
| `state` | All | Shared storage to pass data from `before` -> `after` hooks. |
60-
| `data` | Create/Update | The payload being written. |
61-
| `previousData` | Update | The record as it exists in DB *before* this update. |
62-
| `isModified(field)`| Update | Helper to check if a field is changing. |
63-
| `query` | Find/Count | The query AST. Useful for row-level security. |
81+
| `query` | `steedos-filters` | The query AST (filters, fields, sort). **Mutable**. |
82+
| `result` | `Any[]` | (After Find) The array of records found. **Mutable**. |
83+
84+
## 4. Common Patterns & Examples
6485

65-
### Row-Level Security Example
86+
### A. Validation & Default Values
87+
Throwing an error inside a `before` hook aborts the transaction.
88+
89+
```typescript
90+
beforeCreate: async ({ data, user }) => {
91+
if (data.amount < 0) {
92+
throw new Error("Amount cannot be negative");
93+
}
94+
// Set default owner if not provided
95+
if (!data.owner) {
96+
data.owner = user.userId;
97+
}
98+
}
99+
```
100+
101+
### B. Immutable Fields Protection
102+
Prevent users from changing critical fields during update.
103+
104+
```typescript
105+
beforeUpdate: async ({ data, previousData }) => {
106+
if (data.code !== undefined && data.code !== previousData.code) {
107+
throw new Error("Cannot change project code once created.");
108+
}
109+
}
110+
```
111+
112+
### C. Row-Level Security (RLS)
113+
The most secure place to enforce permissions is `before:find`. This injects filters into *every* query (API, GraphQL, or internal).
66114

67115
```typescript
68116
beforeFind: async ({ query, user }) => {
69-
// Forcefully filter all queries to only show user's own data
70-
if (!user.isAdmin) {
71-
query.filters.push(['owner_id', '=', user.id]);
117+
if (!user.is_admin) {
118+
// Enforce: owners can only see their own records
119+
// Merging into existing filters
120+
query.filters = [
121+
(query.filters || []),
122+
['owner', '=', user.userId]
123+
];
72124
}
73125
}
74126
```
127+
128+
### D. Side Effects (Notifications)
129+
Use `after` hooks for logic that strictly relies on success.
130+
131+
```typescript
132+
afterCreate: async ({ data, objectName }) => {
133+
await NotificationService.send({
134+
to: data.owner,
135+
message: `New ${objectName} created.`
136+
});
137+
}
138+
```
139+
140+
### E. Result Masking
141+
Hide sensitive fields based on rules.
142+
143+
```typescript
144+
afterFind: async ({ result, user }) => {
145+
if (!user.has_permission('view_salary')) {
146+
result.forEach(record => {
147+
delete record.salary;
148+
delete record.bonus;
149+
});
150+
}
151+
}
152+
```
153+
154+
### F. Auto-Numbering / ID Generation
155+
Generate complex business keys.
156+
157+
```typescript
158+
beforeCreate: async ({ data }) => {
159+
if (!data.code) {
160+
data.code = await SequenceService.next('PROJECT_CODE');
161+
}
162+
}
163+
```
164+
165+
### G. Conditional Deletion
166+
Use `previousData` in delete hooks to prevent deleting records based on their state.
167+
168+
```typescript
169+
beforeDelete: async ({ previousData, user }) => {
170+
// Prevent deletion if project is active
171+
if (previousData.status === 'active') {
172+
throw new Error("Cannot delete an active project. Archive it first.");
173+
}
174+
}
175+
```
176+
177+
## 5. Transaction Safety
178+
179+
Hooks participate in the database transaction.
180+
* If a `before` hook throws -> The DB operation is never executed.
181+
* If the DB operation fails -> `after` hooks are never executed.
182+
* If an `after` hook throws -> **The entire transaction rolls back** (including the DB write).
183+
184+
> **Tip:** If you want a "Fire and Forget" action that shouldn't rollback the transaction (e.g. sending an email), wrap your logic in a `try/catch` or execute it without `await`.

packages/core/src/repository.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class ObjectRepository {
122122
}
123123

124124
async update(id: string | number, doc: any, options?: any): Promise<any> {
125+
const previousData = await this.findOne(id);
125126
const hookCtx: UpdateHookContext = {
126127
...this.context,
127128
objectName: this.objectName,
@@ -130,6 +131,7 @@ export class ObjectRepository {
130131
api: this.getHookAPI(),
131132
id,
132133
data: doc,
134+
previousData,
133135
isModified: (field) => hookCtx.data ? Object.prototype.hasOwnProperty.call(hookCtx.data, field) : false
134136
};
135137
await this.app.triggerHook('beforeUpdate', this.objectName, hookCtx);
@@ -142,13 +144,15 @@ export class ObjectRepository {
142144
}
143145

144146
async delete(id: string | number): Promise<any> {
147+
const previousData = await this.findOne(id);
145148
const hookCtx: MutationHookContext = {
146149
...this.context,
147150
objectName: this.objectName,
148151
operation: 'delete',
149152
state: {},
150153
api: this.getHookAPI(),
151-
id
154+
id,
155+
previousData
152156
};
153157
await this.app.triggerHook('beforeDelete', this.objectName, hookCtx);
154158

packages/types/src/hook.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ export interface MutationHookContext<T = any> extends BaseHookContext<T> {
8585
* Only available in 'after' hooks.
8686
*/
8787
result?: T;
88+
89+
/**
90+
* The existing record fetched from DB before operation.
91+
* Available in 'update' and 'delete' hooks.
92+
*/
93+
previousData?: T;
8894
}
8995

9096
/**
@@ -93,13 +99,6 @@ export interface MutationHookContext<T = any> extends BaseHookContext<T> {
9399
export interface UpdateHookContext<T = any> extends MutationHookContext<T> {
94100
operation: 'update';
95101

96-
/**
97-
* The record state BEFORE the update.
98-
* Useful for comparison logic (e.g. status changed from A to B).
99-
* Note: This may require a pre-fetch lookup depending on engine configuration.
100-
*/
101-
previousData?: T;
102-
103102
/**
104103
* Helper to check if a specific field is being modified.
105104
* Checks if the field exists in 'data' AND is different from 'previousData'.

0 commit comments

Comments
 (0)