@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,32 @@
1
+ /**
2
+ * BaseJunctionFields — shared column shape every junction table carries.
3
+ *
4
+ * Per-pairing role columns and pairing-specific fields are declared in the
5
+ * consumer YAML's `fields:` block and are NOT part of this shape.
6
+ *
7
+ * Exposed as a TS const so two consumers can import it:
8
+ * - `junction.pattern.ts` — registers it as the pattern's
9
+ * column contribution so the
10
+ * registry's `assertHasContribution()`
11
+ * check passes structurally.
12
+ * - `junction-definition.schema.ts` — uses the name set for the
13
+ * reserved-column collision check
14
+ * on the consumer's `fields:` block.
15
+ *
16
+ * See ADR-031 and `.ai-docs/stacks/codegen-app-patterns/specs/58.md`.
17
+ */
18
+
19
+ import type { PatternColumnContribution } from '../pattern-definition.js';
20
+
21
+ export const BaseJunctionFields: readonly PatternColumnContribution[] = [
22
+ { name: 'is_primary', type: 'boolean' },
23
+ { name: 'started_at', type: 'timestamp' },
24
+ { name: 'ended_at', type: 'timestamp' },
25
+ { name: 'sourced_from', type: 'text' },
26
+ { name: 'confidence', type: 'numeric(5,4)' },
27
+ { name: 'matched_at', type: 'timestamp' },
28
+ ] as const;
29
+
30
+ export const BASE_JUNCTION_FIELD_NAMES: ReadonlySet<string> = new Set(
31
+ BaseJunctionFields.map((c) => c.name),
32
+ );
@@ -11,6 +11,7 @@
11
11
  import { registerLibraryPattern } from '../registry.js';
12
12
  import { ActivityPattern } from './activity.pattern.js';
13
13
  import { BasePattern } from './base.pattern.js';
14
+ import { JunctionPattern } from './junction.pattern.js';
14
15
  import { KnowledgePattern } from './knowledge.pattern.js';
15
16
  import { MetadataPattern } from './metadata.pattern.js';
16
17
  import { SyncedPattern } from './synced.pattern.js';
@@ -20,11 +21,17 @@ registerLibraryPattern(SyncedPattern);
20
21
  registerLibraryPattern(ActivityPattern);
21
22
  registerLibraryPattern(KnowledgePattern);
22
23
  registerLibraryPattern(MetadataPattern);
24
+ registerLibraryPattern(JunctionPattern);
23
25
 
24
26
  export {
25
27
  ActivityPattern,
26
28
  BasePattern,
29
+ JunctionPattern,
27
30
  KnowledgePattern,
28
31
  MetadataPattern,
29
32
  SyncedPattern,
30
33
  };
34
+ export {
35
+ BaseJunctionFields,
36
+ BASE_JUNCTION_FIELD_NAMES,
37
+ } from './base-junction-fields.js';
@@ -0,0 +1,41 @@
1
+ /**
2
+ * JunctionPattern — top-level discriminator for explicit many-to-many
3
+ * junction YAML files.
4
+ *
5
+ * Unlike `Activity` / `Synced` / `Metadata` (which attach to an entity via
6
+ * `pattern:` / `patterns:`), `Junction` IS the top-level YAML shape — a
7
+ * junction file's discriminator is `pattern: Junction`, not `entity:`.
8
+ * It therefore does not declare `repositoryClass` / `serviceClass`: the
9
+ * downstream Hygen-template leaf emits a dedicated junction repo/service
10
+ * per pairing.
11
+ *
12
+ * `columns` is set to `BaseJunctionFields` for two reasons:
13
+ * 1. Registry-side declaration of the shared shape — discoverable through
14
+ * `getPattern('Junction').columns` by the downstream template leaf.
15
+ * 2. Satisfies the registry's `assertHasContribution()` check, which
16
+ * insists every pattern contribute at least one of columns / repo /
17
+ * service class. (See spec §"Open Questions Q3"; recommendation (a).)
18
+ *
19
+ * See `.ai-docs/stacks/codegen-app-patterns/specs/58.md`.
20
+ */
21
+
22
+ import { z } from 'zod';
23
+ import { definePattern } from '../pattern-definition.js';
24
+ import { BaseJunctionFields } from './base-junction-fields.js';
25
+
26
+ /**
27
+ * The `pattern: Junction`-attached config block, validated at parse time.
28
+ *
29
+ * Surface is intentionally thin in this leaf — extensions land in later
30
+ * leaves (templates, association-codegen). `.strict()` rejects unknown
31
+ * keys so consumers who misspell a flag fail loudly.
32
+ */
33
+ const JunctionPatternConfigSchema = z.object({}).strict();
34
+
35
+ export const JunctionPattern = definePattern({
36
+ name: 'Junction',
37
+ description:
38
+ 'Explicit many-to-many junction with role + temporal + sourcing metadata',
39
+ columns: [...BaseJunctionFields],
40
+ configSchema: JunctionPatternConfigSchema,
41
+ });
@@ -16,7 +16,7 @@ force: true
16
16
 
