@memberjunction/query-gen 0.0.1 → 2.126.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 (138) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +34 -0
  3. package/COORDINATOR.md +768 -0
  4. package/IMPLEMENTATION_PLAN.md +1753 -0
  5. package/LLM_ENTITY_GROUPING_PLAN.md +977 -0
  6. package/README.md +675 -29
  7. package/dist/cli/commands/export.d.ts +15 -0
  8. package/dist/cli/commands/export.d.ts.map +1 -0
  9. package/dist/cli/commands/export.js +178 -0
  10. package/dist/cli/commands/export.js.map +1 -0
  11. package/dist/cli/commands/generate.d.ts +19 -0
  12. package/dist/cli/commands/generate.d.ts.map +1 -0
  13. package/dist/cli/commands/generate.js +282 -0
  14. package/dist/cli/commands/generate.js.map +1 -0
  15. package/dist/cli/commands/validate.d.ts +17 -0
  16. package/dist/cli/commands/validate.d.ts.map +1 -0
  17. package/dist/cli/commands/validate.js +193 -0
  18. package/dist/cli/commands/validate.js.map +1 -0
  19. package/dist/cli/config.d.ts +51 -0
  20. package/dist/cli/config.d.ts.map +1 -0
  21. package/dist/cli/config.js +142 -0
  22. package/dist/cli/config.js.map +1 -0
  23. package/dist/cli/index.d.ts +13 -0
  24. package/dist/cli/index.d.ts.map +1 -0
  25. package/dist/cli/index.js +57 -0
  26. package/dist/cli/index.js.map +1 -0
  27. package/dist/core/EntityGrouper.d.ts +74 -0
  28. package/dist/core/EntityGrouper.d.ts.map +1 -0
  29. package/dist/core/EntityGrouper.js +246 -0
  30. package/dist/core/EntityGrouper.js.map +1 -0
  31. package/dist/core/MetadataExporter.d.ts +59 -0
  32. package/dist/core/MetadataExporter.d.ts.map +1 -0
  33. package/dist/core/MetadataExporter.js +151 -0
  34. package/dist/core/MetadataExporter.js.map +1 -0
  35. package/dist/core/QueryDatabaseWriter.d.ts +50 -0
  36. package/dist/core/QueryDatabaseWriter.d.ts.map +1 -0
  37. package/dist/core/QueryDatabaseWriter.js +152 -0
  38. package/dist/core/QueryDatabaseWriter.js.map +1 -0
  39. package/dist/core/QueryFixer.d.ts +48 -0
  40. package/dist/core/QueryFixer.d.ts.map +1 -0
  41. package/dist/core/QueryFixer.js +115 -0
  42. package/dist/core/QueryFixer.js.map +1 -0
  43. package/dist/core/QueryRefiner.d.ts +94 -0
  44. package/dist/core/QueryRefiner.d.ts.map +1 -0
  45. package/dist/core/QueryRefiner.js +267 -0
  46. package/dist/core/QueryRefiner.js.map +1 -0
  47. package/dist/core/QueryTester.d.ts +70 -0
  48. package/dist/core/QueryTester.d.ts.map +1 -0
  49. package/dist/core/QueryTester.js +243 -0
  50. package/dist/core/QueryTester.js.map +1 -0
  51. package/dist/core/QueryWriter.d.ts +57 -0
  52. package/dist/core/QueryWriter.d.ts.map +1 -0
  53. package/dist/core/QueryWriter.js +184 -0
  54. package/dist/core/QueryWriter.js.map +1 -0
  55. package/dist/core/QuestionGenerator.d.ts +58 -0
  56. package/dist/core/QuestionGenerator.d.ts.map +1 -0
  57. package/dist/core/QuestionGenerator.js +145 -0
  58. package/dist/core/QuestionGenerator.js.map +1 -0
  59. package/dist/data/schema.d.ts +230 -0
  60. package/dist/data/schema.d.ts.map +1 -0
  61. package/dist/data/schema.js +6 -0
  62. package/dist/data/schema.js.map +1 -0
  63. package/dist/index.d.ts +28 -0
  64. package/dist/index.d.ts.map +1 -0
  65. package/dist/index.js +77 -0
  66. package/dist/index.js.map +1 -0
  67. package/dist/prompts/PromptNames.d.ts +32 -0
  68. package/dist/prompts/PromptNames.d.ts.map +1 -0
  69. package/dist/prompts/PromptNames.js +35 -0
  70. package/dist/prompts/PromptNames.js.map +1 -0
  71. package/dist/utils/category-builder.d.ts +28 -0
  72. package/dist/utils/category-builder.d.ts.map +1 -0
  73. package/dist/utils/category-builder.js +90 -0
  74. package/dist/utils/category-builder.js.map +1 -0
  75. package/dist/utils/entity-helpers.d.ts +49 -0
  76. package/dist/utils/entity-helpers.d.ts.map +1 -0
  77. package/dist/utils/entity-helpers.js +189 -0
  78. package/dist/utils/entity-helpers.js.map +1 -0
  79. package/dist/utils/error-handlers.d.ts +19 -0
  80. package/dist/utils/error-handlers.d.ts.map +1 -0
  81. package/dist/utils/error-handlers.js +41 -0
  82. package/dist/utils/error-handlers.js.map +1 -0
  83. package/dist/utils/graph-helpers.d.ts +51 -0
  84. package/dist/utils/graph-helpers.d.ts.map +1 -0
  85. package/dist/utils/graph-helpers.js +82 -0
  86. package/dist/utils/graph-helpers.js.map +1 -0
  87. package/dist/utils/prompt-helpers.d.ts +25 -0
  88. package/dist/utils/prompt-helpers.d.ts.map +1 -0
  89. package/dist/utils/prompt-helpers.js +66 -0
  90. package/dist/utils/prompt-helpers.js.map +1 -0
  91. package/dist/utils/query-helpers.d.ts +23 -0
  92. package/dist/utils/query-helpers.d.ts.map +1 -0
  93. package/dist/utils/query-helpers.js +34 -0
  94. package/dist/utils/query-helpers.js.map +1 -0
  95. package/dist/utils/user-helpers.d.ts +15 -0
  96. package/dist/utils/user-helpers.d.ts.map +1 -0
  97. package/dist/utils/user-helpers.js +32 -0
  98. package/dist/utils/user-helpers.js.map +1 -0
  99. package/dist/vectors/EmbeddingService.d.ts +58 -0
  100. package/dist/vectors/EmbeddingService.d.ts.map +1 -0
  101. package/dist/vectors/EmbeddingService.js +90 -0
  102. package/dist/vectors/EmbeddingService.js.map +1 -0
  103. package/dist/vectors/SimilaritySearch.d.ts +51 -0
  104. package/dist/vectors/SimilaritySearch.d.ts.map +1 -0
  105. package/dist/vectors/SimilaritySearch.js +85 -0
  106. package/dist/vectors/SimilaritySearch.js.map +1 -0
  107. package/docs/API.md +1040 -0
  108. package/docs/ARCHITECTURE.md +1120 -0
  109. package/examples/advanced-usage.ts +401 -0
  110. package/examples/basic-usage.ts +285 -0
  111. package/package.json +48 -6
  112. package/src/cli/commands/export.ts +173 -0
  113. package/src/cli/commands/generate.ts +330 -0
  114. package/src/cli/commands/validate.ts +185 -0
  115. package/src/cli/config.ts +203 -0
  116. package/src/cli/index.ts +63 -0
  117. package/src/core/EntityGrouper.ts +318 -0
  118. package/src/core/MetadataExporter.ts +148 -0
  119. package/src/core/QueryDatabaseWriter.ts +187 -0
  120. package/src/core/QueryFixer.ts +153 -0
  121. package/src/core/QueryRefiner.ts +382 -0
  122. package/src/core/QueryTester.ts +264 -0
  123. package/src/core/QueryWriter.ts +239 -0
  124. package/src/core/QuestionGenerator.ts +199 -0
  125. package/src/data/golden-queries.json +1371 -0
  126. package/src/data/schema.ts +252 -0
  127. package/src/index.ts +49 -0
  128. package/src/prompts/PromptNames.ts +36 -0
  129. package/src/utils/category-builder.ts +97 -0
  130. package/src/utils/entity-helpers.ts +203 -0
  131. package/src/utils/error-handlers.ts +41 -0
  132. package/src/utils/graph-helpers.ts +99 -0
  133. package/src/utils/prompt-helpers.ts +79 -0
  134. package/src/utils/query-helpers.ts +32 -0
  135. package/src/utils/user-helpers.ts +39 -0
  136. package/src/vectors/EmbeddingService.ts +109 -0
  137. package/src/vectors/SimilaritySearch.ts +108 -0
  138. package/tsconfig.json +39 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * LLM-based Entity Grouper
