@pattern-stack/codegen 0.6.8 → 0.7.0
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/dist/src/cli/index.js +516 -73
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/index.d.ts +208 -1
- package/dist/src/index.js +147 -0
- package/dist/src/index.js.map +1 -1
- package/package.json +1 -1
- package/src/patterns/library/base-junction-fields.ts +32 -0
- package/src/patterns/library/index.ts +7 -0
- package/src/patterns/library/junction.pattern.ts +41 -0
- package/templates/entity/new/backend/application/queries/get-by-id.ejs.t +3 -3
- package/templates/entity/new/backend/application/queries/grouped-index.ejs.t +5 -5
- package/templates/entity/new/backend/application/queries/index.ejs.t +3 -0
- package/templates/entity/new/backend/application/queries/list.ejs.t +3 -3
- package/templates/entity/new/backend/application/queries/relationships.queries.ejs.t +147 -0
- package/templates/entity/new/backend/database/repository.ejs.t +36 -176
- package/templates/entity/new/backend/domain/entity.ejs.t +0 -44
- package/templates/entity/new/backend/domain/grouped-index.ejs.t +4 -60
- package/templates/entity/new/backend/domain/index.ejs.t +2 -2
- package/templates/entity/new/backend/domain/repository-interface.ejs.t +16 -17
- package/templates/entity/new/backend/modules/core/module.ejs.t +10 -0
- package/templates/entity/new/backend/presentation/controller.ejs.t +2 -34
- package/templates/entity/new/clean-lite-ps/entity.ejs.t +15 -2
- package/templates/entity/new/clean-lite-ps/module.ejs.t +27 -2
- package/templates/entity/new/clean-lite-ps/prompt-extension.js +72 -5
- package/templates/entity/new/clean-lite-ps/repository.ejs.t +33 -1
- package/templates/entity/new/clean-lite-ps/service.ejs.t +79 -0
- package/templates/entity/new/prompt.js +1 -0
- package/templates/junction/new/_inject-parent-module-clp-left.ejs.t +8 -0
- package/templates/junction/new/_inject-parent-module-clp-right.ejs.t +8 -0
- package/templates/junction/new/_inject-parent-module-import-clp-left.ejs.t +9 -0
- package/templates/junction/new/_inject-parent-module-import-clp-right.ejs.t +9 -0
- package/templates/junction/new/_inject-parent-service-clp-left.ejs.t +51 -0
- package/templates/junction/new/_inject-parent-service-clp-right.ejs.t +48 -0
- package/templates/junction/new/_inject-parent-service-import-clp-left.ejs.t +11 -0
- package/templates/junction/new/_inject-parent-service-import-clp-right.ejs.t +11 -0
- package/templates/junction/new/entity.ejs.t +111 -0
- package/templates/junction/new/index.ejs.t +15 -0
- package/templates/junction/new/module.ejs.t +37 -0
- package/templates/junction/new/prompt.js +492 -0
- package/templates/junction/new/repository.ejs.t +67 -0
- package/templates/junction/new/service.ejs.t +174 -0
|
@@ -16,23 +16,6 @@ force: true
|
|
|
16
16
|
<% if (hasEntityRefFields) { -%>
|
|
17
17
|
import type { EntityType } from '<%= locations.dbSchemaServer.import %>';
|
|
18
18
|
<% } -%>
|
|
19
|
-
<%
|
|
20
|
-
// Collect unique non-self-referential imports
|
|
21
|
-
const importedEntities = new Set();
|
|
22
|
-
[...belongsToRelations, ...hasManyRelations, ...hasOneRelations].forEach((rel) => {
|
|
23
|
-
if (rel.target !== name) {
|
|
24
|
-
importedEntities.add(rel.target);
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
-%>
|
|
28
|
-
<% if (importedEntities.size > 0) { -%>
|
|
29
|
-
|
|
30
|
-
<% importedEntities.forEach((target) => {
|
|
31
|
-
const targetClass = target.charAt(0).toUpperCase() + target.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
32
|
-
-%>
|
|
33
|
-
import type { <%= targetClass %> } from '../<%= target %>/<%= target %>.entity';
|
|
34
|
-
<% }) -%>
|
|
35
|
-
<% } -%>
|
|
36
19
|
|
|
37
20
|
export class <%= className %> {
|
|
38
21
|
constructor(
|
|
@@ -51,28 +34,12 @@ export class <%= className %> {
|
|
|
51
34
|
public readonly validFrom: Date | null,
|
|
52
35
|
public readonly validTo: Date | null,
|
|
53
36
|
public readonly isActive: boolean,
|
|
54
|
-
<% } -%>
|
|
55
|
-
<% if (hasRelationships) { -%>
|
|
56
|
-
// Loaded relations (optional, populated when eager-loaded)
|
|
57
|
-
<% belongsToRelations.forEach((rel) => { -%>
|
|
58
|
-
public readonly <%= rel.name %>?: <%= rel.targetClass %>,
|
|
59
|
-
<% }) -%>
|
|
60
|
-
<% hasManyRelations.forEach((rel) => { -%>
|
|
61
|
-
public readonly <%= rel.name %>?: <%= rel.targetClass %>[],
|
|
62
|
-
<% }) -%>
|
|
63
|
-
<% hasOneRelations.forEach((rel) => { -%>
|
|
64
|
-
public readonly <%= rel.name %>?: <%= rel.targetClass %>,
|
|
65
|
-
<% }) -%>
|
|
66
37
|
<% } -%>
|
|
67
38
|
) {}
|
|
68
39
|
|
|
69
40
|
static fromRecord(
|
|
70
41
|
// biome-ignore lint/suspicious/noExplicitAny: Drizzle records have dynamic shape
|
|
71
42
|
record: Record<string, any>,
|
|
72
|
-
<% if (hasRelationships) { -%>
|
|
73
|
-
// biome-ignore lint/suspicious/noExplicitAny: Returns different entity types
|
|
74
|
-
mapRelation?: (name: string, data: unknown) => any,
|
|
75
|
-
<% } -%>
|
|
76
43
|
): <%= className %> {
|
|
77
44
|
return new <%= className %>(
|
|
78
45
|
record.id,
|
|
@@ -90,17 +57,6 @@ export class <%= className %> {
|
|
|
90
57
|
record.validFrom,
|
|
91
58
|
record.validTo,
|
|
92
59
|
record.isActive,
|
|
93
|
-
<% } -%>
|
|
94
|
-
<% if (hasRelationships) { -%>
|
|
95
|
-
<% belongsToRelations.forEach((rel) => { -%>
|
|
96
|
-
record.<%= rel.name %> ? mapRelation?.('<%= rel.name %>', record.<%= rel.name %>) : undefined,
|
|
97
|
-
<% }) -%>
|
|
98
|
-
<% hasManyRelations.forEach((rel) => { -%>
|
|
99
|
-
record.<%= rel.name %> ? mapRelation?.('<%= rel.name %>', record.<%= rel.name %>) : undefined,
|
|
100
|
-
<% }) -%>
|
|
101
|
-
<% hasOneRelations.forEach((rel) => { -%>
|
|
102
|
-
record.<%= rel.name %> ? mapRelation?.('<%= rel.name %>', record.<%= rel.name %>) : undefined,
|
|
103
|
-
<% }) -%>
|
|
104
60
|
<% } -%>
|
|
105
61
|
);
|
|
106
62
|
}
|
|
@@ -14,23 +14,6 @@ force: true
|
|
|
14
14
|
<% if (hasEntityRefFields) { -%>
|
|
15
15
|
import type { EntityType } from '<%= locations.dbSchemaServer.import %>';
|
|
16
16
|
<% } -%>
|
|
17
|
-
<%
|
|
18
|
-
// Collect unique non-self-referential imports
|
|
19
|
-
const importedEntities = new Set();
|
|
20
|
-
[...belongsToRelations, ...hasManyRelations, ...hasOneRelations].forEach((rel) => {
|
|
21
|
-
if (rel.target !== name) {
|
|
22
|
-
importedEntities.add(rel.target);
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
-%>
|
|
26
|
-
<% if (importedEntities.size > 0) { -%>
|
|
27
|
-
|
|
28
|
-
<% importedEntities.forEach((target) => {
|
|
29
|
-
const targetClass = target.charAt(0).toUpperCase() + target.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
30
|
-
-%>
|
|
31
|
-
import type { <%= targetClass %> } from '../<%= target %>';
|
|
32
|
-
<% }) -%>
|
|
33
|
-
<% } -%>
|
|
34
17
|
|
|
35
18
|
// ============================================================================
|
|
36
19
|
// Entity
|
|
@@ -53,28 +36,12 @@ export class <%= className %> {
|
|
|
53
36
|
public readonly validFrom: Date | null,
|
|
54
37
|
public readonly validTo: Date | null,
|
|
55
38
|
public readonly isActive: boolean,
|
|
56
|
-
<% } -%>
|
|
57
|
-
<% if (hasRelationships) { -%>
|
|
58
|
-
// Loaded relations (optional, populated when eager-loaded)
|
|
59
|
-
<% belongsToRelations.forEach((rel) => { -%>
|
|
60
|
-
public readonly <%= rel.name %>?: <%= rel.targetClass %>,
|
|
61
|
-
<% }) -%>
|
|
62
|
-
<% hasManyRelations.forEach((rel) => { -%>
|
|
63
|
-
public readonly <%= rel.name %>?: <%= rel.targetClass %>[],
|
|
64
|
-
<% }) -%>
|
|
65
|
-
<% hasOneRelations.forEach((rel) => { -%>
|
|
66
|
-
public readonly <%= rel.name %>?: <%= rel.targetClass %>,
|
|
67
|
-
<% }) -%>
|
|
68
39
|
<% } -%>
|
|
69
40
|
) {}
|
|
70
41
|
|
|
71
42
|
static fromRecord(
|
|
72
43
|
// biome-ignore lint/suspicious/noExplicitAny: Drizzle records have dynamic shape
|
|
73
44
|
record: Record<string, any>,
|
|
74
|
-
<% if (hasRelationships) { -%>
|
|
75
|
-
// biome-ignore lint/suspicious/noExplicitAny: Returns different entity types
|
|
76
|
-
mapRelation?: (name: string, data: unknown) => any,
|
|
77
|
-
<% } -%>
|
|
78
45
|
): <%= className %> {
|
|
79
46
|
return new <%= className %>(
|
|
80
47
|
record.id,
|
|
@@ -92,17 +59,6 @@ export class <%= className %> {
|
|
|
92
59
|
record.validFrom,
|
|
93
60
|
record.validTo,
|
|
94
61
|
record.isActive,
|
|
95
|
-
<% } -%>
|
|
96
|
-
<% if (hasRelationships) { -%>
|
|
97
|
-
<% belongsToRelations.forEach((rel) => { -%>
|
|
98
|
-
record.<%= rel.name %> ? mapRelation?.('<%= rel.name %>', record.<%= rel.name %>) : undefined,
|
|
99
|
-
<% }) -%>
|
|
100
|
-
<% hasManyRelations.forEach((rel) => { -%>
|
|
101
|
-
record.<%= rel.name %> ? mapRelation?.('<%= rel.name %>', record.<%= rel.name %>) : undefined,
|
|
102
|
-
<% }) -%>
|
|
103
|
-
<% hasOneRelations.forEach((rel) => { -%>
|
|
104
|
-
record.<%= rel.name %> ? mapRelation?.('<%= rel.name %>', record.<%= rel.name %>) : undefined,
|
|
105
|
-
<% }) -%>
|
|
106
62
|
<% } -%>
|
|
107
63
|
);
|
|
108
64
|
}
|
|
@@ -111,18 +67,6 @@ export class <%= className %> {
|
|
|
111
67
|
// ============================================================================
|
|
112
68
|
// Repository Interface
|
|
113
69
|
// ============================================================================
|
|
114
|
-
<% if (hasRelationships) { -%>
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Type-safe eager loading options.
|
|
118
|
-
* Pass to repository methods to include related entities.
|
|
119
|
-
*/
|
|
120
|
-
export type <%= className %>With = {
|
|
121
|
-
<% relationships.forEach((rel) => { -%>
|
|
122
|
-
<%= rel.name %>?: boolean;
|
|
123
|
-
<% }) -%>
|
|
124
|
-
};
|
|
125
|
-
<% } -%>
|
|
126
70
|
|
|
127
71
|
/**
|
|
128
72
|
* Domain-level input types for repository operations.
|
|
@@ -143,8 +87,8 @@ export type Update<%= className %>Input = Partial<Create<%= className %>Input>;
|
|
|
143
87
|
|
|
144
88
|
export interface I<%= className %>Repository {
|
|
145
89
|
create(input: Create<%= className %>Input): Promise<<%= className %>>;
|
|
146
|
-
findById(id: string
|
|
147
|
-
findAll(
|
|
90
|
+
findById(id: string): Promise<<%= className %> | null>;
|
|
91
|
+
findAll(): Promise<<%= className %>[]>;
|
|
148
92
|
update(id: string, input: Update<%= className %>Input): Promise<<%= className %> | null>;
|
|
149
93
|
delete(id: string): Promise<<%= className %> | null>;
|
|
150
94
|
<% if (hasSoftDelete) { -%>
|
|
@@ -154,10 +98,10 @@ export interface I<%= className %>Repository {
|
|
|
154
98
|
findOnlyDeleted(): Promise<<%= className %>[]>;
|
|
155
99
|
<% } -%>
|
|
156
100
|
<% belongsToRelations.forEach((rel) => { -%>
|
|
157
|
-
findBy<%= rel.foreignKeyPascal %>(id: string
|
|
101
|
+
findBy<%= rel.foreignKeyPascal %>(id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= className %>[]>;
|
|
158
102
|
<% }) -%>
|
|
159
103
|
<% entityRefFields.forEach((ref) => { -%>
|
|
160
|
-
findBy<%= ref.pascalName %>(entityType: EntityType, entityId: string
|
|
104
|
+
findBy<%= ref.pascalName %>(entityType: EntityType, entityId: string): Promise<<%= className %>[]>;
|
|
161
105
|
<% }) -%>
|
|
162
106
|
}
|
|
163
107
|
<% } -%>
|
|
@@ -11,5 +11,5 @@ force: true
|
|
|
11
11
|
* when using separate file layout (file_grouping: "separate")
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export * from './<%=
|
|
15
|
-
export * from './<%=
|
|
14
|
+
export * from './<%= fileNames.entity.replace('.ts', '') %>';
|
|
15
|
+
export * from './<%= fileNames.repositoryInterface.replace('.ts', '') %>';
|
|
@@ -9,25 +9,13 @@ force: true
|
|
|
9
9
|
* Generated by entity codegen - do not edit directly
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { <%= className %> } from './<%= name
|
|
12
|
+
import type { <%= className %> } from './<%= (typeof fileNames !== 'undefined' ? fileNames.entity : name + '.entity.ts').replace('.ts', '') %>';
|
|
13
13
|
<% if (hasEntityRefFields) { -%>
|
|
14
14
|
import type { EntityType } from '<%= locations.dbSchemaServer.import %>';
|
|
15
15
|
<% } -%>
|
|
16
16
|
<% if (hasEmits && (createEventType || updateEventType || deleteEventType)) { -%>
|
|
17
17
|
import type { DrizzleTransaction } from '<%= eventsTokenImport %>';
|
|
18
18
|
<% } -%>
|
|
19
|
-
<% if (hasRelationships) { -%>
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Type-safe eager loading options.
|
|
23
|
-
* Pass to repository methods to include related entities.
|
|
24
|
-
*/
|
|
25
|
-
export type <%= className %>With = {
|
|
26
|
-
<% relationships.forEach((rel) => { -%>
|
|
27
|
-
<%= rel.name %>?: boolean;
|
|
28
|
-
<% }) -%>
|
|
29
|
-
};
|
|
30
|
-
<% } -%>
|
|
31
19
|
|
|
32
20
|
/**
|
|
33
21
|
* Domain-level input types for repository operations.
|
|
@@ -48,8 +36,8 @@ export type Update<%= className %>Input = Partial<Create<%= className %>Input>;
|
|
|
48
36
|
|
|
49
37
|
export interface I<%= className %>Repository {
|
|
50
38
|
create(input: Create<%= className %>Input<%= (hasEmits && createEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %>>;
|
|
51
|
-
findById(id: string
|
|
52
|
-
findAll(
|
|
39
|
+
findById(id: string): Promise<<%= className %> | null>;
|
|
40
|
+
findAll(): Promise<<%= className %>[]>;
|
|
53
41
|
update(id: string, input: Update<%= className %>Input<%= (hasEmits && updateEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null>;
|
|
54
42
|
delete(id: string<%= (hasEmits && deleteEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null>;
|
|
55
43
|
<% if (hasSoftDelete) { -%>
|
|
@@ -58,16 +46,27 @@ export interface I<%= className %>Repository {
|
|
|
58
46
|
findWithDeleted(): Promise<<%= className %>[]>;
|
|
59
47
|
findOnlyDeleted(): Promise<<%= className %>[]>;
|
|
60
48
|
<% } -%>
|
|
49
|
+
<%_
|
|
50
|
+
// CGP-358: FK methods with opts take priority. Always emit FK signatures with opts.
|
|
51
|
+
// Skip declarative query signature when FK covers the same method name.
|
|
52
|
+
const _riFkMethodNames = new Set(belongsToRelations.map(rel => `findBy${rel.foreignKeyPascal}`));
|
|
53
|
+
_%>
|
|
61
54
|
<% belongsToRelations.forEach((rel) => { -%>
|
|
62
|
-
findBy<%= rel.foreignKeyPascal %>(id: string
|
|
55
|
+
findBy<%= rel.foreignKeyPascal %>(id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= className %>[]>;
|
|
63
56
|
<% }) -%>
|
|
64
57
|
<% entityRefFields.forEach((ref) => { -%>
|
|
65
|
-
findBy<%= ref.pascalName %>(entityType: EntityType, entityId: string
|
|
58
|
+
findBy<%= ref.pascalName %>(entityType: EntityType, entityId: string): Promise<<%= className %>[]>;
|
|
66
59
|
<% }) -%>
|
|
67
60
|
<% if (hasDeclarativeQueries) { -%>
|
|
68
61
|
// Declarative queries (from queries: block in entity YAML)
|
|
69
62
|
<% processedQueries.forEach((q) => { -%>
|
|
63
|
+
<%_
|
|
64
|
+
// Skip declarative signature when FK method covers it (opts is a superset of plain single-param).
|
|
65
|
+
const _skipRiDq = _riFkMethodNames.has(q.methodName) && !q.isUnique && !q.hasVia && !q.hasSelect;
|
|
66
|
+
_%>
|
|
67
|
+
<% if (!_skipRiDq) { -%>
|
|
70
68
|
<%= q.methodName %>(<%- q.params.map(p => `${p.camelName}: ${p.tsType}`).join(', ') %>): Promise<<%- q.returnType %>>;
|
|
69
|
+
<% } -%>
|
|
71
70
|
<% }) -%>
|
|
72
71
|
<% } -%>
|
|
73
72
|
}
|
|
@@ -32,6 +32,9 @@ import { <%= className %>Repository } from '<%= imports.moduleToRepository %>';
|
|
|
32
32
|
<% if (hasDeclarativeQueries) { -%>
|
|
33
33
|
import { declarativeQueryClasses } from '<%= imports.moduleToDeclarativeQueries %>';
|
|
34
34
|
<% } -%>
|
|
35
|
+
<% if (hasRelationships && !isGrouped) { -%>
|
|
36
|
+
import { relationshipQueryClasses } from '<%= imports.moduleToRelationshipQueries %>';
|
|
37
|
+
<% } -%>
|
|
35
38
|
<% if (exposeRest || exposeElectric) { -%>
|
|
36
39
|
import { <%= classNamePlural %>Controller } from '<%= imports.moduleToController %>';
|
|
37
40
|
<% } -%>
|
|
@@ -58,6 +61,10 @@ import { create<%= className %>Schema, update<%= className %>Schema, <%= camelNa
|
|
|
58
61
|
<% if (hasDeclarativeQueries) { -%>
|
|
59
62
|
// Declarative queries
|
|
60
63
|
...declarativeQueryClasses,
|
|
64
|
+
<% } -%>
|
|
65
|
+
<% if (hasRelationships && !isGrouped) { -%>
|
|
66
|
+
// Relationship composition queries
|
|
67
|
+
...relationshipQueryClasses,
|
|
61
68
|
<% } -%>
|
|
62
69
|
],
|
|
63
70
|
exports: [
|
|
@@ -71,6 +78,9 @@ import { create<%= className %>Schema, update<%= className %>Schema, <%= camelNa
|
|
|
71
78
|
<%= deleteCommandClass %>,
|
|
72
79
|
<% if (hasDeclarativeQueries) { -%>
|
|
73
80
|
...declarativeQueryClasses,
|
|
81
|
+
<% } -%>
|
|
82
|
+
<% if (hasRelationships && !isGrouped) { -%>
|
|
83
|
+
...relationshipQueryClasses,
|
|
74
84
|
<% } -%>
|
|
75
85
|
],
|
|
76
86
|
})
|
|
@@ -19,9 +19,6 @@ import {
|
|
|
19
19
|
ParseUUIDPipe,
|
|
20
20
|
Post,
|
|
21
21
|
Put,
|
|
22
|
-
<% if (hasRelationships) { -%>
|
|
23
|
-
Query,
|
|
24
|
-
<% } -%>
|
|
25
22
|
UsePipes,
|
|
26
23
|
} from '@nestjs/common';
|
|
27
24
|
import {
|
|
@@ -44,9 +41,6 @@ import { <%= deleteCommandClass %> } from '<%= imports.controllerToDeleteCommand
|
|
|
44
41
|
import { <%= updateCommandClass %> } from '<%= imports.controllerToUpdateCommand %>';
|
|
45
42
|
import { ZodValidationPipe } from '../../core/pipes/zod-validation.pipe';
|
|
46
43
|
import { <%= className %> } from '<%= imports.controllerToDomain %>';
|
|
47
|
-
<% if (hasRelationships) { -%>
|
|
48
|
-
import type { <%= className %>With } from '<%= imports.controllerToDomain %>';
|
|
49
|
-
<% } -%>
|
|
50
44
|
|
|
51
45
|
// OPENAPI-3: controller decorators reference schemas by `$ref` rather
|
|
52
46
|
// than `type:` class references because generated DTOs are Zod-derived
|
|
@@ -72,12 +66,8 @@ export class <%= classNamePlural %>Controller {
|
|
|
72
66
|
})
|
|
73
67
|
@ApiResponse({ status: 401, schema: { $ref: '#/components/schemas/ErrorResponseDto' } })
|
|
74
68
|
@Get()
|
|
75
|
-
async findAll(
|
|
76
|
-
<% if (hasRelationships) { -%>
|
|
77
|
-
return this.list<%= classNamePlural %>Query.execute(this.parseInclude(include));
|
|
78
|
-
<% } else { -%>
|
|
69
|
+
async findAll(): Promise<<%= className %>[]> {
|
|
79
70
|
return this.list<%= classNamePlural %>Query.execute();
|
|
80
|
-
<% } -%>
|
|
81
71
|
}
|
|
82
72
|
|
|
83
73
|
@ApiOperation({ summary: 'Find <%= name %> by id', operationId: 'find<%= className %>ById' })
|
|
@@ -88,15 +78,8 @@ export class <%= classNamePlural %>Controller {
|
|
|
88
78
|
@Get(':id')
|
|
89
79
|
async findById(
|
|
90
80
|
@Param('id', ParseUUIDPipe) id: string,
|
|
91
|
-
<% if (hasRelationships) { -%>
|
|
92
|
-
@Query('include') include?: string,
|
|
93
|
-
<% } -%>
|
|
94
81
|
): Promise<<%= className %>> {
|
|
95
|
-
<% if (hasRelationships) { -%>
|
|
96
|
-
return this.get<%= className %>ByIdQuery.execute(id, this.parseInclude(include));
|
|
97
|
-
<% } else { -%>
|
|
98
82
|
return this.get<%= className %>ByIdQuery.execute(id);
|
|
99
|
-
<% } -%>
|
|
100
83
|
}
|
|
101
84
|
|
|
102
85
|
@ApiOperation({ summary: 'Create <%= name %>', operationId: 'create<%= className %>' })
|
|
@@ -144,23 +127,8 @@ export class <%= classNamePlural %>Controller {
|
|
|
144
127
|
): Promise<<%= className %>> {
|
|
145
128
|
return this.delete<%= className %>Command.execute(id, { actor: { tenantId, userId } });
|
|
146
129
|
}
|
|
147
|
-
<% if (hasRelationships) { -%>
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Parse comma-separated include query param into typed options.
|
|
151
|
-
* Example: ?include=account,owner → { account: true, owner: true }
|
|
152
|
-
*/
|
|
153
|
-
private parseInclude(include?: string): <%= className %>With | undefined {
|
|
154
|
-
if (!include) return undefined;
|
|
155
|
-
const parts = include.split(',').map((s) => s.trim());
|
|
156
|
-
return {
|
|
157
|
-
<% relationships.forEach((rel) => { -%>
|
|
158
|
-
<%= rel.name %>: parts.includes('<%= rel.name %>'),
|
|
159
|
-
<% }) -%>
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
<% } -%>
|
|
163
130
|
}
|
|
131
|
+
|
|
164
132
|
<% } -%>
|
|
165
133
|
<% if (exposeElectric) { -%>
|
|
166
134
|
/**
|
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
<%_ clpDrizzleImports.filter(i => i !== 'relations').forEach(i => { _%>
|
|
8
8
|
<%= i %>,
|
|
9
9
|
<%_ }) _%>
|
|
10
|
+
<%_ if (typeof clpHasSelfFk !== 'undefined' && clpHasSelfFk) { _%>
|
|
11
|
+
type AnyPgColumn,
|
|
12
|
+
<%_ } _%>
|
|
10
13
|
} from 'drizzle-orm/pg-core';
|
|
11
14
|
<%_ if (clpHasRelationsBlock) { _%>
|
|
12
15
|
import { relations, type InferSelectModel } from 'drizzle-orm';
|
|
@@ -18,6 +21,12 @@ import { type InferSelectModel } from 'drizzle-orm';
|
|
|
18
21
|
import { <%= rel.relatedTable %> } from '<%= rel.importPath %>';
|
|
19
22
|
<%_ } _%>
|
|
20
23
|
<%_ }) _%>
|
|
24
|
+
<%_ /* CGP-358b: import has_many target tables for many() relation const */ _%>
|
|
25
|
+
<%_ if (typeof clpExistingHasMany !== 'undefined') { _%>
|
|
26
|
+
<%_ clpExistingHasMany.filter(rel => !rel.isSelfRef).forEach(rel => { _%>
|
|
27
|
+
import { <%= rel.targetPlural %> } from '../<%= rel.targetPlural %>/<%= rel.target %>.entity';
|
|
28
|
+
<%_ }) _%>
|
|
29
|
+
<%_ } _%>
|
|
21
30
|
<%_ if (typeof clpEnumFields !== 'undefined' && clpEnumFields.length > 0) { _%>
|
|
22
31
|
|
|
23
32
|
<%_ clpEnumFields.forEach(ef => { _%>
|
|
@@ -36,7 +45,7 @@ export const <%= entityNamePlural %> = pgTable(
|
|
|
36
45
|
// cascade rules never fire for a soft-deleted parent. This FK constraint only applies on
|
|
37
46
|
// hard-delete (e.g. admin purge). See ADR-021: docs/adrs/ADR-021-on-delete-semantics.md
|
|
38
47
|
<%_ } _%>
|
|
39
|
-
<%= rel.camelField %>: uuid('<%= rel.field %>')<%= rel.nullable ? '' : '.notNull()' %>.references(()
|
|
48
|
+
<%= rel.camelField %>: uuid('<%= rel.field %>')<%= rel.nullable ? '' : '.notNull()' %>.references(<%= rel.isSelfFk ? '(): AnyPgColumn ' : '() ' %>=> <%= rel.relatedTable %>.id, { onDelete: '<%= rel.onDelete %>' }),
|
|
40
49
|
<%_ }) _%>
|
|
41
50
|
<%_ clpProcessedFields.forEach(field => { _%>
|
|
42
51
|
<%= field.camelName %>: <%- field.drizzleChain %>,
|
|
@@ -57,14 +66,18 @@ export const <%= entityNamePlural %> = pgTable(
|
|
|
57
66
|
},
|
|
58
67
|
);
|
|
59
68
|
<%_ if (clpHasRelationsBlock) { _%>
|
|
69
|
+
<%_ const needsMany = typeof clpExistingHasMany !== 'undefined' && clpExistingHasMany.length > 0; _%>
|
|
60
70
|
|
|
61
|
-
export const <%= entityNamePlural %>Relations = relations(<%= entityNamePlural %>, ({ one }) => ({
|
|
71
|
+
export const <%= entityNamePlural %>Relations = relations(<%= entityNamePlural %>, ({ one<%= needsMany ? ', many' : '' %> }) => ({
|
|
62
72
|
<%_ clpBelongsTo.forEach(rel => { _%>
|
|
63
73
|
<%= rel.relationKey %>: one(<%= rel.relatedTable %>, {
|
|
64
74
|
fields: [<%= entityNamePlural %>.<%= rel.camelField %>],
|
|
65
75
|
references: [<%= rel.relatedTable %>.id],
|
|
66
76
|
}),
|
|
67
77
|
<%_ }) _%>
|
|
78
|
+
<%_ if (typeof clpExistingHasMany !== 'undefined') { clpExistingHasMany.forEach(rel => { _%>
|
|
79
|
+
<%= rel.name %>: many(<%= rel.targetPlural %>),
|
|
80
|
+
<%_ }) } _%>
|
|
68
81
|
}));
|
|
69
82
|
<%_ } _%>
|
|
70
83
|
|
|
@@ -13,9 +13,21 @@ force: true
|
|
|
13
13
|
import { Inject, Module, type OnModuleInit } from '@nestjs/common';
|
|
14
14
|
import { OPENAPI_REGISTRY, type OpenApiRegistry } from '@shared/openapi';
|
|
15
15
|
import { DatabaseModule } from '@shared/database/database.module';
|
|
16
|
-
<%_
|
|
17
|
-
|
|
16
|
+
<%_ /* CGP-358b: Import cross-entity repos needed for has_many composition */ _%>
|
|
17
|
+
<%_ if (typeof clpExistingHasMany !== 'undefined') { _%>
|
|
18
|
+
<%_ const hasManyNeedingImport = clpExistingHasMany.filter(r => !r.isSelfRef); _%>
|
|
19
|
+
<%_ const uniqueHasManyForModule = [...new Map(hasManyNeedingImport.map(r => [r.target, r])).values()]; _%>
|
|
20
|
+
<%_ uniqueHasManyForModule.forEach(rel => { _%>
|
|
21
|
+
import { <%= rel.targetClass %>Repository } from '../<%= rel.targetPlural %>/<%= rel.target %>.repository';
|
|
22
|
+
<%_ }) _%>
|
|
23
|
+
<%_ } _%>
|
|
24
|
+
<%_ /* CGP-358b: Import cross-entity repos needed for belongs_to composition */ _%>
|
|
25
|
+
<%_ if (typeof clpBelongsTo !== 'undefined') { _%>
|
|
26
|
+
<%_ const uniqueBelongsToForModule = [...new Map(clpBelongsTo.filter(r => !r.isSelfFk).map(r => [r.relatedEntity, r])).values()]; _%>
|
|
27
|
+
<%_ uniqueBelongsToForModule.forEach(rel => { _%>
|
|
28
|
+
import { <%= rel.relatedEntityPascal %>Repository } from '../<%= rel.relatedPlural %>/<%= rel.relatedEntity %>.repository';
|
|
18
29
|
<%_ }) _%>
|
|
30
|
+
<%_ } _%>
|
|
19
31
|
<% if (eavEnabled) { -%>
|
|
20
32
|
import { FieldValuesModule } from '../field_values/field_values.module';
|
|
21
33
|
<% } -%>
|
|
@@ -68,6 +80,19 @@ import { <%= classNames.searchController %> } from './<%= entityName %>-search.c
|
|
|
68
80
|
providers: [
|
|
69
81
|
<%= classNames.repository %>,
|
|
70
82
|
<%= classNames.service %>,
|
|
83
|
+
<%_ /* CGP-358b: Register cross-entity repos as providers (needed for service DI) */ _%>
|
|
84
|
+
<%_ if (typeof clpExistingHasMany !== 'undefined') { _%>
|
|
85
|
+
<%_ const uniqueHasManyProviders = [...new Map(clpExistingHasMany.filter(r => !r.isSelfRef).map(r => [r.target, r])).values()]; _%>
|
|
86
|
+
<%_ uniqueHasManyProviders.forEach(rel => { _%>
|
|
87
|
+
<%= rel.targetClass %>Repository,
|
|
88
|
+
<%_ }) _%>
|
|
89
|
+
<%_ } _%>
|
|
90
|
+
<%_ if (typeof clpBelongsTo !== 'undefined') { _%>
|
|
91
|
+
<%_ const uniqueBelongsToProviders = [...new Map(clpBelongsTo.filter(r => !r.isSelfFk).map(r => [r.relatedEntity, r])).values()]; _%>
|
|
92
|
+
<%_ uniqueBelongsToProviders.forEach(rel => { _%>
|
|
93
|
+
<%= rel.relatedEntityPascal %>Repository,
|
|
94
|
+
<%_ }) _%>
|
|
95
|
+
<%_ } _%>
|
|
71
96
|
<%= classNames.findByIdUseCase %>,
|
|
72
97
|
<%= classNames.listUseCase %>,
|
|
73
98
|
<% if (eavEnabled) { -%>
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* all variables required by the clean-lite-ps template set.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
8
10
|
import pluralizePkg from 'pluralize';
|
|
9
11
|
// The patterns barrel has the side effect of pre-registering the five
|
|
10
12
|
// library-shipped patterns (Base / Synced / Activity / Knowledge /
|
|
@@ -306,6 +308,57 @@ function mapOnDelete(onDelete) {
|
|
|
306
308
|
return map[onDelete] ?? 'restrict';
|
|
307
309
|
}
|
|
308
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Process has_many relationships into HasManyRelation[].
|
|
313
|
+
*
|
|
314
|
+
* Mirrors processBelongsTo. The `foreign_key` declared on a has_many
|
|
315
|
+
* relationship is the inverse FK living on the *target* entity's table —
|
|
316
|
+
* e.g. `account.relationships.contacts: { foreign_key: account_id }` means
|
|
317
|
+
* contacts.account_id. The method name on AccountRepository would be
|
|
318
|
+
* `findByAccountId`.
|
|
319
|
+
*/
|
|
320
|
+
function processHasMany(relationships, parentEntityNamePlural, fs, path, srcRoot) {
|
|
321
|
+
if (!relationships) return [];
|
|
322
|
+
|
|
323
|
+
const result = [];
|
|
324
|
+
|
|
325
|
+
for (const [relName, rel] of Object.entries(relationships)) {
|
|
326
|
+
if (rel.type !== 'has_many') continue;
|
|
327
|
+
|
|
328
|
+
const target = rel.target;
|
|
329
|
+
const inverseForeignKey = rel.foreign_key;
|
|
330
|
+
const targetPlural = pluralize(target);
|
|
331
|
+
const isSelfRef = targetPlural === parentEntityNamePlural;
|
|
332
|
+
|
|
333
|
+
// Check whether the target entity has already been generated.
|
|
334
|
+
// Only include targets that exist so the import block doesn't
|
|
335
|
+
// reference files that aren't on disk yet (two-pass generation).
|
|
336
|
+
let targetExists = false;
|
|
337
|
+
if (fs && path && srcRoot) {
|
|
338
|
+
const nestedPath = path.resolve(srcRoot, 'modules', targetPlural, `${target}.entity.ts`);
|
|
339
|
+
const flatPath = path.resolve(srcRoot, 'modules', `${target}.entity.ts`);
|
|
340
|
+
targetExists = fs.existsSync(nestedPath) || fs.existsSync(flatPath) || isSelfRef;
|
|
341
|
+
} else {
|
|
342
|
+
targetExists = isSelfRef;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
result.push({
|
|
346
|
+
name: relName,
|
|
347
|
+
target,
|
|
348
|
+
targetClass: pascalCase(target),
|
|
349
|
+
targetPlural,
|
|
350
|
+
inverseForeignKey,
|
|
351
|
+
inverseForeignKeyCamel: camelCase(inverseForeignKey),
|
|
352
|
+
inverseForeignKeyPascal: pascalCase(inverseForeignKey),
|
|
353
|
+
isSelfRef,
|
|
354
|
+
targetExists,
|
|
355
|
+
importPath: `../${targetPlural}/${target}.repository`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
|
|
309
362
|
/**
|
|
310
363
|
* Process belongs_to relationships into BelongsToRelation[]
|
|
311
364
|
*/
|
|
@@ -364,7 +417,7 @@ function processBelongsTo(relationships, parentEntityNamePlural) {
|
|
|
364
417
|
/**
|
|
365
418
|
* Collect drizzle imports needed for entity fields
|
|
366
419
|
*/
|
|
367
|
-
function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking) {
|
|
420
|
+
function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany = []) {
|
|
368
421
|
const imports = new Set(['pgTable', 'uuid']);
|
|
369
422
|
|
|
370
423
|
for (const field of processedFields) {
|
|
@@ -394,7 +447,7 @@ function collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSof
|
|
|
394
447
|
imports.add('jsonb');
|
|
395
448
|
}
|
|
396
449
|
|
|
397
|
-
if (belongsTo.length > 0) {
|
|
450
|
+
if (belongsTo.length > 0 || hasMany.length > 0) {
|
|
398
451
|
imports.add('relations');
|
|
399
452
|
}
|
|
400
453
|
|
|
@@ -769,6 +822,9 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
|
|
|
769
822
|
// Process belongs_to relationships
|
|
770
823
|
const belongsTo = processBelongsTo(relationships, entityNamePlural);
|
|
771
824
|
|
|
825
|
+
// Process has_many relationships (CGP-358b)
|
|
826
|
+
const hasMany = processHasMany(relationships, entityNamePlural, fs, path, srcRoot);
|
|
827
|
+
|
|
772
828
|
// Issue #41 — warn when a soft-delete entity declares non-restrict on_delete on any
|
|
773
829
|
// belongs_to relation. The FK constraint applies to hard-delete only;
|
|
774
830
|
// developers expecting soft-delete cascade must use activeParentFilter() instead.
|
|
@@ -815,9 +871,9 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
|
|
|
815
871
|
}));
|
|
816
872
|
|
|
817
873
|
// Drizzle imports needed
|
|
818
|
-
const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking);
|
|
819
|
-
// Whether relations() import is needed
|
|
820
|
-
const hasRelationsBlock = belongsTo.length > 0;
|
|
874
|
+
const drizzleEntityImports = collectDrizzleImports(processedFields, belongsTo, hasTimestamps, hasSoftDelete, hasExternalIdTracking, hasMany);
|
|
875
|
+
// Whether relations() import is needed (CGP-358b: also trigger on has_many)
|
|
876
|
+
const hasRelationsBlock = belongsTo.length > 0 || hasMany.length > 0;
|
|
821
877
|
|
|
822
878
|
// Output paths
|
|
823
879
|
const outputPaths = {
|
|
@@ -1011,6 +1067,12 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
|
|
|
1011
1067
|
// Drizzle
|
|
1012
1068
|
clpDrizzleImports: drizzleEntityImports,
|
|
1013
1069
|
clpHasRelationsBlock: hasRelationsBlock,
|
|
1070
|
+
// A self-referential belongs_to FK requires the `references()` callback
|
|
1071
|
+
// to carry a `: AnyPgColumn` return-type annotation; otherwise TypeScript's
|
|
1072
|
+
// strict mode flags the table const with TS7022/TS7024 (circular initializer).
|
|
1073
|
+
// Surfaced by the cgp-62 relationship-scenario smoke when generating a CRM
|
|
1074
|
+
// account with a `parent_account_id` self-FK.
|
|
1075
|
+
clpHasSelfFk: belongsTo.some((rel) => rel.isSelfFk),
|
|
1014
1076
|
clpEnumFields,
|
|
1015
1077
|
|
|
1016
1078
|
// Declarative queries
|
|
@@ -1020,5 +1082,10 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
|
|
|
1020
1082
|
hasMultiFieldQuery,
|
|
1021
1083
|
hasOrderedQuery,
|
|
1022
1084
|
hasViaQuery,
|
|
1085
|
+
|
|
1086
|
+
// CGP-358b: has_many relationships for service-layer composition
|
|
1087
|
+
clpHasMany: hasMany,
|
|
1088
|
+
clpHasManyRelations: hasMany.length > 0,
|
|
1089
|
+
clpExistingHasMany: hasMany.filter((r) => r.targetExists),
|
|
1023
1090
|
};
|
|
1024
1091
|
}
|
|
@@ -4,7 +4,17 @@ skip_if: "<%= typeof clpOutputPaths === 'undefined' %>"
|
|
|
4
4
|
force: true
|
|
5
5
|
---
|
|
6
6
|
import { Injectable, Inject } from '@nestjs/common';
|
|
7
|
-
<%
|
|
7
|
+
<%_
|
|
8
|
+
// CGP-358: FK methods with opts take priority over same-named declarative query impl.
|
|
9
|
+
// Always emit FK methods; skip declarative body when FK covers same name.
|
|
10
|
+
const _fkMethods = (typeof clpBelongsTo !== 'undefined') ? clpBelongsTo : [];
|
|
11
|
+
const _fkMethodNamesCLP = new Set(_fkMethods.map(rel => {
|
|
12
|
+
const _p = rel.camelField.charAt(0).toUpperCase() + rel.camelField.slice(1);
|
|
13
|
+
return `findBy${_p}`;
|
|
14
|
+
}));
|
|
15
|
+
const _needsEq = hasDeclarativeQueries || _fkMethods.length > 0;
|
|
16
|
+
_%>
|
|
17
|
+
<% if (_needsEq) { -%>
|
|
8
18
|
import { eq<%= hasMultiFieldQuery ? ', and' : '' %><%= hasOrderedQuery ? ', desc, asc' : '' %> } from 'drizzle-orm';
|
|
9
19
|
<% } -%>
|
|
10
20
|
<% if (eavValueTable) { -%>
|
|
@@ -48,6 +58,12 @@ export class <%= classNames.repository %> extends <%= repositoryBaseClass %><<%=
|
|
|
48
58
|
// Declarative queries (from queries: block in entity YAML)
|
|
49
59
|
// ═══════════════════════════════════════════════════════════════════════
|
|
50
60
|
<%_ processedQueries.forEach((q) => { _%>
|
|
61
|
+
<%_
|
|
62
|
+
// CGP-358: Skip declarative impl when a FK method covers this method name.
|
|
63
|
+
// FK methods accept opts, making them a superset of a plain non-unique single-param query.
|
|
64
|
+
const _skipClpDq = _fkMethodNamesCLP.has(q.methodName) && !q.isUnique && !q.hasVia && !q.hasSelect;
|
|
65
|
+
_%>
|
|
66
|
+
<%_ if (!_skipClpDq) { _%>
|
|
51
67
|
|
|
52
68
|
async <%= q.methodName %>(<%- q.params.map(p => `${p.camelName}: ${p.tsType}`).join(', ') %>): Promise<<%- q.returnType %>> {
|
|
53
69
|
<% if (q.isUnique) { -%>
|
|
@@ -61,11 +77,27 @@ export class <%= classNames.repository %> extends <%= repositoryBaseClass %><<%=
|
|
|
61
77
|
return rows as <%= classNames.entity %>[];
|
|
62
78
|
<% } -%>
|
|
63
79
|
}
|
|
80
|
+
<%_ } _%>
|
|
64
81
|
<%_ }) _%>
|
|
65
82
|
<% } else { -%>
|
|
66
83
|
|
|
67
84
|
// TODO: Add entity-specific query methods here.
|
|
68
85
|
<% } -%>
|
|
86
|
+
<%_ if (_fkMethods.length > 0) { _%>
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
89
|
+
// FK traversal methods (from belongs_to relationships — CGP-358b)
|
|
90
|
+
// Called by service-layer composition methods on the inverse (has_many) side.
|
|
91
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
92
|
+
<%_ _fkMethods.forEach(rel => { _%>
|
|
93
|
+
|
|
94
|
+
async findBy<%= rel.camelField.charAt(0).toUpperCase() + rel.camelField.slice(1) %>(id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= classNames.entity %>[]> {
|
|
95
|
+
let q = this.baseQuery().where(eq(this.table['<%= rel.camelField %>'], id));
|
|
96
|
+
if (opts?.limit) q = (q as any).limit(opts.limit);
|
|
97
|
+
return (await q) as <%= classNames.entity %>[];
|
|
98
|
+
}
|
|
99
|
+
<%_ }) _%>
|
|
100
|
+
<%_ } _%>
|
|
69
101
|
<% if (eavValueTable) { -%>
|
|
70
102
|
|
|
71
103
|
// ═══════════════════════════════════════════════════════════════════════
|