17
17
  import { Inject, Injectable, NotFoundException } from '@nestjs/common';
18
18
  import { <%= repositoryToken %> } from '<%= imports.constants %>';
19
- import type { I<%= className %>Repository<%= hasRelationships ? `, ${className}With` : '' %> } from '<%= imports.domain %>';
19
+ import type { I<%= className %>Repository } from '<%= imports.domain %>';
20
20
  import { <%= className %> } from '<%= imports.domain %>';
21
21
 
22
22
  @Injectable()
@@ -26,10 +26,10 @@ export class <%= getByIdQueryClass %> {
26
26
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
27
27
  ) {}
28
28
 
29
- async execute(id: string<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>> {
29
+ async execute(id: string): Promise<<%= className %>> {
30
30
  // TODO: Add authorization check if needed (row-level security)
31
31
 
32
- const entity = await this.<%= camelName %>Repository.findById(id<%= hasRelationships ? ', include' : '' %>);
32
+ const entity = await this.<%= camelName %>Repository.findById(id);
33
33
  if (!entity) {
34
34
  throw new NotFoundException(`<%= className %> with id ${id} not found`);
35
35
  }
@@ -14,7 +14,7 @@ force: true
14
14
 
15
15
  import { Inject, Injectable, NotFoundException } from '@nestjs/common';
16
16
  import { <%= repositoryToken %> } from '<%= imports.constants %>';
17
- import type { I<%= className %>Repository<%= hasRelationships ? `, ${className}With` : '' %> } from '<%= imports.domain %>';
17
+ import type { I<%= className %>Repository } from '<%= imports.domain %>';
18
18
  import { <%= className %> } from '<%= imports.domain %>';
19
19
 
20
20
  // ============================================================================
@@ -37,10 +37,10 @@ export class <%= getByIdQueryClass %> {
37
37
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
38
38
  ) {}
39
39
 
40
- async execute(id: string<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>> {
40
+ async execute(id: string): Promise<<%= className %>> {
41
41
  // TODO: Add authorization check if needed (row-level security)
42
42
 
43
- const entity = await this.<%= camelName %>Repository.findById(id<%= hasRelationships ? ', include' : '' %>);
43
+ const entity = await this.<%= camelName %>Repository.findById(id);
44
44
  if (!entity) {
45
45
  throw new NotFoundException(`<%= className %> with id ${id} not found`);
46
46
  }
@@ -73,9 +73,9 @@ export class <%= listQueryClass %> {
73
73
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
74
74
  ) {}
75
75
 
76
- async execute(<%= hasRelationships ? `include?: ${className}With` : '' %>): Promise<<%= className %>[]> {
76
+ async execute(): Promise<<%= className %>[]> {
77
77
  // TODO: Add filtering, pagination, sorting as needed
78
- return this.<%= camelName %>Repository.findAll(<%= hasRelationships ? 'include' : '' %>);
78
+ return this.<%= camelName %>Repository.findAll();
79
79
  }
80
80
  }
81
81
  <% } -%>
@@ -11,4 +11,7 @@ force: true
11
11
 
12
12
  export * from './<%= fileNames.getByIdQuery.replace('.ts', '') %>';
13
13
  export * from './<%= fileNames.listQuery.replace('.ts', '') %>';
14
+ <% if (hasRelationships && !isGrouped) { -%>
15
+ export * from './relationships.queries';
16
+ <% } -%>
14
17
  <% } -%>
@@ -18,7 +18,7 @@ force: true
18
18
 
19
19
  import { Inject, Injectable } from '@nestjs/common';
20
20
  import { <%= repositoryToken %> } from '<%= imports.constants %>';
21
- import type { I<%= className %>Repository<%= hasRelationships ? `, ${className}With` : '' %> } from '<%= imports.domain %>';
21
+ import type { I<%= className %>Repository } from '<%= imports.domain %>';
22
22
  import { <%= className %> } from '<%= imports.domain %>';
23
23
 
24
24
  @Injectable()
@@ -28,9 +28,9 @@ export class <%= listQueryClass %> {
28
28
  private readonly <%= camelName %>Repository: I<%= className %>Repository,
29
29
  ) {}
30
30
 
31
- async execute(<%= hasRelationships ? `include?: ${className}With` : '' %>): Promise<<%= className %>[]> {
31
+ async execute(): Promise<<%= className %>[]> {
32
32
  // TODO: Add filtering, pagination, sorting as needed
33
- return this.<%= camelName %>Repository.findAll(<%= hasRelationships ? 'include' : '' %>);
33
+ return this.<%= camelName %>Repository.findAll();
34
34
  }
35
35
  }
36
36
  <% } -%>
@@ -0,0 +1,147 @@
1
+ ---
2
+ to: "<%= (isCleanArchitecture && hasRelationships && !isGrouped) ? `${basePaths.backendSrc}/${paths.queries}/relationships.queries.ts` : '' %>"
3
+ skip_if: <%= !(isCleanArchitecture && hasRelationships && !isGrouped) %>
4
+ force: true
5
+ ---
6
+ <% if (isCleanArchitecture && hasRelationships && !isGrouped) { -%>
7
+ /**
8
+ * Relationship Composition Queries for <%= className %>
9
+ * Generated by entity codegen - do not edit directly
10
+ *
11
+ * These query classes implement service-layer composition via FK + single-table
12
+ * repo calls. No SQL JOINs. Two queries, no JOIN. (CGP-358 / CGP-62)
13
+ */
14
+
15
+ import { Inject, Injectable } from '@nestjs/common';
16
+ import { <%= repositoryToken %> } from '<%= imports.constants %>';
17
+ import type { I<%= className %>Repository } from '<%= imports.domain %>/<%= name %>';
18
+ import type { <%= className %> } from '<%= imports.domain %>/<%= name %>';
19
+ <%
20
+ // Unique cross-entity targets (exclude self-refs which reuse same repo)
21
+ const crossEntityTargets = [...new Map(
22
+ [...existingBelongsTo, ...existingHasMany, ...existingHasOne]
23
+ .filter(r => r.target !== name)
24
+ .map(r => [r.target, r])
25
+ ).values()];
26
+ -%>
27
+ <% crossEntityTargets.forEach((rel) => { -%>
28
+ import type { I<%= rel.targetClass %>Repository } from '<%= imports.domain %>/<%= rel.target %>';
29
+ import type { <%= rel.targetClass %> } from '<%= imports.domain %>/<%= rel.target %>';
30
+ <% }) -%>
31
+ <% existingBelongsTo.forEach((rel) => { -%>
32
+ <%
33
+ const relNamePascal = rel.name.charAt(0).toUpperCase() + rel.name.slice(1);
34
+ const targetCamel = rel.target.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
35
+ const isSelfRef = rel.target === name;
36
+ -%>
37
+
38
+ /**
39
+ * Resolves the <%= rel.name %> (<%= rel.targetClass %>) for a given <%= className %>.
40
+ * Two repo calls: fetch <%= className %> by id → fetch <%= rel.targetClass %> by FK.
41
+ */
42
+ @Injectable()
43
+ export class <%= className %><%= relNamePascal %>Query {
44
+ constructor(
45
+ @Inject(<%= repositoryToken %>)
46
+ private readonly repository: I<%= className %>Repository,
47
+ <% if (!isSelfRef) { -%>
48
+ @Inject('<%= rel.target.toUpperCase() %>_REPOSITORY')
49
+ private readonly <%= targetCamel %>Repo: I<%= rel.targetClass %>Repository,
50
+ <% } -%>
51
+ ) {}
52
+
53
+ async execute(<%= camelName %>Id: string): Promise<<%= rel.targetClass %> | null> {
54
+ const entity = await this.repository.findById(<%= camelName %>Id);
55
+ <% if (isSelfRef) { -%>
56
+ return entity ? this.repository.findById(entity.<%= rel.foreignKeyCamel %>) : null;
57
+ <% } else { -%>
58
+ return entity ? this.<%= targetCamel %>Repo.findById(entity.<%= rel.foreignKeyCamel %>) : null;
59
+ <% } -%>
60
+ }
61
+ }
62
+ <% }) -%>
63
+ <% existingHasMany.forEach((rel) => { -%>
64
+ <%
65
+ const relNamePascal = rel.name.charAt(0).toUpperCase() + rel.name.slice(1);
66
+ const targetCamel = rel.target.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
67
+ const isSelfRef = rel.target === name;
68
+ // Derive the findBy method name from the inverseForeignKey
69
+ // inverseForeignKey = 'account_id' → foreignKeyPascal = 'AccountId'
70
+ const fkPascal = rel.inverseForeignKeyCamel.charAt(0).toUpperCase() + rel.inverseForeignKeyCamel.slice(1);
71
+ -%>
72
+
73
+ /**
74
+ * Resolves the <%= rel.name %> (<%= rel.targetClass %>[]) for a given <%= className %>.
75
+ * Single repo call with FK filter + optional cursor/limit pagination.
76
+ */
77
+ @Injectable()
78
+ export class <%= className %><%= relNamePascal %>Query {
79
+ constructor(
80
+ <% if (!isSelfRef) { -%>
81
+ @Inject('<%= rel.target.toUpperCase() %>_REPOSITORY')
82
+ private readonly <%= targetCamel %>Repo: I<%= rel.targetClass %>Repository,
83
+ <% } else { -%>
84
+ @Inject(<%= repositoryToken %>)
85
+ private readonly repository: I<%= className %>Repository,
86
+ <% } -%>
87
+ ) {}
88
+
89
+ async execute(<%= camelName %>Id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= rel.targetClass %>[]> {
90
+ <% if (!isSelfRef) { -%>
91
+ return this.<%= targetCamel %>Repo.findBy<%= fkPascal %>(<%= camelName %>Id, opts);
92
+ <% } else { -%>
93
+ return this.repository.findBy<%= fkPascal %>(<%= camelName %>Id, opts);
94
+ <% } -%>
95
+ }
96
+ }
97
+ <% }) -%>
98
+ <% existingHasOne.forEach((rel) => { -%>
99
+ <%
100
+ const relNamePascal = rel.name.charAt(0).toUpperCase() + rel.name.slice(1);
101
+ const targetCamel = rel.target.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
102
+ const isSelfRef = rel.target === name;
103
+ const fkPascal = rel.inverseForeignKeyCamel.charAt(0).toUpperCase() + rel.inverseForeignKeyCamel.slice(1);
104
+ -%>
105
+
106
+ /**
107
+ * Resolves the <%= rel.name %> (<%= rel.targetClass %>) for a given <%= className %> (has_one inverse).
108
+ * Single repo call returning at most one record.
109
+ */
110
+ @Injectable()
111
+ export class <%= className %><%= relNamePascal %>Query {
112
+ constructor(
113
+ <% if (!isSelfRef) { -%>
114
+ @Inject('<%= rel.target.toUpperCase() %>_REPOSITORY')
115
+ private readonly <%= targetCamel %>Repo: I<%= rel.targetClass %>Repository,
116
+ <% } else { -%>
117
+ @Inject(<%= repositoryToken %>)
118
+ private readonly repository: I<%= className %>Repository,
119
+ <% } -%>
120
+ ) {}
121
+
122
+ async execute(<%= camelName %>Id: string): Promise<<%= rel.targetClass %> | null> {
123
+ <% if (!isSelfRef) { -%>
124
+ const results = await this.<%= targetCamel %>Repo.findBy<%= fkPascal %>(<%= camelName %>Id);
125
+ <% } else { -%>
126
+ const results = await this.repository.findBy<%= fkPascal %>(<%= camelName %>Id);
127
+ <% } -%>
128
+ return results[0] ?? null;
129
+ }
130
+ }
131
+ <% }) -%>
132
+
133
+ export const relationshipQueryClasses = [
134
+ <% existingBelongsTo.forEach((rel) => { -%>
135
+ <% const relNamePascal = rel.name.charAt(0).toUpperCase() + rel.name.slice(1); -%>
136
+ <%= className %><%= relNamePascal %>Query,
137
+ <% }) -%>
138
+ <% existingHasMany.forEach((rel) => { -%>
139
+ <% const relNamePascal = rel.name.charAt(0).toUpperCase() + rel.name.slice(1); -%>
140
+ <%= className %><%= relNamePascal %>Query,
141
+ <% }) -%>
142
+ <% existingHasOne.forEach((rel) => { -%>
143
+ <% const relNamePascal = rel.name.charAt(0).toUpperCase() + rel.name.slice(1); -%>
144
+ <%= className %><%= relNamePascal %>Query,
145
+ <% }) -%>
146
+ ];
147
+ <% } -%>
@@ -19,20 +19,8 @@ import type {
19
19
  Create<%= className %>Input,
20
20
  I<%= className %>Repository,
21
21
  Update<%= className %>Input,
22
- <% if (hasRelationships) { -%>
23
- <%= className %>With,
24
- <% } -%>
25
22
  } from '../../../domain';
26
- <%
27
- // Collect unique entity imports - only include relationships with existing target entities
28
- const entityImports = new Set([className]);
29
- if (hasExistingRelationships) {
30
- [...existingBelongsTo, ...existingHasMany, ...existingHasOne].forEach((rel) => {
31
- entityImports.add(rel.targetClass);
32
- });
33
- }
34
- -%>
35
- import { <%- [...entityImports].join(', ') %> } from '../../../domain';
23
+ import { <%= className %> } from '../../../domain';
36
24
  import { <%= plural %> } from '<%= locations.dbSchemaServer.import %>';
37
25
 
38
26
  @Injectable()
@@ -57,12 +45,20 @@ export class <%= className %>Repository
57
45
  protected toEntity(record: typeof <%= plural %>.$inferSelect): <%= className %> {
58
46
  return <%= className %>.fromRecord(record);
59
47
  }
48
+ <%_
49
+ // CGP-358: FK methods with opts take priority over same-named declarative query impl.
50
+ // Always emit FK methods with opts; skip the declarative query body when a FK method
51
+ // covers the same method name (simple single-FK, non-unique, non-via, non-select).
52
+ // The declarative use-case class still works because opts is optional.
53
+ const _fkMethodNames = new Set(belongsToRelations.map(rel => `findBy${rel.foreignKeyPascal}`));
54
+ _%>
60
55
  <% belongsToRelations.forEach((rel) => { -%>
61
56
 
62
- async findBy<%= rel.foreignKeyPascal %>(id: string): Promise<<%= className %>[]> {
63
- const records = await this.baseQuery()
57
+ async findBy<%= rel.foreignKeyPascal %>(id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= className %>[]> {
58
+ let q = this.baseQuery()
64
59
  .where(eq(this.table.<%= rel.foreignKeyCamel %>, id));
65
- return records.map((r) => this.toEntity(r));
60
+ if (opts?.limit) q = (q as any).limit(opts.limit);
61
+ return (await q).map((r) => this.toEntity(r));
66
62
  }
67
63
  <% }) -%>
68
64
  <% entityRefFields.forEach((ref) => { -%>
@@ -84,6 +80,12 @@ export class <%= className %>Repository
84
80
  // Declarative queries (from queries: block in entity YAML)
85
81
  // ═══════════════════════════════════════════════════════════════════════
86
82
  <% processedQueries.forEach((q) => { -%>
83
+ <%_
84
+ // Skip declarative impl when a FK method already covers this method name.
85
+ // FK methods accept opts so they're a superset of a plain single-param non-unique query.
86
+ const _skipDqImpl = _fkMethodNames.has(q.methodName) && !q.isUnique && !q.hasVia && !q.hasSelect;
87
+ _%>
88
+ <% if (!_skipDqImpl) { -%>
87
89
 
88
90
  async <%= q.methodName %>(<%- q.params.map(p => `${p.camelName}: ${p.tsType}`).join(', ') %>): Promise<<%- q.returnType %>> {
89
91
  <% if (q.hasVia) { -%>
@@ -107,70 +109,8 @@ export class <%= className %>Repository
107
109
  return records.map(r => this.toEntity(r));
108
110
  <% } -%>
109
111
  }
110
- <% }) -%>
111
112
  <% } -%>
112
- <% if (hasRelationships) { -%>
113
-
114
- // ═══════════════════════════════════════════════════════════════════════
115
- // Relationship loading (extends base class)
116
- // ═══════════════════════════════════════════════════════════════════════
117
-
118
- async findByIdWithRelations(
119
- id: string,
120
- include?: <%= className %>With,
121
- ): Promise<<%= className %> | null> {
122
- const record = await this.db.query.<%= plural %>.findFirst({
123
- where: eq(<%= plural %>.id, id),
124
- with: this.buildWithClause(include),
125
- });
126
- return record ? this.mapToEntityWithRelations(record) : null;
127
- }
128
-
129
- async findAllWithRelations(include?: <%= className %>With): Promise<<%= className %>[]> {
130
- const records = await this.db.query.<%= plural %>.findMany({
131
- with: this.buildWithClause(include),
132
- });
133
- return records.map((r) => this.mapToEntityWithRelations(r));
134
- }
135
-
136
- private buildWithClause(include?: <%= className %>With) {
137
- if (!include) return undefined;
138
- const result: Record<string, true> = {};
139
- <% relationships.forEach((rel) => { -%>
140
- if (include.<%= rel.name %>) result.<%= rel.name %> = true;
141
113
  <% }) -%>
142
- return Object.keys(result).length > 0 ? result : undefined;
143
- }
144
-
145
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle relational query returns dynamic shape
146
- private mapToEntityWithRelations(record: any): <%= className %> {
147
- <% if (hasExistingRelationships) { -%>
148
- return <%= className %>.fromRecord(record, (name, data) => {
149
- switch (name) {
150
- <% existingBelongsTo.forEach((rel) => { -%>
151
- case '<%= rel.name %>':
152
- // biome-ignore lint/suspicious/noExplicitAny: Cast for Drizzle record
153
- return <%= rel.targetClass %>.fromRecord(data as any);
154
- <% }) -%>
155
- <% existingHasMany.forEach((rel) => { -%>
156
- case '<%= rel.name %>':
157
- // biome-ignore lint/suspicious/noExplicitAny: Cast for Drizzle records
158
- return (data as any[]).map((r) => <%= rel.targetClass %>.fromRecord(r));
159
- <% }) -%>
160
- <% existingHasOne.forEach((rel) => { -%>
161
- case '<%= rel.name %>':
162
- // biome-ignore lint/suspicious/noExplicitAny: Cast for Drizzle record
163
- return <%= rel.targetClass %>.fromRecord(data as any);
164
- <% }) -%>
165
- default:
166
- return data;
167
- }
168
- });
169
- <% } else { -%>
170
- // Related entities not yet generated - return entity without relationship mapping
171
- return <%= className %>.fromRecord(record);
172
- <% } -%>
173
- }
174
114
  <% } -%>
175
115
  }
176
116
  <% } else { -%>
@@ -191,20 +131,8 @@ import type {
191
131
  Create<%= className %>Input,
192
132
  I<%= className %>Repository,
193
133
  Update<%= className %>Input,
194
- <% if (hasRelationships) { -%>
195
- <%= className %>With,
196
- <% } -%>
197
134
  } from '../../../domain';
198
- <%
199
- // Collect unique entity imports - only include relationships with existing target entities
200
- const entityImports = new Set([className]);
201
- if (hasExistingRelationships) {
202
- [...existingBelongsTo, ...existingHasMany, ...existingHasOne].forEach((rel) => {
203
- entityImports.add(rel.targetClass);
204
- });
205
- }
206
- -%>
207
- import { <%- [...entityImports].join(', ') %> } from '../../../domain';
135
+ import { <%= className %> } from '../../../domain';
208
136
  import type { DrizzleDB } from '../database.module';
209
137
  import { <%= plural %> } from '<%= locations.dbSchemaServer.import %>';
210
138
 
@@ -238,35 +166,18 @@ export class <%= className %>Repository implements I<%= className %>Repository {
238
166
  return <%= className %>.fromRecord(record);
239
167
  }
240
168
 
241
- async findById(id: string<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %> | null> {
242
- <% if (hasRelationships) { -%>
243
- const record = await this.db.query.<%= plural %>.findFirst({
244
- where: eq(<%= plural %>.id, id),
245
- with: this.buildWithClause(include),
246
- });
247
-
248
- return record ? this.mapToEntity(record) : null;
249
- <% } else { -%>
169
+ async findById(id: string): Promise<<%= className %> | null> {
250
170
  const result = await this.baseQuery()
251
171
  .where(eq(<%= plural %>.id, id))
252
172
  .limit(1);
253
173
 
254
174
  const record = result[0];
255
175
  return record ? <%= className %>.fromRecord(record) : null;
256
- <% } -%>
257
176
  }
258
177
 
259
- async findAll(<%= hasRelationships ? `include?: ${className}With` : '' %>): Promise<<%= className %>[]> {
260
- <% if (hasRelationships) { -%>
261
- const records = await this.db.query.<%= plural %>.findMany({
262
- with: this.buildWithClause(include),
263
- });
264
-
265
- return records.map((r) => this.mapToEntity(r));
266
- <% } else { -%>
178
+ async findAll(): Promise<<%= className %>[]> {
267
179
  const records = await this.baseQuery();
268
180
  return records.map(<%= className %>.fromRecord);
269
- <% } -%>
270
181
  }
271
182
 
272
183
  async update(id: string, input: Update<%= className %>Input<%= (hasEmits && updateEventType) ? ', tx?: DrizzleTransaction' : '' %>): Promise<<%= className %> | null> {
@@ -359,38 +270,24 @@ export class <%= className %>Repository implements I<%= className %>Repository {
359
270
  return this.db.select().from(<%= plural %>);
360
271
  <% } -%>
361
272
  }
273
+ <%_
274
+ // CGP-358: FK methods with opts take priority over same-named declarative query impl.
275
+ // Always emit FK methods with opts; skip declarative body when FK covers it.
276
+ const _fkMethodNamesInline = new Set(belongsToRelations.map(rel => `findBy${rel.foreignKeyPascal}`));
277
+ _%>
362
278
  <% belongsToRelations.forEach((rel) => { -%>
363
279
 
364
- async findBy<%= rel.foreignKeyPascal %>(id: string<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>[]> {
365
- <% if (hasRelationships) { -%>
366
- const records = await this.db.query.<%= plural %>.findMany({
367
- where: eq(<%= plural %>.<%= rel.foreignKeyCamel %>, id),
368
- with: this.buildWithClause(include),
369
- });
370
-
371
- return records.map((r) => this.mapToEntity(r));
372
- <% } else { -%>
373
- const records = await this.baseQuery()
280
+ async findBy<%= rel.foreignKeyPascal %>(id: string, opts?: { cursor?: string; limit?: number }): Promise<<%= className %>[]> {
281
+ let q = this.baseQuery()
374
282
  .where(eq(<%= plural %>.<%= rel.foreignKeyCamel %>, id));
375
-
283
+ if (opts?.limit) q = (q as any).limit(opts.limit);
284
+ const records = await q;
376
285
  return records.map(<%= className %>.fromRecord);
377
- <% } -%>
378
286
  }
379
287
  <% }) -%>
380
288
  <% entityRefFields.forEach((ref) => { -%>
381
289
 
382
- async findBy<%= ref.pascalName %>(entityType: EntityType, entityId: string<%= hasRelationships ? `, include?: ${className}With` : '' %>): Promise<<%= className %>[]> {
383
- <% if (hasRelationships) { -%>
384
- const records = await this.db.query.<%= plural %>.findMany({
385
- where: and(
386
- eq(<%= plural %>.<%= ref.camelName %>EntityType, entityType),
387
- eq(<%= plural %>.<%= ref.camelName %>EntityId, entityId)
388
- ),
389
- with: this.buildWithClause(include),
390
- });
391
-
392
- return records.map((r) => this.mapToEntity(r));
393
- <% } else { -%>
290
+ async findBy<%= ref.pascalName %>(entityType: EntityType, entityId: string): Promise<<%= className %>[]> {
394
291
  const records = await this.baseQuery()
395
292
  .where(
396
293
  and(
@@ -400,7 +297,6 @@ export class <%= className %>Repository implements I<%= className %>Repository {
400
297
  );
401
298
 
402
299
  return records.map(<%= className %>.fromRecord);
403
- <% } -%>
404
300
  }
405
301
  <% }) -%>
406
302
  <% if (hasDeclarativeQueries) { -%>
@@ -409,6 +305,11 @@ export class <%= className %>Repository implements I<%= className %>Repository {
409
305
  // Declarative queries (from queries: block in entity YAML)
410
306
  // ═══════════════════════════════════════════════════════════════════════
411
307
  <% processedQueries.forEach((q) => { -%>
308
+ <%_
309
+ // Skip declarative impl when a FK method already covers this method name.
310
+ const _skipDqImplInline = _fkMethodNamesInline.has(q.methodName) && !q.isUnique && !q.hasVia && !q.hasSelect;
311
+ _%>
312
+ <% if (!_skipDqImplInline) { -%>
412
313
 
413
314
  async <%= q.methodName %>(<%- q.params.map(p => `${p.camelName}: ${p.tsType}`).join(', ') %>): Promise<<%- q.returnType %>> {
414
315
  <% if (q.hasVia) { -%>
@@ -432,49 +333,8 @@ export class <%= className %>Repository implements I<%= className %>Repository {
432
333
  return records.map(<%= className %>.fromRecord);
433
334
  <% } -%>
434
335
  }
435
- <% }) -%>
436
336
  <% } -%>
437
- <% if (hasRelationships) { -%>
438
-
439
- private buildWithClause(include?: <%= className %>With) {
440
- if (!include) return undefined;
441
- // Drizzle expects `true` or object, not `false`. Only include truthy values.
442
- const result: Record<string, true> = {};
443
- <% relationships.forEach((rel) => { -%>
444
- if (include.<%= rel.name %>) result.<%= rel.name %> = true;
445
- <% }) -%>
446
- return Object.keys(result).length > 0 ? result : undefined;
447
- }
448
-
449
- // biome-ignore lint/suspicious/noExplicitAny: Drizzle relational query returns dynamic shape
450
- private mapToEntity(record: any): <%= className %> {
451
- <% if (hasExistingRelationships) { -%>
452
- return <%= className %>.fromRecord(record, (name, data) => {
453
- switch (name) {
454
- <% existingBelongsTo.forEach((rel) => { -%>
455
- case '<%= rel.name %>':
456
- // biome-ignore lint/suspicious/noExplicitAny: Cast for Drizzle record
457
- return <%= rel.targetClass %>.fromRecord(data as any);
458
337
  <% }) -%>
459
- <% existingHasMany.forEach((rel) => { -%>
460
- case '<%= rel.name %>':
461
- // biome-ignore lint/suspicious/noExplicitAny: Cast for Drizzle records
462
- return (data as any[]).map((r) => <%= rel.targetClass %>.fromRecord(r));
463
- <% }) -%>
464
- <% existingHasOne.forEach((rel) => { -%>
465
- case '<%= rel.name %>':
466
- // biome-ignore lint/suspicious/noExplicitAny: Cast for Drizzle record
467
- return <%= rel.targetClass %>.fromRecord(data as any);
468
- <% }) -%>
469
- default:
470
- return data;
471
- }
472
- });
473
- <% } else { -%>
474
- // Related entities not yet generated - return entity without relationship mapping
475
- return <%= className %>.fromRecord(record);
476
- <% } -%>
477
- }
478
338
  <% } -%>
479
339
  }
480
340
  <% } -%>