3
+ *
4
+ * Uses AI to generate semantically meaningful entity groupings based on
5
+ * business context and schema understanding, replacing the deterministic
6
+ * hub-and-spoke algorithm with intelligent semantic analysis.
7
+ */
8
+
9
+ import { EntityInfo, EntityRelationshipInfo, UserInfo, LogStatus } from '@memberjunction/core';
10
+ import { AIEngine } from '@memberjunction/aiengine';
11
+ import { EntityGroup } from '../data/schema';
12
+ import { QueryGenConfig } from '../cli/config';
13
+ import { generateRelationshipGraph, formatEntitiesForPrompt } from '../utils/graph-helpers';
14
+ import { extractErrorMessage } from '../utils/error-handlers';
15
+ import { executePromptWithOverrides } from '../utils/prompt-helpers';
16
+
17
+ /**
18
+ * LLM response format from Entity Group Generator prompt
19
+ */
20
+ interface LLMEntityGroupResponse {
21
+ groups: Array<{
22
+ entities: string[];
23
+ primaryEntity: string;
24
+ businessDomain: string;
25
+ businessRationale: string;
26
+ relationshipType: 'single' | 'parent-child' | 'many-to-many';
27
+ expectedQuestionTypes: string[];
28
+ }>;
29
+ }
30
+
31
+ /**
32
+ * Generates entity groups using LLM-based semantic analysis
33
+ *
34
+ * This class replaces the deterministic hub-and-spoke algorithm with an
35
+ * intelligent approach that understands business context and generates
36
+ * meaningful entity combinations for query generation.
37
+ */
38
+ export class EntityGrouper {
39
+ private readonly promptName = 'Entity Group Generator';
40
+ private readonly config: QueryGenConfig;
41
+
42
+ constructor(config: QueryGenConfig) {
43
+ this.config = config;
44
+ }
45
+
46
+ /**
47
+ * Generate semantically meaningful entity groups using LLM analysis
48
+ *
49
+ * Note: The LLM determines the optimal number and size of entity groups based on
50
+ * business domain understanding. Makes separate LLM calls for each schema to
51
+ * avoid mixing entities from different databases.
52
+ *
53
+ * @param entities - All entities to analyze
54
+ * @param contextUser - User context for server-side operations
55
+ * @returns Array of validated entity groups with business context
56
+ */
57
+ async generateEntityGroups(
58
+ entities: EntityInfo[],
59
+ contextUser: UserInfo
60
+ ): Promise<EntityGroup[]> {
61
+ try {
62
+ // 1. Group entities by schema
63
+ const entitiesBySchema = this.groupEntitiesBySchema(entities);
64
+
65
+ // 2. Process each schema separately
66
+ const allGroups: EntityGroup[] = [];
67
+ let schemaIndex = 0;
68
+ const totalSchemas = entitiesBySchema.size;
69
+
70
+ for (const [schemaName, schemaEntities] of entitiesBySchema.entries()) {
71
+ schemaIndex++;
72
+
73
+ if (this.config.verbose && totalSchemas > 1) {
74
+ LogStatus(`[${schemaIndex}/${totalSchemas}] Processing schema: ${schemaName} (${schemaEntities.length} entities)`);
75
+ }
76
+
77
+ // 2a. Prepare schema data for LLM
78
+ const schemaData = this.prepareSchemaData(schemaEntities, schemaName);
79
+
80
+ // 2b. Call LLM to generate groups for this schema
81
+ const llmResponse = await this.callLLMForGrouping(schemaData, contextUser);
82
+
83
+ // 2c. Validate and convert to EntityGroup objects
84
+ const validatedGroups = this.validateAndConvertGroups(llmResponse, schemaEntities);
85
+
86
+ if (this.config.verbose && totalSchemas > 1) {
87
+ LogStatus(`[${schemaIndex}/${totalSchemas}] Generated ${validatedGroups.length} groups for schema: ${schemaName}`);
88
+ }
89
+
90
+ allGroups.push(...validatedGroups);
91
+ }
92
+
93
+ // 3. Deduplicate any similar groups across all schemas
94
+ const deduplicatedGroups = this.deduplicateGroups(allGroups);
95
+
96
+ return deduplicatedGroups;
97
+ } catch (error: unknown) {
98
+ throw new Error(extractErrorMessage(error, 'EntityGrouper.generateEntityGroups'));
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Group entities by schema name
104
+ */
105
+ private groupEntitiesBySchema(entities: EntityInfo[]): Map<string, EntityInfo[]> {
106
+ const schemaMap = new Map<string, EntityInfo[]>();
107
+
108
+ for (const entity of entities) {
109
+ const schemaName = entity.SchemaName || 'Unknown';
110
+ const schemaEntities = schemaMap.get(schemaName) || [];
111
+ schemaEntities.push(entity);
112
+ schemaMap.set(schemaName, schemaEntities);
113
+ }
114
+
115
+ return schemaMap;
116
+ }
117
+
118
+ /**
119
+ * Prepare schema data for LLM prompt
120
+ *
121
+ * Note: The LLM will determine appropriate group count based on business
122
+ * domain understanding and schema complexity. We provide the full entity
123
+ * schema and relationship graph for intelligent analysis.
124
+ *
125
+ * @param entities - Entities within a single schema
126
+ * @param schemaName - Name of the schema being processed
127
+ */
128
+ private prepareSchemaData(entities: EntityInfo[], schemaName: string): Record<string, unknown> {
129
+ const formattedEntities = formatEntitiesForPrompt(entities);
130
+ const relationshipGraph = generateRelationshipGraph(entities);
131
+
132
+ return {
133
+ schemaName,
134
+ entities: formattedEntities,
135
+ relationshipGraph,
136
+ minGroupSize: this.config.minGroupSize,
137
+ maxGroupSize: this.config.maxGroupSize
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Call LLM via AIPromptRunner to generate entity groups
143
+ */
144
+ private async callLLMForGrouping(
145
+ schemaData: Record<string, unknown>,
146
+ contextUser: UserInfo
147
+ ): Promise<LLMEntityGroupResponse> {
148
+ // Get prompt entity from AIEngine
149
+ const prompt = AIEngine.Instance.Prompts.find(p => p.Name === this.promptName);
150
+ if (!prompt) {
151
+ throw new Error(`Prompt "${this.promptName}" not found. Ensure metadata has been synced to database.`);
152
+ }
153
+
154
+ // Execute with model/vendor overrides if specified in config
155
+ const result = await executePromptWithOverrides<LLMEntityGroupResponse>(
156
+ prompt,
157
+ schemaData,
158
+ contextUser,
159
+ this.config
160
+ );
161
+
162
+ if (!result.success) {
163
+ throw new Error(`LLM grouping failed: ${result.errorMessage || 'Unknown error'}`);
164
+ }
165
+
166
+ if (!result.result) {
167
+ throw new Error('LLM did not return structured data');
168
+ }
169
+
170
+ return result.result;
171
+ }
172
+
173
+ /**
174
+ * Validate LLM output and convert to EntityGroup objects
175
+ */
176
+ private validateAndConvertGroups(
177
+ llmResponse: LLMEntityGroupResponse,
178
+ entities: EntityInfo[]
179
+ ): EntityGroup[] {
180
+ const entityMap = new Map(entities.map(e => [e.Name, e]));
181
+ const validGroups: EntityGroup[] = [];
182
+
183
+ for (const llmGroup of llmResponse.groups) {
184
+ try {
185
+ // Validate all entity names exist
186
+ const groupEntities = llmGroup.entities
187
+ .map(name => entityMap.get(name))
188
+ .filter((e): e is EntityInfo => e !== undefined);
189
+
190
+ if (groupEntities.length !== llmGroup.entities.length) {
191
+ // Skip logging - verbose debug info that clutters output
192
+ continue;
193
+ }
194
+
195
+ // Validate primary entity exists
196
+ const primaryEntity = entityMap.get(llmGroup.primaryEntity);
197
+ if (!primaryEntity) {
198
+ // Skip logging - verbose debug info that clutters output
199
+ continue;
200
+ }
201
+
202
+ // Build relationships array (collect all relationships between entities in the group)
203
+ const relationships = this.extractRelationships(groupEntities);
204
+
205
+ // Validate connectivity (all entities must be reachable from primary)
206
+ if (groupEntities.length > 1 && !this.isConnected(groupEntities, relationships)) {
207
+ // Skip logging - verbose debug info that clutters output
208
+ continue;
209
+ }
210
+
211
+ // Create EntityGroup with LLM metadata
212
+ validGroups.push({
213
+ entities: groupEntities,
214
+ relationships,
215
+ primaryEntity,
216
+ relationshipType: llmGroup.relationshipType,
217
+ businessDomain: llmGroup.businessDomain,
218
+ businessRationale: llmGroup.businessRationale,
219
+ expectedQuestionTypes: llmGroup.expectedQuestionTypes
220
+ });
221
+ } catch (error: unknown) {
222
+ // Skip logging - verbose debug info that clutters output
223
+ }
224
+ }
225
+
226
+ if (validGroups.length === 0) {
227
+ throw new Error('No valid entity groups generated by LLM');
228
+ }
229
+
230
+ return validGroups;
231
+ }
232
+
233
+ /**
234
+ * Extract relationships between entities in a group
235
+ */
236
+ private extractRelationships(entities: EntityInfo[]): EntityRelationshipInfo[] {
237
+ const entityNames = new Set(entities.map(e => e.Name));
238
+ const relationships: EntityRelationshipInfo[] = [];
239
+
240
+ for (const entity of entities) {
241
+ for (const rel of entity.RelatedEntities) {
242
+ if (entityNames.has(rel.RelatedEntity)) {
243
+ relationships.push(rel);
244
+ }
245
+ }
246
+ }
247
+
248
+ return relationships;
249
+ }
250
+
251
+ /**
252
+ * Check if all entities in a group are connected by relationships
253
+ *
254
+ * Uses BFS to verify all entities are reachable from the first entity
255
+ */
256
+ private isConnected(entities: EntityInfo[], relationships: EntityRelationshipInfo[]): boolean {
257
+ if (entities.length <= 1) return true;
258
+
259
+ // Build adjacency map
260
+ const adjacency = new Map<string, Set<string>>();
261
+ for (const entity of entities) {
262
+ adjacency.set(entity.Name, new Set());
263
+ }
264
+
265
+ for (const rel of relationships) {
266
+ const entityName = entities.find(e =>
267
+ e.RelatedEntities.includes(rel)
268
+ )?.Name;
269
+
270
+ if (entityName) {
271
+ adjacency.get(entityName)?.add(rel.RelatedEntity);
272
+ adjacency.get(rel.RelatedEntity)?.add(entityName); // Bidirectional
273
+ }
274
+ }
275
+
276
+ // BFS from first entity
277
+ const visited = new Set<string>();
278
+ const queue = [entities[0].Name];
279
+ visited.add(entities[0].Name);
280
+
281
+ while (queue.length > 0) {
282
+ const current = queue.shift()!;
283
+ const neighbors = adjacency.get(current) || new Set();
284
+
285
+ for (const neighbor of neighbors) {
286
+ if (!visited.has(neighbor)) {
287
+ visited.add(neighbor);
288
+ queue.push(neighbor);
289
+ }
290
+ }
291
+ }
292
+
293
+ // All entities should be visited
294
+ return visited.size === entities.length;
295
+ }
296
+
297
+ /**
298
+ * Remove duplicate or highly similar groups
299
+ *
300
+ * Groups are considered duplicates if they contain the exact same set of entities
301
+ */
302
+ private deduplicateGroups(groups: EntityGroup[]): EntityGroup[] {
303
+ const seen = new Set<string>();
304
+ const unique: EntityGroup[] = [];
305
+
306
+ for (const group of groups) {
307
+ // Create normalized key (sorted entity names)
308
+ const key = group.entities.map(e => e.Name).sort().join('|');
309
+
310
+ if (!seen.has(key)) {
311
+ seen.add(key);
312
+ unique.push(group);
313
+ }
314
+ }
315
+
316
+ return unique;
317
+ }
318
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * MetadataExporter - Exports validated queries to MJ metadata format
3
+ *
4
+ * Transforms validated queries into MemberJunction metadata JSON files
5
+ * that can be synced to the database using mj-sync.
6
+ */
7
+
8
+ import { promises as fs } from 'fs';
9
+ import * as path from 'path';
10
+ import { ValidatedQuery, ExportResult, QueryMetadataRecord, QueryCategoryInfo } from '../data/schema';
11
+ import { generateQueryName } from '../utils/query-helpers';
12
+
13
+ /**
14
+ * MetadataExporter class
15
+ * Exports validated queries to MJ metadata JSON format
16
+ */
17
+ export class MetadataExporter {
18
+ /**
19
+ * Export queries to metadata JSON files
20
+ *
21
+ * Transforms validated queries into MemberJunction metadata format
22
+ * and writes them to timestamped JSON files:
23
+ * - .query-categories-{timestamp}.json for the categories
24
+ * - .queries-{timestamp}.json for the queries
25
+ *
26
+ * @param validatedQueries - Array of validated queries to export
27
+ * @param uniqueCategories - Pre-built unique categories for all queries
28
+ * @param outputDirectory - Directory to write the queries file
29
+ * @param outputCategoryDirectory - Optional directory for categories file (defaults to outputDirectory)
30
+ * @returns Export result with file path and query count
31
+ */
32
+ async exportQueries(
33
+ validatedQueries: ValidatedQuery[],
34
+ uniqueCategories: QueryCategoryInfo[],
35
+ outputDirectory: string,
36
+ outputCategoryDirectory?: string
37
+ ): Promise<ExportResult> {
38
+ // Generate shared timestamp for both files
39
+ const timestamp = Date.now();
40
+
41
+ // Use category directory if provided, otherwise use queries directory
42
+ const categoryDir = outputCategoryDirectory || outputDirectory;
43
+
44
+ // 1. Transform to MJ metadata format
45
+ const queryMetadata = validatedQueries.map(q => this.toQueryMetadata(q));
46
+ const categoryMetadata = uniqueCategories.map(c => this.toCategoryMetadata(c));
47
+
48
+ // 2. Ensure output directories exist
49
+ await fs.mkdir(outputDirectory, { recursive: true });
50
+ if (categoryDir !== outputDirectory) {
51
+ await fs.mkdir(categoryDir, { recursive: true });
52
+ }
53
+
54
+ // 3. Write categories file (if categories exist)
55
+ if (categoryMetadata.length > 0) {
56
+ const categoriesPath = path.join(categoryDir, `.query-categories-${timestamp}.json`);
57
+ await fs.writeFile(
58
+ categoriesPath,
59
+ JSON.stringify(categoryMetadata, null, 2),
60
+ 'utf-8'
61
+ );
62
+ }
63
+
64
+ // 4. Write queries file
65
+ const outputPath = path.join(outputDirectory, `.queries-${timestamp}.json`);
66
+ await fs.writeFile(
67
+ outputPath,
68
+ JSON.stringify(queryMetadata, null, 2),
69
+ 'utf-8'
70
+ );
71
+
72
+ return {
73
+ success: true,
74
+ outputPath,
75
+ queryCount: queryMetadata.length
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Transform a validated query into MJ metadata format
81
+ *
82
+ * Note: This method only creates the Query record.
83
+ * QueryFields and QueryParameters are automatically extracted
84
+ * by QueryEntity.server.ts using AI analysis of the SQL template.
85
+ * This happens asynchronously during the Save() operation.
86
+ *
87
+ * @param query - Validated query to transform
88
+ * @returns Query metadata record
89
+ */
90
+ private toQueryMetadata(query: ValidatedQuery): QueryMetadataRecord {
91
+ // Build category lookup path (e.g., "Golden-Queries/Members" becomes lookup filter)
92
+ const categoryLookup = this.buildCategoryLookup(query.category);
93
+
94
+ return {
95
+ fields: {
96
+ Name: generateQueryName(query.businessQuestion),
97
+ CategoryID: categoryLookup,
98
+ UserQuestion: query.businessQuestion.userQuestion,
99
+ Description: query.businessQuestion.description,
100
+ TechnicalDescription: query.businessQuestion.technicalDescription,
101
+ SQL: query.query.sql,
102
+ OriginalSQL: query.query.sql,
103
+ UsesTemplate: true,
104
+ Status: 'Pending'
105
+ }
106
+ // relatedEntities removed - QueryEntity.server.ts handles extraction automatically
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Transform a QueryCategoryInfo into MJ metadata format
112
+ *
113
+ * @param category - Category information
114
+ * @returns Category metadata record
115
+ */
116
+ private toCategoryMetadata(category: QueryCategoryInfo) {
117
+ return {
118
+ fields: {
119
+ Name: category.name,
120
+ ParentID: category.parentName
121
+ ? `@lookup:Query Categories.Name=${category.parentName}`
122
+ : null,
123
+ Description: category.description,
124
+ UserID: '@lookup:Users.Name=System'
125
+ }
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Build category lookup string for metadata
131
+ * Uses multi-field lookup to uniquely identify categories in hierarchies
132
+ *
133
+ * For root categories: Name=X&ParentID=null
134
+ * For child categories: Name=X&Parent=Y (where Parent is the parent category name)
135
+ *
136
+ * @param category - Category information
137
+ * @returns Lookup string for CategoryID field
138
+ */
139
+ private buildCategoryLookup(category: QueryCategoryInfo): string {
140
+ if (category.parentName) {
141
+ // Child category - use Name and Parent field (view field showing parent name)
142
+ return `@lookup:Query Categories.Name=${category.name}&Parent=${category.parentName}`;
143
+ } else {
144
+ // Root category - use Name and ParentID=null
145
+ return `@lookup:Query Categories.Name=${category.name}&ParentID=null`;
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * QueryDatabaseWriter - Write validated queries directly to the database
3
+ *
4
+ * Creates Query entities in the database. QueryFields and QueryParameters
5
+ * are automatically extracted by QueryEntity.server.ts using AI analysis
6
+ * of the SQL template during the Save() operation.
7
+ */
8
+
9
+ import { Metadata, RunView, UserInfo } from '@memberjunction/core';
10
+ import {
11
+ QueryEntity,
12
+ QueryCategoryEntity
13
+ } from '@memberjunction/core-entities';
14
+ import { ValidatedQuery, WriteResult } from '../data/schema';
15
+ import { extractErrorMessage } from '../utils/error-handlers';
16
+ import { generateQueryName } from '../utils/query-helpers';
17
+
18
+ /**
19
+ * QueryDatabaseWriter class
20
+ * Writes validated queries directly to the database
21
+ */
22
+ export class QueryDatabaseWriter {
23
+ private categoryCache: Map<string, string> = new Map();
24
+
25
+ constructor() {
26
+ // No config needed - category info comes from ValidatedQuery
27
+ }
28
+ /**
29
+ * Write validated queries to the database
30
+ *
31
+ * Creates Query entities. QueryFields and QueryParameters are automatically
32
+ * extracted by QueryEntity.server.ts using AI analysis of the SQL template.
33
+ * This happens asynchronously during the Save() operation.
34
+ *
35
+ * Errors for individual queries are logged but don't stop the batch process.
36
+ *
37
+ * @param validatedQueries - Array of validated queries to write
38
+ * @param contextUser - User context for entity operations
39
+ * @returns Write result with success status and per-query results
40
+ */
41
+ async writeQueriesToDatabase(
42
+ validatedQueries: ValidatedQuery[],
43
+ contextUser: UserInfo
44
+ ): Promise<WriteResult> {
45
+ const md = new Metadata();
46
+ const results: string[] = [];
47
+
48
+ // Process each query
49
+ for (const vq of validatedQueries) {
50
+ try {
51
+ // Get or create the category for this query (with caching)
52
+ const categoryId = await this.getCategoryId(vq.category, contextUser);
53
+
54
+ // Create Query entity ONLY (NO manual fields/params creation)
55
+ // QueryEntity.server.ts will automatically:
56
+ // - Detect Nunjucks syntax
57
+ // - Extract parameters using AI
58
+ // - Create QueryParameter records
59
+ // - Create QueryField records
60
+ // - Set UsesTemplate flag
61
+ const query = await md.GetEntityObject<QueryEntity>('Queries', contextUser);
62
+ query.NewRecord();
63
+ query.Name = generateQueryName(vq.businessQuestion);
64
+ query.CategoryID = categoryId;
65
+ query.UserQuestion = vq.businessQuestion.userQuestion;
66
+ query.Description = vq.businessQuestion.description;
67
+ query.TechnicalDescription = vq.businessQuestion.technicalDescription;
68
+ query.SQL = vq.query.sql;
69
+ query.OriginalSQL = vq.query.sql;
70
+ query.Status = 'Pending';
71
+
72
+ const saved = await query.Save();
73
+ if (!saved) {
74
+ throw new Error(`Failed to save query: ${query.LatestResult?.Message}`);
75
+ }
76
+
77
+ results.push(`✓ ${query.Name} (ID: ${query.ID}) - AI extraction queued`);
78
+
79
+ } catch (error: unknown) {
80
+ results.push(`✗ ${vq.businessQuestion.userQuestion}: ${extractErrorMessage(error, 'Database Write')}`);
81
+ }
82
+ }
83
+
84
+ return {
85
+ success: true,
86
+ results
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Get or create category ID from QueryCategoryInfo
92
+ * Uses caching to avoid repeated lookups/creations
93
+ * Ensures parent categories exist before creating children
94
+ */
95
+ private async getCategoryId(
96
+ category: { name: string; parentName: string | null; description: string; path: string },
97
+ contextUser: UserInfo
98
+ ): Promise<string> {
99
+ // Check cache first (by path for uniqueness)
100
+ if (this.categoryCache.has(category.path)) {
101
+ return this.categoryCache.get(category.path)!;
102
+ }
103
+
104
+ // If this category has a parent, ensure parent exists first
105
+ let parentId: string | null = null;
106
+ if (category.parentName) {
107
+ // Create parent category info (it's a root category)
108
+ const parentCategory = {
109
+ name: category.parentName,
110
+ parentName: null,
111
+ description: 'Automatically generated queries from query-gen tool',
112
+ path: category.parentName
113
+ };
114
+ parentId = await this.getCategoryId(parentCategory, contextUser);
115
+ }
116
+
117
+ // Find or create this category
118
+ const categoryId = await this.findOrCreateCategory(
119
+ category.name,
120
+ parentId,
121
+ category.description,
122
+ contextUser
123
+ );
124
+
125
+ // Cache for future use
126
+ this.categoryCache.set(category.path, categoryId);
127
+ return categoryId;
128
+ }
129
+
130
+ /**
131
+ * Find or create a query category
132
+ *
133
+ * Searches for an existing category with the given name and parent, or creates it if not found.
134
+ *
135
+ * @param categoryName - Name of the category to find or create
136
+ * @param parentCategoryId - Parent category ID (null for root categories)
137
+ * @param description - Description for new categories
138
+ * @param contextUser - User context for entity operations
139
+ * @returns Category ID
140
+ */
141
+ private async findOrCreateCategory(
142
+ categoryName: string,
143
+ parentCategoryId: string | null,
144
+ description: string,
145
+ contextUser: UserInfo
146
+ ): Promise<string> {
147
+ const rv = new RunView();
148
+
149
+ // Build filter to match both name and parent
150
+ let filter = `Name='${categoryName.replace(/'/g, "''")}'`;
151
+ if (parentCategoryId) {
152
+ filter += ` AND ParentID='${parentCategoryId}'`;
153
+ } else {
154
+ filter += ` AND ParentID IS NULL`;
155
+ }
156
+
157
+ const result = await rv.RunView<QueryCategoryEntity>({
158
+ EntityName: 'Query Categories',
159
+ ExtraFilter: filter,
160
+ ResultType: 'entity_object'
161
+ }, contextUser);
162
+
163
+ if (!result.Success) {
164
+ throw new Error(`Failed to search for category: ${result.ErrorMessage}`);
165
+ }
166
+
167
+ // If found, return existing category ID
168
+ if (result.Results && result.Results.length > 0) {
169
+ return result.Results[0].ID;
170
+ }
171
+
172
+ // Category doesn't exist, create it
173
+ const md = new Metadata();
174
+ const category = await md.GetEntityObject<QueryCategoryEntity>('Query Categories', contextUser);
175
+ category.NewRecord();
176
+ category.Name = categoryName;
177
+ category.ParentID = parentCategoryId;
178
+ category.Description = description;
179
+
180
+ const saved = await category.Save();
181
+ if (!saved) {
182
+ throw new Error(`Failed to create category: ${category.LatestResult?.Message}`);
183
+ }
184
+
185
+ return category.ID;
186
+ }
187
+ }