@nestledjs/api 0.0.1

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 (132) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +11 -0
  3. package/eslint.config.cjs +28 -0
  4. package/generators.json +69 -0
  5. package/package.json +21 -0
  6. package/project.json +47 -0
  7. package/src/account/files/data-access/src/index.ts__tmpl__ +5 -0
  8. package/src/account/files/data-access/src/lib/api-account-data-access.module.ts__tmpl__ +10 -0
  9. package/src/account/files/data-access/src/lib/api-account-data-access.service.ts__tmpl__ +152 -0
  10. package/src/account/files/data-access/src/lib/dto/account-create-email.input.ts__tmpl__ +9 -0
  11. package/src/account/files/data-access/src/lib/dto/account-update-password.input.ts__tmpl__ +16 -0
  12. package/src/account/files/data-access/src/lib/dto/account-update-profile.input.ts__tmpl__ +25 -0
  13. package/src/account/files/feature/src/index.ts__tmpl__ +1 -0
  14. package/src/account/files/feature/src/lib/api-account-feature.module.ts__tmpl__ +9 -0
  15. package/src/account/files/feature/src/lib/api-account-feature.resolver.ts__tmpl__ +83 -0
  16. package/src/account/generator.spec.ts +71 -0
  17. package/src/account/generator.ts +20 -0
  18. package/src/account/schema.d.ts +3 -0
  19. package/src/account/schema.json +13 -0
  20. package/src/app/files/src/app.config.ts__tmpl__ +66 -0
  21. package/src/app/files/src/app.module.ts__tmpl__ +43 -0
  22. package/src/app/files/src/applogger.middleware.ts__tmpl__ +21 -0
  23. package/src/app/files/src/main.ts__tmpl__ +33 -0
  24. package/src/app/files/webpack.config.js__tmpl__ +54 -0
  25. package/src/app/generator.spec.ts +112 -0
  26. package/src/app/generator.ts +105 -0
  27. package/src/app/schema.d.ts +1 -0
  28. package/src/app/schema.json +9 -0
  29. package/src/config/files/src/index.ts__tmpl__ +3 -0
  30. package/src/config/files/src/lib/config.service.ts__tmpl__ +51 -0
  31. package/src/config/files/src/lib/configuration.ts__tmpl__ +32 -0
  32. package/src/config/files/src/lib/validation.ts__tmpl__ +21 -0
  33. package/src/config/generator.spec.ts +47 -0
  34. package/src/config/generator.ts +16 -0
  35. package/src/config/schema.d.ts +3 -0
  36. package/src/config/schema.json +13 -0
  37. package/src/core/files/data-access/src/index.ts__tmpl__ +5 -0
  38. package/src/core/files/data-access/src/lib/api-core-data-access.module.ts__tmpl__ +9 -0
  39. package/src/core/files/data-access/src/lib/api-core-data-access.service.ts__tmpl__ +97 -0
  40. package/src/core/files/data-access/src/lib/api-core-pub-sub.ts__tmpl__ +37 -0
  41. package/src/core/files/data-access/src/lib/dto/core-paging.input.ts__tmpl__ +26 -0
  42. package/src/core/files/data-access/src/lib/dto/multi-select-input.ts__tmpl__ +7 -0
  43. package/src/core/files/data-access/src/lib/models/core-paging.ts__tmpl__ +19 -0
  44. package/src/core/files/feature/src/index.ts__tmpl__ +2 -0
  45. package/src/core/files/feature/src/lib/api-core-feature.controller.ts__tmpl__ +12 -0
  46. package/src/core/files/feature/src/lib/api-core-feature.module.ts__tmpl__ +86 -0
  47. package/src/core/files/feature/src/lib/api-core-feature.resolver.ts__tmpl__ +12 -0
  48. package/src/core/files/feature/src/lib/api-core-feature.service.ts__tmpl__ +55 -0
  49. package/src/core/files/feature/src/lib/config/configuration.ts__tmpl__ +32 -0
  50. package/src/core/files/feature/src/lib/config/validation.ts__tmpl__ +25 -0
  51. package/src/core/files/feature/src/lib/plugins/complexity.plugin.ts__tmpl__ +51 -0
  52. package/src/core/files/feature/src/lib/plugins/logging.plugin.ts__tmpl__ +17 -0
  53. package/src/core/files/models/src/index.ts__tmpl__ +1 -0
  54. package/src/core/files/models/src/lib/generate-models.ts__tmpl__ +294 -0
  55. package/src/core/files/models/src/lib/models/core-paging.model.ts__tmpl__ +25 -0
  56. package/src/core/generator.spec.ts +85 -0
  57. package/src/core/generator.ts +35 -0
  58. package/src/core/schema.d.ts +3 -0
  59. package/src/core/schema.json +13 -0
  60. package/src/custom/generator.spec.ts +75 -0
  61. package/src/custom/generator.ts +239 -0
  62. package/src/custom/schema.json +21 -0
  63. package/src/custom/schema.ts +5 -0
  64. package/src/extended/generator.spec.ts +95 -0
  65. package/src/extended/generator.ts +161 -0
  66. package/src/extended/index.ts +1 -0
  67. package/src/extended/schema.json +12 -0
  68. package/src/extended/schema.ts +3 -0
  69. package/src/generate-crud/files/data-access/src/index.ts__tmpl__ +3 -0
  70. package/src/generate-crud/files/data-access/src/lib/api-crud-data-access.module.ts__tmpl__ +11 -0
  71. package/src/generate-crud/files/data-access/src/lib/api-crud-data-access.service.ts__tmpl__ +72 -0
  72. package/src/generate-crud/files/data-access/src/lib/dto/index.ts__tmpl__ +224 -0
  73. package/src/generate-crud/files/feature/.gitkeep +0 -0
  74. package/src/generate-crud/generator.spec.ts +84 -0
  75. package/src/generate-crud/generator.ts +354 -0
  76. package/src/generate-crud/schema.json +32 -0
  77. package/src/generate-crud/schema.ts +8 -0
  78. package/src/index.ts +13 -0
  79. package/src/plugin/generator.spec.ts +18 -0
  80. package/src/plugin/generator.ts +74 -0
  81. package/src/plugin/schema.json +14 -0
  82. package/src/plugin/schema.ts +4 -0
  83. package/src/prisma/files/src/index.ts__tmpl__ +1 -0
  84. package/src/prisma/files/src/lib/.gitkeep +1 -0
  85. package/src/prisma/files/src/lib/schemas/schema.prisma__tmpl__ +402 -0
  86. package/src/prisma/files/src/lib/seed/seed-data/iso-3166-countries.ts__tmpl__ +3239 -0
  87. package/src/prisma/files/src/lib/seed/seed-data/seed-users.ts__tmpl__ +32 -0
  88. package/src/prisma/files/src/lib/seed/seed.ts__tmpl__ +64 -0
  89. package/src/prisma/generator.spec.ts +60 -0
  90. package/src/prisma/generator.ts +61 -0
  91. package/src/prisma/schema.d.ts +3 -0
  92. package/src/prisma/schema.json +13 -0
  93. package/src/setup/generator.md +49 -0
  94. package/src/setup/generator.spec.ts +18 -0
  95. package/src/setup/generator.ts +106 -0
  96. package/src/setup/schema.json +8 -0
  97. package/src/smtp-mailer/files/data-access/src/index.ts__tmpl__ +2 -0
  98. package/src/smtp-mailer/files/data-access/src/lib/api-smtp-mailer-data-access.module.ts__tmpl__ +10 -0
  99. package/src/smtp-mailer/files/data-access/src/lib/api-smtp-mailer-data-access.service.ts__tmpl__ +61 -0
  100. package/src/smtp-mailer/generator.spec.ts +41 -0
  101. package/src/smtp-mailer/generator.ts +14 -0
  102. package/src/smtp-mailer/schema.d.ts +0 -0
  103. package/src/smtp-mailer/schema.json +7 -0
  104. package/src/user/files/data-access/src/index.ts__tmpl__ +5 -0
  105. package/src/user/files/data-access/src/lib/api-user-data-access.module.ts__tmpl__ +10 -0
  106. package/src/user/files/data-access/src/lib/api-user-data-access.service.ts__tmpl__ +119 -0
  107. package/src/user/files/data-access/src/lib/dto/admin-create-user.input.ts__tmpl__ +20 -0
  108. package/src/user/files/data-access/src/lib/dto/admin-update-user.input.ts__tmpl__ +29 -0
  109. package/src/user/files/feature/src/index.ts__tmpl__ +1 -0
  110. package/src/user/files/feature/src/lib/api-user-feature-admin.resolver.ts__tmpl__ +57 -0
  111. package/src/user/files/feature/src/lib/api-user-feature.module.ts__tmpl__ +10 -0
  112. package/src/user/files/feature/src/lib/api-user-feature.resolver.ts__tmpl__ +17 -0
  113. package/src/user/generator.spec.ts +41 -0
  114. package/src/user/generator.ts +15 -0
  115. package/src/user/schema.d.ts +0 -0
  116. package/src/user/schema.json +7 -0
  117. package/src/utils/files/src/index.ts__tmpl__ +3 -0
  118. package/src/utils/files/src/lib/decorators/ctx-user.decorator.ts__tmpl__ +6 -0
  119. package/src/utils/files/src/lib/guards/gql-auth-admin.guard.ts__tmpl__ +39 -0
  120. package/src/utils/files/src/lib/guards/gql-auth.guard.ts__tmpl__ +11 -0
  121. package/src/utils/generator.ts +14 -0
  122. package/src/utils/schema.json +8 -0
  123. package/src/workspace-setup/generator.md +39 -0
  124. package/src/workspace-setup/generator.spec.ts +82 -0
  125. package/src/workspace-setup/generator.ts +49 -0
  126. package/src/workspace-setup/lib/helpers.ts +142 -0
  127. package/src/workspace-setup/schema.d.ts +3 -0
  128. package/src/workspace-setup/schema.json +7 -0
  129. package/tsconfig.json +16 -0
  130. package/tsconfig.lib.json +23 -0
  131. package/tsconfig.spec.json +22 -0
  132. package/vite.config.mts +37 -0
