@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.
Files changed (41) hide show
  1. package/dist/src/cli/index.js +516 -73
  2. package/dist/src/cli/index.js.map +1 -1
  3. package/dist/src/index.d.ts +208 -1
  4. package/dist/src/index.js +147 -0
  5. package/dist/src/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/patterns/library/base-junction-fields.ts +32 -0
  8. package/src/patterns/library/index.ts +7 -0
  9. package/src/patterns/library/junction.pattern.ts +41 -0
  10. package/templates/entity/new/backend/application/queries/get-by-id.ejs.t +3 -3
  11. package/templates/entity/new/backend/application/queries/grouped-index.ejs.t +5 -5
  12. package/templates/entity/new/backend/application/queries/index.ejs.t +3 -0
  13. package/templates/entity/new/backend/application/queries/list.ejs.t +3 -3
  14. package/templates/entity/new/backend/application/queries/relationships.queries.ejs.t +147 -0
  15. package/templates/entity/new/backend/database/repository.ejs.t +36 -176
  16. package/templates/entity/new/backend/domain/entity.ejs.t +0 -44
  17. package/templates/entity/new/backend/domain/grouped-index.ejs.t +4 -60
  18. package/templates/entity/new/backend/domain/index.ejs.t +2 -2
  19. package/templates/entity/new/backend/domain/repository-interface.ejs.t +16 -17
  20. package/templates/entity/new/backend/modules/core/module.ejs.t +10 -0
  21. package/templates/entity/new/backend/presentation/controller.ejs.t +2 -34
  22. package/templates/entity/new/clean-lite-ps/entity.ejs.t +15 -2
  23. package/templates/entity/new/clean-lite-ps/module.ejs.t +27 -2
  24. package/templates/entity/new/clean-lite-ps/prompt-extension.js +72 -5
  25. package/templates/entity/new/clean-lite-ps/repository.ejs.t +33 -1
  26. package/templates/entity/new/clean-lite-ps/service.ejs.t +79 -0
  27. package/templates/entity/new/prompt.js +1 -0
  28. package/templates/junction/new/_inject-parent-module-clp-left.ejs.t +8 -0
  29. package/templates/junction/new/_inject-parent-module-clp-right.ejs.t +8 -0
  30. package/templates/junction/new/_inject-parent-module-import-clp-left.ejs.t +9 -0
  31. package/templates/junction/new/_inject-parent-module-import-clp-right.ejs.t +9 -0
  32. package/templates/junction/new/_inject-parent-service-clp-left.ejs.t +51 -0
  33. package/templates/junction/new/_inject-parent-service-clp-right.ejs.t +48 -0
  34. package/templates/junction/new/_inject-parent-service-import-clp-left.ejs.t +11 -0
  35. package/templates/junction/new/_inject-parent-service-import-clp-right.ejs.t +11 -0
  36. package/templates/junction/new/entity.ejs.t +111 -0
  37. package/templates/junction/new/index.ejs.t +15 -0
  38. package/templates/junction/new/module.ejs.t +37 -0
  39. package/templates/junction/new/prompt.js +492 -0
  40. package/templates/junction/new/repository.ejs.t +67 -0
  41. 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<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %> | null>;
147
- findAll(<%= hasRelationships ? `include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
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<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
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<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
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 './<%= name %>.entity';
15
- export * from './<%= name %>.repository.interface';
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 %>.entity';
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<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %> | null>;
52
- findAll(<%= hasRelationships ? `include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
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<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
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<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>[]>;
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(<%- hasRelationships ? `@Query('include') include?: string` : '' %>): Promise<<%= className %>[]> {
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(() => <%= rel.relatedTable %>.id, { onDelete: '<%= rel.onDelete %>' }),
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
- <%_ clpBelongsTo.forEach(rel => { _%>
17
- // import { <%= rel.relatedEntityPascal %>sModule } from '../<%= rel.relatedPlural %>/<%= rel.relatedPlural %>.module';
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
- <% if (hasDeclarativeQueries) { -%>
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
  // ═══════════════════════════════════════════════════════════════════════