nicot 1.2.4 → 1.2.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/README-CN.md CHANGED
@@ -197,6 +197,240 @@ meta: SomeJSONType; // ?meta={"foo":"bar"} → 对象
197
197
 
198
198
  在 OpenAPI 里,这些字段仍以 string 展示;在实际运行时,它们已经被转换为你想要的类型。
199
199
 
200
+ ---
201
+
202
+ ## 🔐 Binding Context(数据绑定 / 多租户隔离)
203
+
204
+ 在实际的业务系统中,后端经常需要根据“当前用户 / 当前租户 / 当前 App”等上下文,对数据进行自动隔离:
205
+
206
+ - 一个用户只能看到自己的数据
207
+ - 不同 App 的数据不能相互越界
208
+ - 更新 / 删除操作必须自动附带权限条件
209
+ - 不希望每个 Controller/Service 都写重复的 `qb.andWhere(...)`
210
+
211
+ NICOT 提供了 **BindingColumn / BindingValue / useBinding / beforeSuper / RequestScope Provider**,
212
+ 让多租户隔离变成 **实体级声明**,和 DTO / Query / Lifecycle 保持一致。
213
+
214
+ ---
215
+
216
+ ### 1. BindingColumn — 声明“这个字段必须被绑定”
217
+
218
+ 当某个字段的值应该由后端上下文(而不是前端请求)决定时,应使用 `@BindingColumn`。
219
+
220
+ 示例:
221
+
222
+ ```ts
223
+ @Entity()
224
+ class Article extends IdBase() {
225
+ @BindingColumn() // 默认 bindingKey: "default"
226
+ @IntColumn('int')
227
+ userId: number;
228
+
229
+ @BindingColumn('app') // bindingKey: "app"
230
+ @IntColumn('int')
231
+ appId: number;
232
+ }
233
+ ```
234
+
235
+ 含义:
236
+
237
+ - **Create**:NICOT 会自动写入绑定值,无需前端提供
238
+ - **FindAll**:NICOT 会自动在 WHERE 中加入 userId/appId 条件
239
+ - **Update/Delete**:NICOT 会自动加上绑定条件,防止越权修改
240
+ - 这是“多租户字段”或“业务隔离字段”的最直接声明方式
241
+
242
+ 这样做的好处:
243
+
244
+ - 权限隔离逻辑不会散落在 controller/service 里
245
+ - Entity = Contract → 数据隔离是实体的一部分
246
+ - 自动生成的控制器天然具备隔离能力
247
+
248
+ ---
249
+
250
+ ### 2. BindingValue — 绑定值的来源(Service 层)
251
+
252
+ BindingColumn 声明了“需要绑定的字段”,
253
+ BindingValue 声明“绑定值从哪里来”。
254
+
255
+ 示例:
256
+
257
+ ```ts
258
+ @Injectable()
259
+ class ArticleService extends CrudService(Article) {
260
+ @BindingValue() // 对应 BindingColumn()
261
+ get currentUserId() {
262
+ return this.ctx.userId;
263
+ }
264
+
265
+ @BindingValue('app')
266
+ get currentAppId() {
267
+ return this.ctx.appId;
268
+ }
269
+ }
270
+ ```
271
+
272
+ BindingValue 可以定义成:
273
+
274
+ - 方法(NICOT 会自动调用)
275
+ - getter 属性
276
+
277
+ 它们会在 CRUD pre-phase 被收集成:
278
+
279
+ - create:强制写入字段
280
+ - findAll/update/delete:用于 WHERE 条件
281
+
282
+ 优先级高于前端传入值。
283
+
284
+ ---
285
+
286
+ ### 3. useBinding — 本次调用临时覆盖绑定值
287
+
288
+ 适合:
289
+
290
+ - 测试
291
+ - CLI 脚本
292
+ - 内部批处理任务
293
+ - 覆盖默认绑定行为
294
+
295
+ 示例:
296
+
297
+ ```ts
298
+ service
299
+ .useBinding(7) // 覆盖 bindingKey = default
300
+ .useBinding(44, 'app') // 覆盖 bindingKey = "app"
301
+ .findAll({});
302
+ ```
303
+
304
+ 特点:
305
+
306
+ - 覆盖值仅对当前一次方法调用有效
307
+ - 不影响同一 service 的其他并发请求
308
+ - 可与 BindingValue 合并
309
+ - 可用于 request-scope provider 不存在时的替代方案
310
+
311
+ ---
312
+
313
+ ### 4. beforeSuper — Override 场景的并发安全机制(高级用法)
314
+
315
+ 如果你 override `findAll` / `update` / `delete` 并插入 `await`,
316
+ 可能打乱绑定上下文的使用时序(因为 Service 是 singleton)。
317
+
318
+ NICOT 提供 `beforeSuper` 方法,确保绑定上下文在 override 内不会被并发污染:
319
+
320
+ ```ts
321
+ override async findAll(...args) {
322
+ await this.beforeSuper(async () => {
323
+ await doSomethingSlow();
324
+ });
325
+ return super.findAll(...args);
326
+ }
327
+ ```
328
+
329
+ 机制:
330
+
331
+ 1. freeze 当前 binding 上下文
332
+ 2. 执行 override 的 async 逻辑
333
+ 3. restore binding
334
+ 4. 再交给 CrudBase 做正式的 CRUD 处理
335
+
336
+ 这是一个高级能力,不是普通用户需要接触的 API。
337
+
338
+ ---
339
+
340
+ ### 5. Request-scope Provider(推荐的绑定来源模式)
341
+
342
+ 推荐使用 NestJS 的 request-scope provider 自动提供绑定上下文。
343
+ 绑定值自然来自当前 HTTP 请求:
344
+
345
+ - userId 来自认证信息
346
+ - appId 来自 header
347
+ - tenantId 来自域名
348
+ - ……
349
+
350
+ #### 5.1 使用 `createProvider` 构造 request-scope binding provider
351
+
352
+ ```ts
353
+ export const BindingContextProvider = createProvider(
354
+ {
355
+ provide: 'BindingContext',
356
+ scope: Scope.REQUEST, // ⭐ 每个请求一份独立上下文
357
+ inject: [REQUEST, AuthService] as const,
358
+ },
359
+ async (req, auth) => {
360
+ const user = await auth.getUserFromRequest(req);
361
+ return {
362
+ userId: user.id,
363
+ appId: Number(req.headers['x-app-id']),
364
+ };
365
+ },
366
+ );
367
+ ```
368
+
369
+ `createProvider` 会自动推断 `(req, auth)` 的类型。
370
+
371
+ #### 5.2 在 Service 中注入 BindingContext
372
+
373
+ ```ts
374
+ @Injectable()
375
+ class ArticleService extends CrudService(Article) {
376
+ constructor(
377
+ @Inject('BindingContext')
378
+ private readonly ctx: { userId: number; appId: number },
379
+ ) {
380
+ super(repo);
381
+ }
382
+
383
+ @BindingValue()
384
+ get currentUserId() {
385
+ return this.ctx.userId;
386
+ }
387
+
388
+ @BindingValue('app')
389
+ get currentAppId() {
390
+ return this.ctx.appId;
391
+ }
392
+ }
393
+ ```
394
+
395
+ 效果:
396
+
397
+ - Service 仍然可以是 singleton
398
+ - BindingValue 一律从 per-request binding context 读取
399
+ - 完全并发安全
400
+
401
+ 这是 NICOT 官方推荐的绑定方式。
402
+
403
+ ---
404
+
405
+ ### 6. Binding 工作流程(流程概览)
406
+
407
+ 1. 用户调用 Service(可能使用 `useBinding` 覆盖)
408
+ 2. CrudBase pre-phase:收集所有 BindingValue
409
+ 3. 合并 request-scope provider / useBinding / 默认值
410
+ 4. 构造 PartialEntity(绑定字段 → 绑定值)
411
+ 5. create:强制写入字段
412
+ 6. findAll/update/delete:自动注入 WHERE 条件
413
+ 7. 执行实体生命周期钩子
414
+ 8. 返回经过 ResultDTO 剪裁的结果
415
+
416
+ Binding 系统与 NICOT 的 CRUD 生命周期保持一致,也可自由组合和继承。
417
+
418
+ ---
419
+
420
+ ### 小结
421
+
422
+ Binding 系统提供了:
423
+
424
+ - `@BindingColumn`:声明需要绑定的字段
425
+ - `@BindingValue`:绑定值的来源
426
+ - `useBinding`:单次调用级覆盖
427
+ - `beforeSuper`:override 时保证并发安全
428
+ - request-scope provider:推荐的绑定上下文提供方式,彻底避免并发污染
429
+
430
+ 这套机制让 NICOT 在保持自动化 CRUD 的同时,也能优雅支持多租户隔离、权限隔离与上下文驱动业务逻辑。
431
+
432
+
433
+
200
434
  ---