@@ -0,0 +1,224 @@
1
+ <%# EJS template for generating DTOs %>
2
+ <%
3
+ let gqlImports = new Set(['Field', 'InputType']);
4
+ let usesGraphQLJSON = false;
5
+ let usesInt = false;
6
+ let usesFloat = false;
7
+ let usesID = false;
8
+ let enumNames = new Set();
9
+
10
+ // First pass to determine necessary imports and enums
11
+ for (const model of models) {
12
+ for (const field of model.fields) {
13
+ if (field.type === 'Int') usesInt = true;
14
+ if (field.type === 'Float' || field.type === 'Decimal') usesFloat = true;
15
+ if (field.type === 'Json') usesGraphQLJSON = true;
16
+
17
+ if (field.type === 'ID') { // Explicit ID field
18
+ usesID = true;
19
+ } else if (field.kind === 'object') {
20
+ // Check if a corresponding scalar ID field (e.g., 'userId') exists for this object field (e.g., 'user')
21
+ const correspondingIdFieldName = `${field.name}Id`;
22
+ const hasCorrespondingIdField = model.fields.some(f => f.name === correspondingIdFieldName && f.kind !== 'object');
23
+ if (!hasCorrespondingIdField) {
24
+ // If no pre-existing '...Id' scalar, we'll generate one, so 'ID' import is needed.
25
+ usesID = true;
26
+ }
27
+ } else if (field.kind === 'enum') {
28
+ enumNames.add(field.type);
29
+ }
30
+ }
31
+ }
32
+
33
+ if (usesInt) gqlImports.add('Int');
34
+ if (usesFloat) gqlImports.add('Float');
35
+ if (usesID) gqlImports.add('ID');
36
+ %>
37
+ import { <%= Array.from(gqlImports).join(', ') %> } from '@nestjs/graphql'
38
+
39
+ <% if (enumNames.size > 0) { %>import { <%= Array.from(enumNames).join(', ') %> } from '<%= npmScope %>/api/core/models'
40
+ <% } %><% if (usesGraphQLJSON) { %>import { GraphQLJSON } from 'graphql-type-json'<% } %>
41
+ import { CorePagingInput } from '<%= npmScope %>/api/core/data-access'
42
+
43
+ <% for (const model of models) { %>
44
+ @InputType()
45
+ export class Create<%= model.modelName %>Input {
46
+ <% for (const field of model.fields) { %>
47
+ <%
48
+ let shouldSkipField = false;
49
+ let baseGqlType;
50
+ let tsType;
51
+ let effectiveFieldName = field.name;
52
+
53
+ if (field.kind === 'object') {
54
+ const correspondingIdFieldName = `${field.name}Id`;
55
+ const hasCorrespondingIdField = model.fields.some(f => f.name === correspondingIdFieldName && f.kind !== 'object');
56
+ if (hasCorrespondingIdField) {
57
+ shouldSkipField = true; // Skip this object field; its ID is handled by the existing scalar field.
58
+ } else {
59
+ // No corresponding '...Id' scalar found, so generate an ID field for this relation.
60
+ baseGqlType = 'ID';
61
+ tsType = 'string';
62
+ effectiveFieldName = correspondingIdFieldName;
63
+ }
64
+ } else {
65
+ // Handle non-object (scalar, enum, or explicit ID) fields
66
+ effectiveFieldName = field.name;
67
+ if (field.type === 'String') { baseGqlType = 'String'; tsType = 'string'; }
68
+ else if (field.type === 'Int') { baseGqlType = 'Int'; tsType = 'number'; }
69
+ else if (field.type === 'Float') { baseGqlType = 'Float'; tsType = 'number'; }
70
+ else if (field.type === 'Decimal') { baseGqlType = 'Float'; tsType = 'number'; }
71
+ else if (field.type === 'Boolean') { baseGqlType = 'Boolean'; tsType = 'boolean'; }
72
+ else if (field.type === 'DateTime') { baseGqlType = 'Date'; tsType = 'Date'; }
73
+ else if (field.type === 'Json') { baseGqlType = 'GraphQLJSON'; tsType = 'typeof GraphQLJSON'; }
74
+ else if (field.type === 'ID') { baseGqlType = 'ID'; tsType = 'string'; }
75
+ else if (field.kind === 'enum') {
76
+ baseGqlType = field.type;
77
+ tsType = field.type;
78
+ } else { // Fallback, should ideally not be reached if all model types are known
79
+ baseGqlType = 'String'; tsType = 'string';
80
+ }
81
+ }
82
+
83
+ let fieldDecoratorTypeArg = '';
84
+ let finalTsType = tsType;
85
+
86
+ if (!shouldSkipField) {
87
+ if (field.isList) { // Use original field's isList property
88
+ fieldDecoratorTypeArg = `() => [${baseGqlType}]`; // baseGqlType is 'ID' if transformed from object list
89
+ finalTsType = tsType + '[]'; // tsType is 'string' for ID, so 'string[]'
90
+ } else {
91
+ // For non-list fields (scalars or single objects transformed to ID)
92
+ if (baseGqlType !== 'String' && baseGqlType !== 'Boolean') {
93
+ fieldDecoratorTypeArg = `() => ${baseGqlType}`;
94
+ }
95
+ // finalTsType is already 'tsType', which is correct
96
+ }
97
+ }
98
+ %>
99
+ <% if (!shouldSkipField) { %>
100
+ @Field(<% if (fieldDecoratorTypeArg) { %><%- fieldDecoratorTypeArg %>, <% } %>{ nullable: true })
101
+ <%= effectiveFieldName %>?: <%= finalTsType %>
102
+ <% } %>
103
+ <% } %>
104
+ }
105
+
106
+ @InputType()
107
+ export class Update<%= model.modelName %>Input {
108
+ <% for (const field of model.fields) { %>
109
+ <%
110
+ let shouldSkipField = false;
111
+ let baseGqlType;
112
+ let tsType;
113
+ let effectiveFieldName = field.name;
114
+
115
+ if (field.kind === 'object') {
116
+ const correspondingIdFieldName = `${field.name}Id`;
117
+ const hasCorrespondingIdField = model.fields.some(f => f.name === correspondingIdFieldName && f.kind !== 'object');
118
+ if (hasCorrespondingIdField) {
119
+ shouldSkipField = true;
120
+ } else {
121
+ baseGqlType = 'ID';
122
+ tsType = 'string';
123
+ effectiveFieldName = correspondingIdFieldName;
124
+ }
125
+ } else {
126
+ effectiveFieldName = field.name;
127
+ if (field.type === 'String') { baseGqlType = 'String'; tsType = 'string'; }
128
+ else if (field.type === 'Int') { baseGqlType = 'Int'; tsType = 'number'; }
129
+ else if (field.type === 'Float') { baseGqlType = 'Float'; tsType = 'number'; }
130
+ else if (field.type === 'Decimal') { baseGqlType = 'Float'; tsType = 'number'; }
131
+ else if (field.type === 'Boolean') { baseGqlType = 'Boolean'; tsType = 'boolean'; }
132
+ else if (field.type === 'DateTime') { baseGqlType = 'Date'; tsType = 'Date'; }
133
+ else if (field.type === 'Json') { baseGqlType = 'GraphQLJSON'; tsType = 'typeof GraphQLJSON'; }
134
+ else if (field.type === 'ID') { baseGqlType = 'ID'; tsType = 'string'; }
135
+ else if (field.kind === 'enum') {
136
+ baseGqlType = field.type;
137
+ tsType = field.type;
138
+ } else {
139
+ baseGqlType = 'String'; tsType = 'string';
140
+ }
141
+ }
142
+
143
+ let fieldDecoratorTypeArg = '';
144
+ let finalTsType = tsType;
145
+
146
+ if (!shouldSkipField) {
147
+ if (field.isList) {
148
+ fieldDecoratorTypeArg = `() => [${baseGqlType}]`;
149
+ finalTsType = tsType + '[]';
150
+ } else {
151
+ if (baseGqlType !== 'String' && baseGqlType !== 'Boolean') {
152
+ fieldDecoratorTypeArg = `() => ${baseGqlType}`;
153
+ }
154
+ }
155
+ }
156
+ %>
157
+ <% if (!shouldSkipField) { %>
158
+ @Field(<% if (fieldDecoratorTypeArg) { %><%- fieldDecoratorTypeArg %>, <% } %>{ nullable: true })
159
+ <%= effectiveFieldName %>?: <%= finalTsType %>
160
+ <% } %>
161
+ <% } %>
162
+ }
163
+
164
+ @InputType()
165
+ export class List<%= model.modelName %>Input extends CorePagingInput {
166
+ <% for (const field of model.fields) { %>
167
+ <%
168
+ let shouldSkipField = false;
169
+ let baseGqlType;
170
+ let tsType;
171
+ let effectiveFieldName = field.name;
172
+
173
+ if (field.kind === 'object') {
174
+ const correspondingIdFieldName = `${field.name}Id`;
175
+ const hasCorrespondingIdField = model.fields.some(f => f.name === correspondingIdFieldName && f.kind !== 'object');
176
+ if (hasCorrespondingIdField) {
177
+ shouldSkipField = true;
178
+ } else {
179
+ // For ListInput, if no direct ...Id field, we create one for filtering by relation ID
180
+ baseGqlType = 'ID';
181
+ tsType = 'string';
182
+ effectiveFieldName = correspondingIdFieldName;
183
+ }
184
+ } else {
185
+ // Handle non-object fields for filtering
186
+ effectiveFieldName = field.name;
187
+ if (field.type === 'String') { baseGqlType = 'String'; tsType = 'string'; }
188
+ else if (field.type === 'Int') { baseGqlType = 'Int'; tsType = 'number'; }
189
+ else if (field.type === 'Float') { baseGqlType = 'Float'; tsType = 'number'; }
190
+ else if (field.type === 'Decimal') { baseGqlType = 'Float'; tsType = 'number'; }
191
+ else if (field.type === 'Boolean') { baseGqlType = 'Boolean'; tsType = 'boolean'; }
192
+ else if (field.type === 'DateTime') { baseGqlType = 'Date'; tsType = 'Date'; }
193
+ else if (field.type === 'Json') { baseGqlType = 'GraphQLJSON'; tsType = 'typeof GraphQLJSON'; }
194
+ else if (field.type === 'ID') { baseGqlType = 'ID'; tsType = 'string'; }
195
+ else if (field.kind === 'enum') {
196
+ baseGqlType = field.type;
197
+ tsType = field.type;
198
+ } else {
199
+ baseGqlType = 'String'; tsType = 'string';
200
+ }
201
+ }
202
+
203
+ let fieldDecoratorTypeArg = '';
204
+ let finalTsType = tsType;
205
+
206
+ if (!shouldSkipField) {
207
+ if (field.isList) {
208
+ fieldDecoratorTypeArg = `() => [${baseGqlType}]`;
209
+ finalTsType = tsType + '[]';
210
+ } else {
211
+ if (baseGqlType !== 'String' && baseGqlType !== 'Boolean') {
212
+ fieldDecoratorTypeArg = `() => ${baseGqlType}`;
213
+ }
214
+ }
215
+ }
216
+ %>
217
+ <% if (!shouldSkipField) { %>
218
+ @Field(<% if (fieldDecoratorTypeArg) { %><%- fieldDecoratorTypeArg %>, <% } %>{ nullable: true })
219
+ <%= effectiveFieldName %>?: <%= finalTsType %>
220
+ <% } %>
221
+ <% } %>
222
+ }
223
+
224
+ <% } %>
File without changes
@@ -0,0 +1,84 @@
1
+ // generators/api/src/generate-crud/generator.spec.ts
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'
5
+ import { GenerateCrudGeneratorDependencies, generateCrudLogic } from './generator'
6
+ import { Tree } from '@nx/devkit'
7
+
8
+ // The mocked DMMF object
9
+ const dmmf = {
10
+ datamodel: {
11
+ models: [
12
+ {
13
+ name: 'User',
14
+ fields: [
15
+ { name: 'id', type: 'Int', isId: true },
16
+ { name: 'name', type: 'String', isId: false },
17
+ ],
18
+ },
19
+ ],
20
+ },
21
+ }
22
+
23
+ describe('generate-crud generator', () => {
24
+ let tree: Tree
25
+ let mockDependencies: GenerateCrudGeneratorDependencies
26
+
27
+ beforeEach(() => {
28
+ tree = createTreeWithEmptyWorkspace()
29
+ tree.write('prisma/schema.prisma', 'model User {}')
30
+
31
+ mockDependencies = {
32
+ formatFiles: vi.fn(),
33
+ generateFiles: vi.fn(),
34
+ installPackagesTask: vi.fn(),
35
+ joinPathFragments: vi.fn((...args: string[]) => args.join('/')),
36
+ names: vi.fn((name: string) => ({
37
+ name,
38
+ className: name.charAt(0).toUpperCase() + name.slice(1),
39
+ propertyName: name,
40
+ constantName: name.toUpperCase(),
41
+ fileName: name,
42
+ })),
43
+ getDMMF: vi.fn().mockResolvedValue(dmmf),
44
+ apiLibraryGenerator: vi.fn().mockResolvedValue(undefined),
45
+ getPrismaSchemaPath: vi.fn(() => 'prisma/schema.prisma'),
46
+ readPrismaSchema: vi.fn(
47
+ () => `
48
+ /// @crudAuth: { "create": "public", "readMany": "user" }
49
+ model User {
50
+ id Int @id
51
+ name String
52
+ }
53
+ `,
54
+ ),
55
+ getNpmScope: vi.fn(() => 'testscope'),
56
+ // FIX: Add the missing mock for 'pluralize'
57
+ pluralize: vi.fn((name: string) => (name.endsWith('s') ? name : name + 's')),
58
+ }
59
+ vi.clearAllMocks()
60
+ })
61
+
62
+ it('returns early if no Prisma models are found', async () => {
63
+ mockDependencies.getDMMF = vi.fn().mockResolvedValue({ datamodel: { models: [] } })
64
+
65
+ // The test now correctly calls the exported logic function
66
+ const result = await generateCrudLogic(tree, { name: 'crud' }, mockDependencies)
67
+
68
+ expect(result).toBeUndefined()
69
+ expect(mockDependencies.apiLibraryGenerator).not.toHaveBeenCalled()
70
+ expect(mockDependencies.generateFiles).not.toHaveBeenCalled()
71
+ })
72
+
73
+ it('generates files and calls utilities for valid models', async () => {
74
+ const callback = await generateCrudLogic(tree, { name: 'crud' }, mockDependencies)
75
+
76
+ expect(mockDependencies.apiLibraryGenerator).toHaveBeenCalled()
77
+ expect(mockDependencies.generateFiles).toHaveBeenCalled()
78
+ expect(mockDependencies.formatFiles).toHaveBeenCalled()
79
+
80
+ expect(typeof callback).toBe('function')
81
+ if (callback) callback()
82
+ expect(mockDependencies.installPackagesTask).toHaveBeenCalled()
83
+ })
84
+ })
@@ -0,0 +1,354 @@
1
+ import { formatFiles, generateFiles, installPackagesTask, joinPathFragments, names, Tree } from '@nx/devkit'
2
+ import { getDMMF } from '@prisma/internals'
3
+ import { apiLibraryGenerator, getPrismaSchemaPath, readPrismaSchema } from '@nestledjs/utils'
4
+ import { GenerateCrudGeneratorSchema } from './schema'
5
+ import { getNpmScope } from '@nx/js/src/utils/package-json/get-npm-scope'
6
+ import pluralize from 'pluralize'
7
+
8
+ // STEP 1: DEFINE INTERFACES FOR DATA AND DEPENDENCIES
9
+ interface CrudAuthConfig {
10
+ readOne?: string
11
+ readMany?: string
12
+ count?: string
13
+ create?: string
14
+ update?: string
15
+ delete?: string
16
+ }
17
+
18
+ interface ModelType {
19
+ name: string
20
+ pluralName: string
21
+ fields: ReadonlyArray<Record<string, unknown> & { name: string; type: string }>
22
+ primaryField: string
23
+ modelName: string
24
+ modelPropertyName: string
25
+ pluralModelName: string
26
+ pluralModelPropertyName: string
27
+ auth?: CrudAuthConfig
28
+ }
29
+
30
+ // This interface makes the generator logic testable by defining its external dependencies.
31
+ export interface GenerateCrudGeneratorDependencies {
32
+ formatFiles: typeof formatFiles
33
+ generateFiles: typeof generateFiles
34
+ installPackagesTask: typeof installPackagesTask
35
+ joinPathFragments: typeof joinPathFragments
36
+ names: typeof names
37
+ getDMMF: typeof getDMMF
38
+ apiLibraryGenerator: typeof apiLibraryGenerator
39
+ getPrismaSchemaPath: typeof getPrismaSchemaPath
40
+ readPrismaSchema: typeof readPrismaSchema
41
+ getNpmScope: typeof getNpmScope
42
+ pluralize: typeof pluralize
43
+ }
44
+
45
+ // STEP 2: DEFINE PURE HELPER & CONTENT GENERATION FUNCTIONS
46
+ // These functions are side-effect free and can be tested independently.
47
+
48
+ export function parseCrudAuth(comment: string): CrudAuthConfig | null {
49
+ try {
50
+ const match = comment.match(/@crudAuth:\s*(\{.*\})/)
51
+ if (!match) return null
52
+ return JSON.parse(match[1])
53
+ } catch (e) {
54
+ console.error('Error parsing @crudAuth:', e)
55
+ return null
56
+ }
57
+ }
58
+
59
+ export function getCrudAuthForModel(schema: string, modelName: string): CrudAuthConfig {
60
+ const defaultConfig: CrudAuthConfig = {
61
+ readOne: 'admin',
62
+ readMany: 'admin',
63
+ count: 'admin',
64
+ create: 'admin',
65
+ update: 'admin',
66
+ delete: 'admin',
67
+ }
68
+ const lines = schema.split('\n')
69
+ let modelDoc: string[] = []
70
+ let foundModel = false
71
+ for (const line of lines) {
72
+ const trimmedLine = line.trim()
73
+ if (
74
+ trimmedLine.startsWith(`model ${modelName}`) ||
75
+ trimmedLine.startsWith(`model ${modelName} `) ||
76
+ trimmedLine.startsWith(`model ${modelName}{`)
77
+ ) {
78
+ foundModel = true
79
+ break
80
+ } else if (trimmedLine.startsWith('model ')) {
81
+ modelDoc = []
82
+ } else if (trimmedLine.startsWith('///') && !foundModel) {
83
+ modelDoc.push(trimmedLine)
84
+ }
85
+ }
86
+ if (!foundModel) return defaultConfig
87
+ const authLine = modelDoc.find((line) => line.includes('@crudAuth:'))
88
+ if (!authLine) return defaultConfig
89
+ const config = parseCrudAuth(authLine)
90
+ return config ? { ...defaultConfig, ...config } : defaultConfig
91
+ }
92
+
93
+ export function getGuardForAuthLevel(level: string): string | null {
94
+ if (!level) return 'GqlAuthAdminGuard'
95
+ level = level.toLowerCase()
96
+ if (level === 'public') return null
97
+ if (level === 'user') return 'GqlAuthGuard'
98
+ if (level === 'admin') return 'GqlAuthAdminGuard'
99
+ const pascalCase = level.charAt(0).toUpperCase() + level.slice(1).toLowerCase()
100
+ return `GqlAuth${pascalCase}Guard`
101
+ }
102
+
103
+ function toKebabCase(str: string): string {
104
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
105
+ }
106
+
107
+ export function generateResolverContent(model: ModelType, npmScope: string): string {
108
+ const usedGuards = new Set<string>()
109
+ if (model.auth) {
110
+ Object.values(model.auth).forEach((level) => {
111
+ if (level === 'public') return
112
+ const guard = getGuardForAuthLevel(level)
113
+ if (guard) usedGuards.add(guard)
114
+ })
115
+ } else {
116
+ usedGuards.add('GqlAuthAdminGuard')
117
+ }
118
+
119
+ const guardImports =
120
+ usedGuards.size > 0 ? `import { ${Array.from(usedGuards).sort().join(', ')} } from '@${npmScope}/api/utils'` : ''
121
+
122
+ const readManyGuardDecorator = model.auth?.readMany ? getGuardForAuthLevel(model.auth.readMany) : 'GqlAuthAdminGuard'
123
+ const countGuardDecorator = model.auth?.count ? getGuardForAuthLevel(model.auth.count) : 'GqlAuthAdminGuard'
124
+ const readOneGuardDecorator = model.auth?.readOne ? getGuardForAuthLevel(model.auth.readOne) : 'GqlAuthAdminGuard'
125
+ const createGuardDecorator = model.auth?.create ? getGuardForAuthLevel(model.auth.create) : 'GqlAuthAdminGuard'
126
+ const updateGuardDecorator = model.auth?.update ? getGuardForAuthLevel(model.auth.update) : 'GqlAuthAdminGuard'
127
+ const deleteGuardDecorator = model.auth?.delete ? getGuardForAuthLevel(model.auth.delete) : 'GqlAuthAdminGuard'
128
+
129
+ const readManyMethodName = model.pluralModelPropertyName
130
+ const countMethodName = `${model.pluralModelPropertyName}Count`
131
+ const readOneMethodName = model.modelPropertyName
132
+
133
+ return `import { Args, Mutation, Query, Resolver, Info } from '@nestjs/graphql'
134
+ import { UseGuards } from '@nestjs/common'
135
+ import type { GraphQLResolveInfo } from 'graphql'
136
+ import { CorePaging } from '@${npmScope}/api/core/data-access'
137
+ import { ApiCrudDataAccessService } from '@${npmScope}/api/generated-crud/data-access'
138
+ import { ${model.modelName} } from '@${npmScope}/api/core/models'
139
+ import { Create${model.modelName}Input, List${model.modelName}Input, Update${
140
+ model.modelName
141
+ }Input } from '@${npmScope}/api/generated-crud/data-access'
142
+ ${guardImports}
143
+
144
+ @Resolver(() => ${model.modelName})
145
+ export class Generated${model.modelName}Resolver {
146
+ constructor(private readonly service: ApiCrudDataAccessService) {}
147
+
148
+ @Query(() => [${model.modelName}], { nullable: true })
149
+ ${readManyGuardDecorator ? `@UseGuards(${readManyGuardDecorator})` : ''}
150
+ ${readManyMethodName}(
151
+ @Info() info: GraphQLResolveInfo,
152
+ @Args({ name: 'input', type: () => List${model.modelName}Input, nullable: true }) input?: List${
153
+ model.modelName
154
+ }Input,
155
+ ) {
156
+ return this.service.${readManyMethodName}(info, input)
157
+ }
158
+
159
+ @Query(() => CorePaging, { nullable: true })
160
+ ${countGuardDecorator ? `@UseGuards(${countGuardDecorator})` : ''}
161
+ ${countMethodName}(
162
+ @Args({ name: 'input', type: () => List${model.modelName}Input, nullable: true }) input?: List${
163
+ model.modelName
164
+ }Input,
165
+ ) {
166
+ return this.service.${countMethodName}(input)
167
+ }
168
+
169
+ @Query(() => ${model.modelName}, { nullable: true })
170
+ ${readOneGuardDecorator ? `@UseGuards(${readOneGuardDecorator})` : ''}
171
+ ${readOneMethodName}(
172
+ @Info() info: GraphQLResolveInfo,
173
+ @Args('${model.modelPropertyName}Id') ${model.modelPropertyName}Id: string
174
+ ) {
175
+ return this.service.${readOneMethodName}(info, ${model.modelPropertyName}Id)
176
+ }
177
+
178
+ @Mutation(() => ${model.modelName}, { nullable: true })
179
+ ${createGuardDecorator ? `@UseGuards(${createGuardDecorator})` : ''}
180
+ create${model.modelName}(
181
+ @Info() info: GraphQLResolveInfo,
182
+ @Args('input') input: Create${model.modelName}Input,
183
+ ) {
184
+ return this.service.create${model.modelName}(info, input)
185
+ }
186
+
187
+ @Mutation(() => ${model.modelName}, { nullable: true })
188
+ ${updateGuardDecorator ? `@UseGuards(${updateGuardDecorator})` : ''}
189
+ update${model.modelName}(
190
+ @Info() info: GraphQLResolveInfo,
191
+ @Args('${model.modelPropertyName}Id') ${model.modelPropertyName}Id: string,
192
+ @Args('input') input: Update${model.modelName}Input,
193
+ ) {
194
+ return this.service.update${model.modelName}(info, ${model.modelPropertyName}Id, input)
195
+ }
196
+
197
+ @Mutation(() => ${model.modelName}, { nullable: true })
198
+ ${deleteGuardDecorator ? `@UseGuards(${deleteGuardDecorator})` : ''}
199
+ delete${model.modelName}(
200
+ @Args('${model.modelPropertyName}Id') ${model.modelPropertyName}Id: string,
201
+ ) {
202
+ return this.service.delete${model.modelName}(${model.modelPropertyName}Id)
203
+ }
204
+ }
205
+ `
206
+ }
207
+
208
+ export function generateFeatureModuleContent(models: ModelType[], npmScope: string): string {
209
+ return `import { Module } from '@nestjs/common'\nimport { ApiCrudDataAccessModule } from '@${npmScope}/api/generated-crud/data-access'\n${models
210
+ .map((model) => `import { Generated${model.modelName}Resolver } from './${toKebabCase(model.modelName)}.resolver'`)
211
+ .join('\n')}\n\n@Module({\n imports: [ApiCrudDataAccessModule],\n providers: [${models
212
+ .map((model) => `Generated${model.modelName}Resolver`)
213
+ .join(', ')}],\n})\nexport class ApiGeneratedCrudFeatureModule {}\n`
214
+ }
215
+
216
+ export function generateFeatureIndexContent(models: ModelType[]): string {
217
+ return `export * from './lib/api-admin-crud-feature.module'\n${models
218
+ .map((model) => `export * from './lib/${toKebabCase(model.modelName)}.resolver'`)
219
+ .join('\n')}\n`
220
+ }
221
+
222
+ // STEP 3: DEFINE THE CORE LOGIC FUNCTION
223
+ // This function contains all the generator's logic but uses injected dependencies, making it testable.
224
+ export async function generateCrudLogic(
225
+ tree: Tree,
226
+ schema: GenerateCrudGeneratorSchema,
227
+ dependencies: GenerateCrudGeneratorDependencies,
228
+ ) {
229
+ // Helper functions that now use injected dependencies
230
+ async function getAllPrismaModels(tree: Tree): Promise<ModelType[]> {
231
+ const prismaPath = dependencies.getPrismaSchemaPath(tree)
232
+ const prismaSchema = dependencies.readPrismaSchema(tree, prismaPath)
233
+ if (!prismaSchema) {
234
+ console.error(`No Prisma schema found at ${prismaPath}`)
235
+ return []
236
+ }
237
+ try {
238
+ const dmmf = await dependencies.getDMMF({ datamodel: prismaSchema })
239
+ return dmmf.datamodel.models.map((model) => {
240
+ const singularPropertyName = model.name.charAt(0).toLowerCase() + model.name.slice(1)
241
+ const pluralPropertyName = dependencies.pluralize(singularPropertyName)
242
+ const authConfig = getCrudAuthForModel(prismaSchema, model.name)
243
+ return {
244
+ name: model.name,
245
+ pluralName: dependencies.pluralize(model.name),
246
+ fields: model.fields.map((field) => ({ ...field })),
247
+ primaryField: model.fields.find((f) => !f.isId && f.type === 'String')?.name || 'name',
248
+ modelName: model.name,
249
+ modelPropertyName: singularPropertyName,
250
+ pluralModelName: dependencies.pluralize(model.name),
251
+ pluralModelPropertyName: pluralPropertyName,
252
+ auth: authConfig,
253
+ }
254
+ })
255
+ } catch (error) {
256
+ console.error('Error parsing Prisma schema:', error)
257
+ return []
258
+ }
259
+ }
260
+
261
+ async function createLibraries(tree: Tree, name: string) {
262
+ const dataAccessLibraryRoot = `libs/api/${name}/data-access`
263
+ const featureLibraryRoot = `libs/api/${name}/feature`
264
+ const dataAccessTemplatePath = dependencies.joinPathFragments(__dirname, './files/data-access')
265
+ const featureTemplatePath = dependencies.joinPathFragments(__dirname, './files/feature')
266
+ await dependencies.apiLibraryGenerator(tree, { name }, dataAccessTemplatePath, 'data-access')
267
+ await dependencies.apiLibraryGenerator(tree, { name }, featureTemplatePath, 'feature')
268
+ return { dataAccessLibraryRoot, featureLibraryRoot }
269
+ }
270
+
271
+ async function generateModelFiles(
272
+ tree: Tree,
273
+ dataAccessLibraryRoot: string,
274
+ featureLibraryRoot: string,
275
+ models: ModelType[],
276
+ name: string,
277
+ ) {
278
+ const npmScope = dependencies.getNpmScope(tree)
279
+ const nameObj = dependencies.names(name)
280
+ const substitutions = { ...nameObj, name, models, npmScope: `@${npmScope}`, apiClassName: 'PrismaCrud', tmpl: '' }
281
+
282
+ dependencies.generateFiles(
283
+ tree,
284
+ dependencies.joinPathFragments(__dirname, './files/data-access/src/lib'),
285
+ dependencies.joinPathFragments(dataAccessLibraryRoot, 'src/lib'),
286
+ { ...substitutions, type: 'data-access' },
287
+ )
288
+ dependencies.generateFiles(
289
+ tree,
290
+ dependencies.joinPathFragments(__dirname, './files/data-access/src'),
291
+ dependencies.joinPathFragments(dataAccessLibraryRoot, 'src'),
292
+ { ...substitutions, type: 'data-access' },
293
+ )
294
+
295
+ const featureModuleContent = generateFeatureModuleContent(models, npmScope)
296
+ tree.write(
297
+ dependencies.joinPathFragments(featureLibraryRoot, 'src/lib/api-admin-crud-feature.module.ts'),
298
+ featureModuleContent,
299
+ )
300
+
301
+ const featureIndexContent = generateFeatureIndexContent(models)
302
+ tree.write(dependencies.joinPathFragments(featureLibraryRoot, 'src/index.ts'), featureIndexContent)
303
+
304
+ for (const model of models) {
305
+ const resolverFilePath = dependencies.joinPathFragments(
306
+ featureLibraryRoot,
307
+ `src/lib/${toKebabCase(model.modelName)}.resolver.ts`,
308
+ )
309
+ const resolverContent = generateResolverContent(model, npmScope)
310
+ tree.write(resolverFilePath, resolverContent)
311
+ }
312
+ }
313
+
314
+ // Main Orchestration Logic
315
+ const name = schema.name || 'generated-crud'
316
+ const models = await getAllPrismaModels(tree)
317
+ if (models.length === 0) {
318
+ console.error('No Prisma models found')
319
+ return // Return early for the test case
320
+ }
321
+
322
+ const { dataAccessLibraryRoot, featureLibraryRoot } = await createLibraries(tree, name)
323
+ await generateModelFiles(tree, dataAccessLibraryRoot, featureLibraryRoot, models, name)
324
+ await dependencies.formatFiles(tree)
325
+
326
+ return () => {
327
+ dependencies.installPackagesTask(tree)
328
+ }
329
+ }
330
+
331
+ // STEP 4: DEFINE THE DEFAULT EXPORT
332
+ // This is what Nx CLI executes. It's a simple wrapper that provides the *real* dependencies to the logic function.
333
+ export default async function (tree: Tree, schema: GenerateCrudGeneratorSchema) {
334
+ const dependencies: GenerateCrudGeneratorDependencies = {
335
+ formatFiles,
336
+ generateFiles,
337
+ installPackagesTask,
338
+ joinPathFragments,
339
+ names,
340
+ getDMMF,
341
+ apiLibraryGenerator,
342
+ getPrismaSchemaPath,
343
+ readPrismaSchema,
344
+ getNpmScope,
345
+ pluralize,
346
+ }
347
+
348
+ try {
349
+ return await generateCrudLogic(tree, schema, dependencies)
350
+ } catch (error) {
351
+ console.error('Error in CRUD generator:', error)
352
+ throw error
353
+ }
354
+ }