@appxdigital/appx-core 0.1.128-beta.4 → 0.1.128-beta.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/CHANGELOG.md +1 -1
- package/dist/common/decorators/field-requires.decorator.d.ts +11 -11
- package/dist/common/decorators/field-requires.decorator.d.ts.map +1 -1
- package/dist/common/decorators/field-requires.decorator.js +13 -13
- package/dist/graphql/field-requires.registry.d.ts +27 -0
- package/dist/graphql/field-requires.registry.d.ts.map +1 -0
- package/dist/graphql/field-requires.registry.js +96 -0
- package/dist/graphql/generic.resolver.d.ts.map +1 -1
- package/dist/graphql/generic.resolver.js +68 -52
- package/dist/graphql/graphql.module.d.ts.map +1 -1
- package/dist/graphql/graphql.module.js +5 -1
- package/docs/graphql.md +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -29,7 +29,7 @@ Read-only GraphQL, reworked into a **namespaced, opt-in-per-module** API.
|
|
|
29
29
|
- **Nested selection is to-one only.** Generated GraphQL model types expose scalars, **to-one** relations, and `_count`; **to-many relations are not nested-selectable** (nested lists have no pagination, so they could return unbounded rows). Fetch a list at the top level with pagination — `track { find(where: { albumId … }, take, skip) }`. Filtering by a to-many relation in `where` (`some`/`every`/`none`) and `_count { … }` still work.
|
|
30
30
|
- **Self-documenting operations.** `find` / `get` / `count` and their `where` arg carry GraphQL descriptions (visible in the Apollo Sandbox); model-field descriptions come from Prisma `///` doc comments as before. See [docs/graphql.md](./docs/graphql.md) for the full `where`/`orderBy`/pagination reference.
|
|
31
31
|
- **Malformed filter values return `400`, not `500`.** A `where`/`orderBy` value Prisma can't parse (e.g. `createdAt: { gt: 2024 }` — a number where a date is expected) now surfaces a clean `400`, instead of an internal error that leaked the raw Prisma invocation.
|
|
32
|
-
- **Output shaping via native field resolvers.** Transform a field on the way out (sign a stored key into a URL, mask, format) or add a computed field with a `@Resolver(() => Model)` + `@ResolveField`, registered in the module. A field resolver reads its source columns from `@Parent()`; ABAC field omission still runs at query time, so a transform never sees a `@Role`-restricted column. New **`@FieldRequires('col')`** (one column, several, or an array) declares which columns a
|
|
32
|
+
- **Output shaping via native field resolvers.** Transform a field on the way out (sign a stored key into a URL, mask, format) or add a computed field with a `@Resolver(() => Model)` + `@ResolveField`, registered in the module. A field resolver reads its source columns from `@Parent()`; ABAC field omission still runs at query time, so a transform never sees a `@Role`-restricted column. New **`@FieldRequires('col')`** (one column, several, or an array) declares which columns a field reads so the generated `find`/`get` fetches exactly those — for a computed field, an in-place override, **and a field on a nested relation** (the source columns are resolved from a bootstrap registry, so declarations apply at every level). Undeclared field resolvers fall back to fetching the model's scalar columns; relations always stay selection-driven. See [docs/graphql.md](./docs/graphql.md) → *Shaping read output*.
|
|
33
33
|
- **GraphQL introspection on in development.** The Apollo Sandbox (`GET /graphql`) needs introspection, which Apollo Server v4 disables under `NODE_ENV=production` (`INTROSPECTION_DISABLED`). It is now enabled in development and disabled in production by default (defense-in-depth — introspection exposes schema shape, not data, which ABAC still governs). Opt in for a production deployment with `APPX_GRAPHQL_INTROSPECTION=true`.
|
|
34
34
|
|
|
35
35
|
### Fixed
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Reflect metadata key under which {@link FieldRequires} records a field
|
|
3
|
+
* resolver's source columns. Read at bootstrap by the GraphQL field-requires
|
|
4
|
+
* scanner (see `field-requires.registry.ts`) to build the model→field→columns
|
|
5
|
+
* registry the read resolver consults.
|
|
6
6
|
*/
|
|
7
|
-
export declare const
|
|
7
|
+
export declare const FIELD_REQUIRES_METADATA = "appx:field_requires";
|
|
8
8
|
/**
|
|
9
9
|
* Declare the model columns a custom GraphQL `@ResolveField` reads from its
|
|
10
10
|
* `@Parent()`, so the generated `find` / `get` fetches those columns even when
|
|
11
|
-
* the client didn't select them.
|
|
11
|
+
* the client didn't select them — at the top level and on nested relations.
|
|
12
12
|
*
|
|
13
|
-
* Use it on a computed field whose value derives from a
|
|
14
|
-
* `coverUrl` built from a stored `coverKey`. Without it,
|
|
15
|
-
* client selected are fetched and `@Parent().coverKey` would
|
|
13
|
+
* Use it on a computed field or an in-place transform whose value derives from a
|
|
14
|
+
* column, e.g. a signed `coverUrl` built from a stored `coverKey`. Without it,
|
|
15
|
+
* only the columns the client selected are fetched and `@Parent().coverKey` would
|
|
16
|
+
* be `undefined`.
|
|
16
17
|
*
|
|
17
18
|
* - Declared → only the listed columns are fetched for the field.
|
|
18
19
|
* - Not declared → all of the model's scalar columns are fetched.
|
|
19
|
-
* - Not needed for an in-place transform that reuses an existing field's name.
|
|
20
20
|
*
|
|
21
21
|
* ABAC applies: a column the caller's role may not read is omitted at query time,
|
|
22
22
|
* so the resolver sees it as absent — a transform can never surface a
|
|
@@ -46,5 +46,5 @@ export declare const FIELD_REQUIRES_EXTENSION = "requires";
|
|
|
46
46
|
* }
|
|
47
47
|
* ```
|
|
48
48
|
*/
|
|
49
|
-
export declare const FieldRequires: (...columns: (string | string[])[]) =>
|
|
49
|
+
export declare const FieldRequires: (...columns: (string | string[])[]) => import("@nestjs/common").CustomDecorator<string>;
|
|
50
50
|
//# sourceMappingURL=field-requires.decorator.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"field-requires.decorator.d.ts","sourceRoot":"","sources":["../../../src/common/decorators/field-requires.decorator.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,
|
|
1
|
+
{"version":3,"file":"field-requires.decorator.d.ts","sourceRoot":"","sources":["../../../src/common/decorators/field-requires.decorator.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,wBAAwB,CAAC;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,aAAa,eAAgB,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC,EAAE,qDACP,CAAC"}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.FieldRequires = exports.
|
|
4
|
-
const
|
|
3
|
+
exports.FieldRequires = exports.FIELD_REQUIRES_METADATA = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Reflect metadata key under which {@link FieldRequires} records a field
|
|
7
|
+
* resolver's source columns. Read at bootstrap by the GraphQL field-requires
|
|
8
|
+
* scanner (see `field-requires.registry.ts`) to build the model→field→columns
|
|
9
|
+
* registry the read resolver consults.
|
|
10
10
|
*/
|
|
11
|
-
exports.
|
|
11
|
+
exports.FIELD_REQUIRES_METADATA = 'appx:field_requires';
|
|
12
12
|
/**
|
|
13
13
|
* Declare the model columns a custom GraphQL `@ResolveField` reads from its
|
|
14
14
|
* `@Parent()`, so the generated `find` / `get` fetches those columns even when
|
|
15
|
-
* the client didn't select them.
|
|
15
|
+
* the client didn't select them — at the top level and on nested relations.
|
|
16
16
|
*
|
|
17
|
-
* Use it on a computed field whose value derives from a
|
|
18
|
-
* `coverUrl` built from a stored `coverKey`. Without it,
|
|
19
|
-
* client selected are fetched and `@Parent().coverKey` would
|
|
17
|
+
* Use it on a computed field or an in-place transform whose value derives from a
|
|
18
|
+
* column, e.g. a signed `coverUrl` built from a stored `coverKey`. Without it,
|
|
19
|
+
* only the columns the client selected are fetched and `@Parent().coverKey` would
|
|
20
|
+
* be `undefined`.
|
|
20
21
|
*
|
|
21
22
|
* - Declared → only the listed columns are fetched for the field.
|
|
22
23
|
* - Not declared → all of the model's scalar columns are fetched.
|
|
23
|
-
* - Not needed for an in-place transform that reuses an existing field's name.
|
|
24
24
|
*
|
|
25
25
|
* ABAC applies: a column the caller's role may not read is omitted at query time,
|
|
26
26
|
* so the resolver sees it as absent — a transform can never surface a
|
|
@@ -50,5 +50,5 @@ exports.FIELD_REQUIRES_EXTENSION = 'requires';
|
|
|
50
50
|
* }
|
|
51
51
|
* ```
|
|
52
52
|
*/
|
|
53
|
-
const FieldRequires = (...columns) => (0,
|
|
53
|
+
const FieldRequires = (...columns) => (0, common_1.SetMetadata)(exports.FIELD_REQUIRES_METADATA, columns.flat());
|
|
54
54
|
exports.FieldRequires = FieldRequires;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
|
|
3
|
+
/**
|
|
4
|
+
* Look up a field resolver on a model. Returns `undefined` if the field is not a
|
|
5
|
+
* custom `@ResolveField` (i.e. a plain column), otherwise `{ requires }` where
|
|
6
|
+
* `requires` is the declared columns or `null` if none were declared.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getFieldRequires(model: string, field: string): {
|
|
9
|
+
requires: string[] | null;
|
|
10
|
+
} | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Scans every resolver provider once at startup and records each `@ResolveField`
|
|
13
|
+
* as `Model.field` → its `@FieldRequires` columns. Runs after all providers are
|
|
14
|
+
* instantiated, so field resolvers registered in any feature module are included.
|
|
15
|
+
*
|
|
16
|
+
* This scan (not a per-request read) is required because the code that builds the
|
|
17
|
+
* Prisma query — the generated `find`/`get` — must know the source columns of
|
|
18
|
+
* field resolvers on OTHER classes and nested relation models, not just the
|
|
19
|
+
* executing handler.
|
|
20
|
+
*/
|
|
21
|
+
export declare class FieldRequiresScanner implements OnModuleInit {
|
|
22
|
+
private readonly discovery;
|
|
23
|
+
private readonly scanner;
|
|
24
|
+
constructor(discovery: DiscoveryService, scanner: MetadataScanner);
|
|
25
|
+
onModuleInit(): void;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=field-requires.registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"field-requires.registry.d.ts","sourceRoot":"","sources":["../../src/graphql/field-requires.registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,YAAY,EAAC,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAC,gBAAgB,EAAE,eAAe,EAAC,MAAM,cAAc,CAAC;AAqB/D;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAC,GAAG,SAAS,CAMtG;AAED;;;;;;;;;GASG;AACH,qBACa,oBAAqB,YAAW,YAAY;IAEjD,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADP,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE,eAAe;IAG7C,YAAY,IAAI,IAAI;CA+BvB"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.FieldRequiresScanner = exports.getFieldRequires = void 0;
|
|
13
|
+
const common_1 = require("@nestjs/common");
|
|
14
|
+
const core_1 = require("@nestjs/core");
|
|
15
|
+
const field_requires_decorator_1 = require("../common/decorators/field-requires.decorator");
|
|
16
|
+
// NestJS GraphQL reflect keys (set by @Resolver / @ResolveField). Reading them is
|
|
17
|
+
// how we recover, for each field resolver, the model it belongs to and the field
|
|
18
|
+
// it resolves — including in-place overrides, whose declared columns never reach
|
|
19
|
+
// the built schema field.
|
|
20
|
+
const RESOLVER_NAME_METADATA = 'graphql:resolver_name';
|
|
21
|
+
const RESOLVER_PROPERTY_METADATA = 'graphql:resolve_property';
|
|
22
|
+
/**
|
|
23
|
+
* `Model.field` → the columns the field resolver reads, or `null` when the field
|
|
24
|
+
* is a custom resolver that declared nothing (an undeclared resolver means "fetch
|
|
25
|
+
* this model's scalars, we can't know what it reads"). Absence of a key means the
|
|
26
|
+
* field is not a custom resolver at all (a plain column).
|
|
27
|
+
*
|
|
28
|
+
* Populated once at bootstrap by {@link FieldRequiresScanner}; read by the
|
|
29
|
+
* generated read resolver when building the Prisma `select`.
|
|
30
|
+
*/
|
|
31
|
+
const registry = new Map();
|
|
32
|
+
/**
|
|
33
|
+
* Look up a field resolver on a model. Returns `undefined` if the field is not a
|
|
34
|
+
* custom `@ResolveField` (i.e. a plain column), otherwise `{ requires }` where
|
|
35
|
+
* `requires` is the declared columns or `null` if none were declared.
|
|
36
|
+
*/
|
|
37
|
+
function getFieldRequires(model, field) {
|
|
38
|
+
const key = `${model}.${field}`;
|
|
39
|
+
if (!registry.has(key)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return { requires: registry.get(key) ?? null };
|
|
43
|
+
}
|
|
44
|
+
exports.getFieldRequires = getFieldRequires;
|
|
45
|
+
/**
|
|
46
|
+
* Scans every resolver provider once at startup and records each `@ResolveField`
|
|
47
|
+
* as `Model.field` → its `@FieldRequires` columns. Runs after all providers are
|
|
48
|
+
* instantiated, so field resolvers registered in any feature module are included.
|
|
49
|
+
*
|
|
50
|
+
* This scan (not a per-request read) is required because the code that builds the
|
|
51
|
+
* Prisma query — the generated `find`/`get` — must know the source columns of
|
|
52
|
+
* field resolvers on OTHER classes and nested relation models, not just the
|
|
53
|
+
* executing handler.
|
|
54
|
+
*/
|
|
55
|
+
let FieldRequiresScanner = exports.FieldRequiresScanner = class FieldRequiresScanner {
|
|
56
|
+
constructor(discovery, scanner) {
|
|
57
|
+
this.discovery = discovery;
|
|
58
|
+
this.scanner = scanner;
|
|
59
|
+
}
|
|
60
|
+
onModuleInit() {
|
|
61
|
+
registry.clear();
|
|
62
|
+
for (const wrapper of this.discovery.getProviders()) {
|
|
63
|
+
const instance = wrapper.instance;
|
|
64
|
+
if (!instance || typeof instance !== 'object') {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// The model a resolver is bound to: @Resolver(() => Model) records the
|
|
68
|
+
// model name on the class under RESOLVER_NAME_METADATA.
|
|
69
|
+
const model = Reflect.getMetadata(RESOLVER_NAME_METADATA, instance.constructor);
|
|
70
|
+
if (!model || typeof model !== 'string') {
|
|
71
|
+
continue; // not a model-bound resolver (root resolvers, other providers)
|
|
72
|
+
}
|
|
73
|
+
const prototype = Object.getPrototypeOf(instance);
|
|
74
|
+
if (!prototype) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
for (const methodName of this.scanner.getAllMethodNames(prototype)) {
|
|
78
|
+
const handler = prototype[methodName];
|
|
79
|
+
if (typeof handler !== 'function') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (Reflect.getMetadata(RESOLVER_PROPERTY_METADATA, handler) !== true) {
|
|
83
|
+
continue; // not a @ResolveField
|
|
84
|
+
}
|
|
85
|
+
const field = Reflect.getMetadata(RESOLVER_NAME_METADATA, handler) || methodName;
|
|
86
|
+
const requires = Reflect.getMetadata(field_requires_decorator_1.FIELD_REQUIRES_METADATA, handler);
|
|
87
|
+
registry.set(`${model}.${field}`, Array.isArray(requires) ? requires : null);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
exports.FieldRequiresScanner = FieldRequiresScanner = __decorate([
|
|
93
|
+
(0, common_1.Injectable)(),
|
|
94
|
+
__metadata("design:paramtypes", [core_1.DiscoveryService,
|
|
95
|
+
core_1.MetadataScanner])
|
|
96
|
+
], FieldRequiresScanner);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"generic.resolver.d.ts","sourceRoot":"","sources":["../../src/graphql/generic.resolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAC,IAAI,EAAC,MAAM,iBAAiB,CAAC;AAoIrC;;;;GAIG;AACH,MAAM,WAAW,kBAAkB,CAAC,SAAS,GAAG,GAAG;IAC/C,iFAAiF;IACjF,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;IACvB,gFAAgF;IAChF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oDAAoD;IACpD,YAAY,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACxB,oDAAoD;IACpD,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IACzB,8DAA8D;IAC9D,UAAU,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,kBAAkB,CAAC,SAAS,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,CAuE/F"}
|
|
@@ -18,7 +18,7 @@ const common_1 = require("@nestjs/common");
|
|
|
18
18
|
const plugins_1 = require("@paljs/plugins");
|
|
19
19
|
const graphql_2 = require("graphql");
|
|
20
20
|
const prisma_service_1 = require("../prisma/prisma.service");
|
|
21
|
-
const
|
|
21
|
+
const field_requires_registry_1 = require("./field-requires.registry");
|
|
22
22
|
/**
|
|
23
23
|
* Run a read and translate a Prisma **validation** error into a `400`. A
|
|
24
24
|
* malformed `where`/`orderBy` value (e.g. `createdAt: { gt: 2024 }` — a number
|
|
@@ -39,83 +39,99 @@ async function readOrBadRequest(op) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
42
|
+
* Build the Prisma `select` for a read so that every custom `@ResolveField` — at
|
|
43
|
+
* the top level and on nested relations — receives its source columns in
|
|
44
|
+
* `@Parent()`.
|
|
44
45
|
*
|
|
45
|
-
* `PrismaSelect` narrows
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
46
|
+
* `PrismaSelect` narrows each query to the columns the GraphQL request selected,
|
|
47
|
+
* which breaks the standard NestJS contract that a `@ResolveField` receives the
|
|
48
|
+
* full entity: a transform or computed field can't read a column the client
|
|
49
|
+
* didn't also select, and on a nested relation not even its own key is
|
|
50
|
+
* guaranteed. Using the `@FieldRequires` registry (populated at bootstrap), each
|
|
51
|
+
* selected field that is a custom resolver contributes its declared source
|
|
52
|
+
* columns; a resolver that declared nothing falls back to the model's scalar
|
|
53
|
+
* columns. Plain columns are fetched as-is, so a query with no custom fields is
|
|
54
|
+
* exactly what was asked for. Relations stay selection-driven — only selected
|
|
55
|
+
* relations are fetched, and each is recursed into so its own resolvers get their
|
|
56
|
+
* columns too. ABAC field omission runs afterward in the proxy and strips
|
|
57
|
+
* `@Role`-restricted columns at select-time, so a caller — and a transform — only
|
|
58
|
+
* ever sees readable columns.
|
|
52
59
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* When declared, ONLY those columns are added — no over-fetch.
|
|
57
|
-
* - A custom field that declares nothing falls back to fetching all of the
|
|
58
|
-
* model's scalar columns (safe default: the resolver can read anything from
|
|
59
|
-
* `@Parent()`), at the cost of reading columns the query didn't select.
|
|
60
|
-
* - A query that selects no custom fields fetches exactly what was asked for.
|
|
61
|
-
*
|
|
62
|
-
* Relations stay selection-driven throughout (no unbounded relation fetching).
|
|
63
|
-
* ABAC field omission runs afterward in the proxy and strips `@Role`-restricted
|
|
64
|
-
* columns at select-time, so a caller still only ever receives — and a transform
|
|
65
|
-
* only ever sees — the columns their role may read.
|
|
66
|
-
*
|
|
67
|
-
* Custom field names (no backing column) are dropped from the Prisma `select`:
|
|
68
|
-
* `PrismaSelect` puts every requested GraphQL field there, and passing a name
|
|
69
|
-
* Prisma doesn't recognise throws a validation error. They resolve from the
|
|
70
|
-
* parent afterward, so they must not reach the query.
|
|
60
|
+
* Custom field names with no backing column are dropped: `PrismaSelect` puts every
|
|
61
|
+
* requested GraphQL field into the `select`, and passing a name Prisma doesn't
|
|
62
|
+
* recognise throws a validation error. They resolve from the parent afterward.
|
|
71
63
|
*/
|
|
72
64
|
function selectReadColumns(select, info, prisma, modelName) {
|
|
73
|
-
const scalars = new Set(prisma.getScalarFields(modelName));
|
|
74
|
-
const requested = select?.select && typeof select.select === 'object' ? select.select : {};
|
|
75
|
-
// The GraphQL type of the returned rows, to read each field's
|
|
76
|
-
// `extensions.requires` (find → [Model], get → Model; getNamedType unwraps).
|
|
77
65
|
const named = (0, graphql_2.getNamedType)(info.returnType);
|
|
78
|
-
const
|
|
66
|
+
const type = named instanceof graphql_2.GraphQLObjectType ? named : null;
|
|
67
|
+
const requested = select?.select && typeof select.select === 'object' ? select.select : {};
|
|
68
|
+
const resolved = resolveLevelColumns(requested, type, prisma, modelName);
|
|
69
|
+
return { ...(select && typeof select === 'object' ? select : {}), select: resolved };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Per-level worker for {@link selectReadColumns}: resolve the columns fetched for
|
|
73
|
+
* one model, recursing into each selected relation against its own model type.
|
|
74
|
+
*/
|
|
75
|
+
function resolveLevelColumns(requested, type, prisma, modelName) {
|
|
76
|
+
const scalars = prisma.getScalarFields(modelName);
|
|
77
|
+
const scalarSet = new Set(scalars);
|
|
78
|
+
const fields = type ? type.getFields() : undefined;
|
|
79
79
|
const out = {};
|
|
80
|
-
const
|
|
81
|
-
let
|
|
80
|
+
const needed = new Set();
|
|
81
|
+
let fetchAllScalars = false;
|
|
82
82
|
for (const [key, value] of Object.entries(requested)) {
|
|
83
83
|
if (value && typeof value === 'object') {
|
|
84
|
-
|
|
84
|
+
// A relation or `_count`. Recurse into a known relation model so its
|
|
85
|
+
// own field resolvers get their source columns (a relation's GraphQL
|
|
86
|
+
// type name is its Prisma model name); keep `_count`/opaque verbatim.
|
|
87
|
+
const relField = fields?.[key];
|
|
88
|
+
if (relField && value.select && typeof value.select === 'object') {
|
|
89
|
+
const relType = (0, graphql_2.getNamedType)(relField.type);
|
|
90
|
+
if (relType instanceof graphql_2.GraphQLObjectType && prisma.getScalarFields(relType.name).length) {
|
|
91
|
+
out[key] = {
|
|
92
|
+
...value,
|
|
93
|
+
select: resolveLevelColumns(value.select, relType, prisma, relType.name),
|
|
94
|
+
};
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
out[key] = value;
|
|
99
|
+
continue;
|
|
85
100
|
}
|
|
86
|
-
|
|
87
|
-
out[key] = value; // a real scalar column
|
|
101
|
+
if (scalarSet.has(key)) {
|
|
102
|
+
out[key] = value; // a real scalar column — keep even if also overridden by a resolver
|
|
88
103
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
for (const col of requires) {
|
|
95
|
-
if (
|
|
96
|
-
|
|
104
|
+
// Is this field a custom @ResolveField on this model (new field OR in-place
|
|
105
|
+
// override)? The registry knows, and knows its declared source columns.
|
|
106
|
+
const entry = (0, field_requires_registry_1.getFieldRequires)(modelName, key);
|
|
107
|
+
if (entry) {
|
|
108
|
+
if (entry.requires) {
|
|
109
|
+
for (const col of entry.requires) {
|
|
110
|
+
if (scalarSet.has(col))
|
|
111
|
+
needed.add(col);
|
|
97
112
|
}
|
|
98
113
|
}
|
|
99
114
|
else {
|
|
100
|
-
|
|
115
|
+
fetchAllScalars = true; // a resolver that declared nothing — fetch all scalars
|
|
101
116
|
}
|
|
102
117
|
}
|
|
118
|
+
// else: a plain column (already added above) or an unknown non-column
|
|
119
|
+
// field name — dropped; it resolves from @Parent() afterward.
|
|
103
120
|
}
|
|
104
|
-
if (
|
|
121
|
+
if (fetchAllScalars) {
|
|
105
122
|
for (const field of scalars)
|
|
106
123
|
out[field] = true;
|
|
107
124
|
}
|
|
108
125
|
else {
|
|
109
|
-
for (const col of
|
|
126
|
+
for (const col of needed)
|
|
110
127
|
out[col] = true;
|
|
111
128
|
}
|
|
112
|
-
//
|
|
113
|
-
// — an empty Prisma `select` is invalid; fall back to all scalars.
|
|
129
|
+
// An empty select is invalid Prisma; fall back to the model's scalar columns.
|
|
114
130
|
if (Object.keys(out).length === 0) {
|
|
115
131
|
for (const field of scalars)
|
|
116
132
|
out[field] = true;
|
|
117
133
|
}
|
|
118
|
-
return
|
|
134
|
+
return out;
|
|
119
135
|
}
|
|
120
136
|
/**
|
|
121
137
|
* Build a **namespaced, read-only** GraphQL resolver for one model.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"graphql.module.d.ts","sourceRoot":"","sources":["../../src/graphql/graphql.module.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,qBACa,oBAAoB;IAK7B,QAAQ,IAAI,MAAM;CAGrB;AAED,qBA0Ba,aAAa;CAAG"}
|
|
@@ -11,8 +11,10 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.GraphqlModule = exports.AppxCoreRootResolver = void 0;
|
|
13
13
|
const common_1 = require("@nestjs/common");
|
|
14
|
+
const core_1 = require("@nestjs/core");
|
|
14
15
|
const graphql_1 = require("@nestjs/graphql");
|
|
15
16
|
const apollo_1 = require("@nestjs/apollo");
|
|
17
|
+
const field_requires_registry_1 = require("./field-requires.registry");
|
|
16
18
|
/**
|
|
17
19
|
* Always-present root query. GraphQL requires a non-empty `Query` type, but a
|
|
18
20
|
* project may register `GraphqlModule` before any model has opted in (models
|
|
@@ -41,6 +43,8 @@ let GraphqlModule = exports.GraphqlModule = class GraphqlModule {
|
|
|
41
43
|
exports.GraphqlModule = GraphqlModule = __decorate([
|
|
42
44
|
(0, common_1.Module)({
|
|
43
45
|
imports: [
|
|
46
|
+
// DiscoveryService/MetadataScanner power the @FieldRequires bootstrap scan.
|
|
47
|
+
core_1.DiscoveryModule,
|
|
44
48
|
graphql_1.GraphQLModule.forRoot({
|
|
45
49
|
driver: apollo_1.ApolloDriver,
|
|
46
50
|
context: ({ req, res }) => ({ req, res }),
|
|
@@ -60,6 +64,6 @@ exports.GraphqlModule = GraphqlModule = __decorate([
|
|
|
60
64
|
process.env.APPX_GRAPHQL_INTROSPECTION === 'true',
|
|
61
65
|
}),
|
|
62
66
|
],
|
|
63
|
-
providers: [AppxCoreRootResolver],
|
|
67
|
+
providers: [AppxCoreRootResolver, field_requires_registry_1.FieldRequiresScanner],
|
|
64
68
|
})
|
|
65
69
|
], GraphqlModule);
|
package/docs/graphql.md
CHANGED
|
@@ -214,9 +214,9 @@ coverUrl(@Parent() p: Project) { … }
|
|
|
214
214
|
fullName(@Parent() p: Project) { … }
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
-
- **Declared** → only those columns are fetched for the field.
|
|
217
|
+
- **Declared** → only those columns are fetched for the field. Works the same for a computed field, an in-place override, and a field on a nested relation.
|
|
218
218
|
- **Not declared** → all of the model's scalar columns are fetched, so the field can read any of them from its parent.
|
|
219
|
-
- **In-place transform
|
|
219
|
+
- **In-place transform reading only its own column** → needs no declaration; that column is fetched whenever the client selects the field. Declare `@FieldRequires` for any *other* columns it reads.
|
|
220
220
|
|
|
221
221
|
Read from `@Parent()`, not the raw Prisma client — re-querying bypasses ABAC. For key-to-URL transforms, reuse the existing field's name so the raw stored value never leaves the server.
|
|
222
222
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appxdigital/appx-core",
|
|
3
|
-
"version": "0.1.128-beta.
|
|
3
|
+
"version": "0.1.128-beta.5",
|
|
4
4
|
"description": "Appx Core is a library that provides a set of tools to help you build your application faster.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|