201
435
 
202
436
  ## Relations 与 @RelationComputed
package/README.md CHANGED
@@ -550,6 +550,264 @@ Recommended:
550
550
 
551
551
  ---
552
552
 
553
+ ## Binding Context (Data Binding & Multi-Tenant Isolation)
554
+
555
+ In real systems, you often need to isolate data by *context*:
556
+
557
+ - current user
558
+ - current tenant / app
559
+ - current organization / project
560
+
561
+ Typical rules:
562
+
563
+ - A user can only see their own rows.
564
+ - Updates/deletes must be scoped by ownership.
565
+ - You don’t want to copy-paste `qb.andWhere('userId = :id', ...)` everywhere.
566
+
567
+ NICOT provides **BindingColumn / BindingValue / useBinding / beforeSuper** on top of `CrudBase` so that
568
+ *multi-tenant isolation* becomes part of the **entity contract**, not scattered per-controller logic.
569
+
570
+ ---
571
+
572
+ ### BindingColumn — declare “this field must be bound”
573
+
574
+ Use `@BindingColumn` on entity fields that should be filled and filtered by the backend context,
575
+ instead of coming from the client payload.
576
+
577
+ ```ts
578
+ @Entity()
579
+ export class Article extends IdBase() {
580
+ @BindingColumn() // default bindingKey: "default"
581
+ @IntColumn('int', { unsigned: true })
582
+ userId: number;
583
+
584
+ @BindingColumn('app') // bindingKey: "app"
585
+ @IntColumn('int', { unsigned: true })
586
+ appId: number;
587
+ }
588
+ ```
589
+
590
+ NICOT will:
591
+
592
+ - on `create`:
593
+ - write binding values into `userId` / `appId` (if provided)
594
+ - on `findAll`:
595
+ - automatically add `WHERE userId = :value` / `appId = :value`
596
+ - on `update` / `delete`:
597
+ - add the same binding conditions, preventing cross-tenant access
598
+
599
+ Effectively: **binding columns are your “ownership / tenant” fields**.
600
+
601
+ ---
602
+
603
+ ### BindingValue — where the binding values come from
604
+
605
+ `@BindingValue` is placed on service properties or methods that provide the actual binding values.
606
+
607
+ ```ts
608
+ @Injectable()
609
+ class ArticleService extends CrudService(Article) {
610
+ constructor(@InjectRepository(Article) repo: Repository<Article>) {
611
+ super(repo);
612
+ }
613
+
614
+ @BindingValue() // for BindingColumn()
615
+ get currentUserId() {
616
+ return this.ctx.userId;
617
+ }
618
+
619
+ @BindingValue('app') // for BindingColumn('app')
620
+ get currentAppId() {
621
+ return this.ctx.appId;
622
+ }
623
+ }
624
+ ```
625
+
626
+ At runtime, NICOT will:
627
+
628
+ - collect all `BindingValue` metadata
629
+ - build a partial entity `{ userId, appId, ... }`
630
+ - use it to:
631
+ - fill fields on `create`
632
+ - add `WHERE` conditions on `findAll`, `update`, `delete`
633
+
634
+ If both client payload and BindingValue provide a value, **BindingValue wins** for binding columns.
635
+
636
+ > You can use:
637
+ > - properties (sync)
638
+ > - getters
639
+ > - methods (sync)
640
+ > - async methods
641
+ > NICOT will await async BindingValues when necessary.
642
+
643
+ ---
644
+
645
+ ### Request-scoped context provider (recommended)
646
+
647
+ The “canonical” way to provide binding values in a web app is:
648
+
649
+ 1. Extract context (user, app, tenant, etc.) from the incoming request.
650
+ 2. Put it into a **request-scoped provider**.
651
+ 3. Have `@BindingValue` simply read from that provider.
652
+
653
+ This keeps:
654
+
655
+ - context lifetime = request lifetime
656
+ - services as singletons
657
+ - binding logic centralized and testable
658
+
659
+ #### 1) Define a request-scoped binding context
660
+
661
+ Using `createProvider` from **nesties**, you can declare a strongly-typed request-scoped provider:
662
+
663
+ ```ts
664
+ export const BindingContextProvider = createProvider(
665
+ {
666
+ provide: 'BindingContext',
667
+ scope: Scope.REQUEST, // ⭐ one instance per HTTP request
668
+ inject: [REQUEST, AuthService] as const,
669
+ },
670
+ async (req, auth) => {
671
+ const user = await auth.getUserFromRequest(req);
672
+ return {
673
+ userId: user.id,
674
+ appId: Number(req.headers['x-app-id']),
675
+ };
676
+ },
677
+ );
678
+ ```
679
+
680
+ Key points:
681
+
682
+ - `scope: Scope.REQUEST` → each request has its own context instance.
683
+ - `inject: [REQUEST, AuthService]` → you can pull anything you need to compute bindings.
684
+ - `createProvider` infers `(req, auth)` types automatically.
685
+
686
+ #### 2) Inject the context into your service and expose BindingValues
687
+
688
+ ```ts
689
+ @Injectable()
690
+ class ArticleService extends CrudService(Article) {
691
+ constructor(
692
+ @InjectRepository(Article) repo: Repository<Article>,
693
+ @Inject('BindingContext')
694
+ private readonly ctx: { userId: number; appId: number },
695
+ ) {
696
+ super(repo);
697
+ }
698
+
699
+ @BindingValue()
700
+ get currentUserId() {
701
+ return this.ctx.userId;
702
+ }
703
+
704
+ @BindingValue('app')
705
+ get currentAppId() {
706
+ return this.ctx.appId;
707
+ }
708
+ }
709
+ ```
710
+
711
+ With this setup:
712
+
713
+ - each request gets its own `{ userId, appId }` context
714
+ - `@BindingValue` simply reads from that context
715
+ - `CrudBase` applies bindings for create / findAll / update / delete automatically
716
+ - controllers do **not** need to repeat `userId` conditions
717
+
718
+ This is the **recommended** way to use binding in a NestJS HTTP app.
719
+
720
+ ---
721
+
722
+ ### useBinding — override binding per call
723
+
724
+ For tests, scripts, or some internal flows, you may want to override binding values *per call*
725
+ instead of relying on `@BindingValue`.
726
+
727
+ Use `useBinding` for this:
728
+
729
+ ```ts
730
+ // create with explicit binding
731
+ const res = await articleService
732
+ .useBinding(7) // bindingKey: "default"
733
+ .useBinding(44, 'app') // bindingKey: "app"
734
+ .create({ name: 'Article 1' });
735
+
736
+ // query in the same binding scope
737
+ const list = await articleService
738
+ .useBinding(7)
739
+ .useBinding(44, 'app')
740
+ .findAll({});
741
+ ```
742
+
743
+ Key properties:
744
+
745
+ - override is **per call**, not global
746
+ - multiple concurrent calls with different `useBinding` values are isolated
747
+ - merges with `@BindingValue` (explicit `useBinding` can override default BindingValue)
748
+
749
+ This is particularly handy in unit tests and CLI scripts.
750
+
751
+ ---
752
+
753
+ ### beforeSuper — safe overrides with async logic (advanced)
754
+
755
+ `CrudService` subclasses are singletons, but bindings are *per call*.
756
+
757
+ If you override `findAll` / `update` / `delete` and add `await` **before** calling `super`,
758
+ you can accidentally mess with binding order / concurrency.
759
+
760
+ NICOT offers `beforeSuper` as a small helper:
761
+
762
+ ```ts
763
+ @Injectable()
764
+ class SlowArticleService extends ArticleService {
765
+ override async findAll(
766
+ ...args: Parameters<typeof ArticleService.prototype.findAll>
767
+ ) {
768
+ await this.beforeSuper(async () => {
769
+ // any async work before delegating to CrudBase
770
+ await new Promise((resolve) => setTimeout(resolve, 100));
771
+ });
772
+ return super.findAll(...args);
773
+ }
774
+ }
775
+ ```
776
+
777
+ What `beforeSuper` ensures:
778
+
779
+ 1. capture (freeze) current binding state
780
+ 2. run your async pre-logic
781
+ 3. restore binding state
782
+ 4. continue into `CrudBase` with the correct bindings
783
+
784
+ This is an **advanced** hook; most users don’t need it. For typical per-request isolation, prefer request-scoped context + `@BindingValue`.
785
+
786
+ ---
787
+
788
+ ### How Binding works inside CrudBase
789
+
790
+ On each CRUD operation, NICOT does roughly:
791
+
792
+ 1. collect `BindingValue` from the service (properties / getters / methods / async methods)
793
+ 2. merge with `useBinding(...)` overlays
794
+ 3. build a “binding partial entity”
795
+ 4. apply it to:
796
+ - `create`: force binding fields
797
+ - `findAll` / `update` / `delete`: add binding-based `WHERE` conditions
798
+ 5. continue with:
799
+ - `beforeGet` / `beforeUpdate` / `beforeCreate`
800
+ - query decorators (`@QueryXXX`)
801
+ - pagination
802
+ - relations
803
+
804
+ You can think of Binding as **“automatic ownership filters”** configured declaratively on:
805
+
806
+ - entities (`@BindingColumn`)
807
+ - services (`@BindingValue`, `useBinding`, `beforeSuper`, request-scoped context)
808
+
809
+ ---
810
+
553
811
  ## Pagination
554
812
 
555
813
  ### Offset pagination (default)