@memberjunction/server 2.73.0 → 2.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -85,13 +85,14 @@ export * from './resolvers/DatasetResolver.js';
85
85
  export * from './resolvers/EntityRecordNameResolver.js';
86
86
  export * from './resolvers/MergeRecordsResolver.js';
87
87
  export * from './resolvers/ReportResolver.js';
88
+ export * from './resolvers/QueryResolver.js';
88
89
  export * from './resolvers/SqlLoggingConfigResolver.js';
89
90
  export * from './resolvers/SyncRolesUsersResolver.js';
90
91
  export * from './resolvers/SyncDataResolver.js';
91
92
  export * from './resolvers/GetDataResolver.js';
92
93
  export * from './resolvers/GetDataContextDataResolver.js';
93
94
  export * from './resolvers/TransactionGroupResolver.js';
94
-
95
+ export * from './resolvers/CreateQueryResolver.js';
95
96
  export { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
96
97
 
97
98
  export * from './generated/generated.js';
@@ -1396,6 +1396,20 @@ cycle.`);
1396
1396
  );
1397
1397
  }
1398
1398
 
1399
+
1400
+ /**
1401
+ * Recursively builds the category path for a query
1402
+ * @param md
1403
+ * @param categoryID
1404
+ */
1405
+ protected buildQueryCategoryPath(md: Metadata, categoryID: string): string {
1406
+ const cat = md.QueryCategories.find((c) => c.ID === categoryID);
1407
+ if (!cat) return '';
1408
+ if (!cat.ParentID) return cat.Name; // base case, no parent, just return the name
1409
+ const parentPath = this.buildQueryCategoryPath(md, cat.ParentID); // build the path recursively
1410
+ return parentPath ? `${parentPath}/${cat.Name}` : cat.Name;
1411
+ }
1412
+
1399
1413
  /**
1400
1414
  * Packages up queries from the metadata based on their status
1401
1415
  * Used to provide Skip with information about available queries
@@ -1412,6 +1426,7 @@ cycle.`);
1412
1426
  name: q.Name,
1413
1427
  description: q.Description,
1414
1428
  category: q.Category,
1429
+ categoryPath: this.buildQueryCategoryPath(md, q.CategoryID),
1415
1430
  sql: q.SQL,
1416
1431
  originalSQL: q.OriginalSQL,
1417
1432
  feedback: q.Feedback,
@@ -0,0 +1,257 @@
1
+ import { Arg, Ctx, Field, InputType, Mutation, ObjectType, registerEnumType } from 'type-graphql';
2
+ import { AppContext } from '../types.js';
3
+ import { LogError, Metadata, RunView, UserInfo } from '@memberjunction/core';
4
+ import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
+ import { QueryEntity, QueryCategoryEntity } from '@memberjunction/core-entities';
6
+
7
+ /**
8
+ * Query status enumeration for GraphQL
9
+ */
10
+ export enum QueryStatus {
11
+ Pending = "Pending",
12
+ Approved = "Approved",
13
+ Rejected = "Rejected",
14
+ Expired = "Expired"
15
+ }
16
+
17
+ registerEnumType(QueryStatus, {
18
+ name: "QueryStatus",
19
+ description: "Status of a query: Pending, Approved, Rejected, or Expired"
20
+ });
21
+
22
+ @InputType()
23
+ export class CreateQueryInputType {
24
+ @Field(() => String)
25
+ Name!: string;
26
+
27
+ @Field(() => String, { nullable: true })
28
+ CategoryID?: string;
29
+
30
+ @Field(() => String, { nullable: true })
31
+ CategoryPath?: string;
32
+
33
+ @Field(() => String, { nullable: true })
34
+ UserQuestion?: string;
35
+
36
+ @Field(() => String, { nullable: true })
37
+ Description?: string;
38
+
39
+ @Field(() => String, { nullable: true })
40
+ SQL?: string;
41
+
42
+ @Field(() => String, { nullable: true })
43
+ TechnicalDescription?: string;
44
+
45
+ @Field(() => String, { nullable: true })
46
+ OriginalSQL?: string;
47
+
48
+ @Field(() => String, { nullable: true })
49
+ Feedback?: string;
50
+
51
+ @Field(() => QueryStatus, { nullable: true, defaultValue: QueryStatus.Pending })
52
+ Status?: QueryStatus;
53
+
54
+ @Field(() => Number, { nullable: true })
55
+ QualityRank?: number;
56
+
57
+ @Field(() => Number, { nullable: true })
58
+ ExecutionCostRank?: number;
59
+
60
+ @Field(() => Boolean, { nullable: true })
61
+ UsesTemplate?: boolean;
62
+ }
63
+
64
+ @ObjectType()
65
+ export class CreateQueryResultType {
66
+ @Field(() => Boolean)
67
+ Success!: boolean;
68
+
69
+ @Field(() => String, { nullable: true })
70
+ ErrorMessage?: string;
71
+
72
+ @Field(() => String, { nullable: true })
73
+ QueryData?: string;
74
+ }
75
+
76
+ export class CreateQueryResolver {
77
+ /**
78
+ * Creates a new query with the provided attributes. This mutation is restricted to system users only.
79
+ * @param input - CreateQueryInputType containing all the query attributes
80
+ * @param context - Application context containing user information
81
+ * @returns CreateQueryResultType with success status and query data
82
+ */
83
+ @RequireSystemUser()
84
+ @Mutation(() => CreateQueryResultType)
85
+ async CreateQuery(
86
+ @Arg('input', () => CreateQueryInputType) input: CreateQueryInputType,
87
+ @Ctx() context: AppContext
88
+ ): Promise<CreateQueryResultType> {
89
+ try {
90
+ const md = new Metadata();
91
+ const newQuery = await md.GetEntityObject<QueryEntity>("Queries", context.userPayload.userRecord);
92
+
93
+ // Handle CategoryPath if provided
94
+ let finalCategoryID = input.CategoryID;
95
+ if (input.CategoryPath) {
96
+ finalCategoryID = await this.findOrCreateCategoryPath(input.CategoryPath, md, context.userPayload.userRecord);
97
+ }
98
+
99
+ // Populate the query fields from input
100
+ newQuery.Name = input.Name;
101
+
102
+ if (finalCategoryID != null) {
103
+ newQuery.CategoryID = finalCategoryID;
104
+ }
105
+
106
+ if (input.UserQuestion != null) {
107
+ newQuery.UserQuestion = input.UserQuestion;
108
+ }
109
+
110
+ if (input.Description != null) {
111
+ newQuery.Description = input.Description;
112
+ }
113
+
114
+ if (input.SQL != null) {
115
+ newQuery.SQL = input.SQL;
116
+ }
117
+
118
+ if (input.TechnicalDescription != null) {
119
+ newQuery.TechnicalDescription = input.TechnicalDescription;
120
+ }
121
+
122
+ if (input.OriginalSQL != null) {
123
+ newQuery.OriginalSQL = input.OriginalSQL;
124
+ }
125
+
126
+ if (input.Feedback != null) {
127
+ newQuery.Feedback = input.Feedback;
128
+ }
129
+
130
+ if (input.Status != null) {
131
+ newQuery.Status = input.Status;
132
+ }
133
+
134
+ if (input.QualityRank != null) {
135
+ newQuery.QualityRank = input.QualityRank;
136
+ }
137
+
138
+ if (input.ExecutionCostRank != null) {
139
+ newQuery.ExecutionCostRank = input.ExecutionCostRank;
140
+ }
141
+
142
+ if (input.UsesTemplate != null) {
143
+ newQuery.UsesTemplate = input.UsesTemplate;
144
+ }
145
+
146
+ // Save the query
147
+ const saveResult = await newQuery.Save();
148
+
149
+ if (saveResult) {
150
+ return {
151
+ Success: true,
152
+ QueryData: JSON.stringify(newQuery.GetAll())
153
+ };
154
+ } else {
155
+ return {
156
+ Success: false,
157
+ ErrorMessage: `Failed to save query: ${newQuery.LatestResult?.Message || 'Unknown error'}`
158
+ };
159
+ }
160
+
161
+ } catch (err) {
162
+ LogError(err);
163
+ return {
164
+ Success: false,
165
+ ErrorMessage: `CreateQueryResolver::CreateQuery --- Error creating query: ${err instanceof Error ? err.message : String(err)}`
166
+ };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Finds or creates a category hierarchy based on the provided path.
172
+ * Path format: "Parent/Child/Grandchild" - case insensitive lookup and creation.
173
+ * @param categoryPath - Slash-separated category path
174
+ * @param md - Metadata instance
175
+ * @param contextUser - User context for operations
176
+ * @returns The ID of the final category in the path
177
+ */
178
+ private async findOrCreateCategoryPath(categoryPath: string, md: Metadata, contextUser: UserInfo): Promise<string> {
179
+ if (!categoryPath || categoryPath.trim() === '') {
180
+ throw new Error('CategoryPath cannot be empty');
181
+ }
182
+
183
+ const pathParts = categoryPath.split('/').map(part => part.trim()).filter(part => part.length > 0);
184
+ if (pathParts.length === 0) {
185
+ throw new Error('CategoryPath must contain at least one valid category name');
186
+ }
187
+
188
+ let currentParentID: string | null = null;
189
+ let currentCategoryID: string | null = null;
190
+
191
+ for (let i = 0; i < pathParts.length; i++) {
192
+ const categoryName = pathParts[i];
193
+
194
+ // Look for existing category at this level
195
+ const existingCategory = await this.findCategoryByNameAndParent(categoryName, currentParentID, contextUser);
196
+
197
+ if (existingCategory) {
198
+ currentCategoryID = existingCategory.ID;
199
+ currentParentID = existingCategory.ID;
200
+ } else {
201
+ // Create new category
202
+ const newCategory = await md.GetEntityObject<QueryCategoryEntity>("Query Categories", contextUser);
203
+ newCategory.Name = categoryName;
204
+ newCategory.ParentID = currentParentID;
205
+ newCategory.UserID = contextUser.ID;
206
+ newCategory.Description = `Auto-created category from path: ${categoryPath}`;
207
+
208
+ const saveResult = await newCategory.Save();
209
+ if (!saveResult) {
210
+ throw new Error(`Failed to create category '${categoryName}': ${newCategory.LatestResult?.Message || 'Unknown error'}`);
211
+ }
212
+
213
+ currentCategoryID = newCategory.ID;
214
+ currentParentID = newCategory.ID;
215
+
216
+ // Refresh metadata after each category creation to ensure it's available for subsequent lookups
217
+ await md.Refresh();
218
+ }
219
+ }
220
+
221
+ if (!currentCategoryID) {
222
+ throw new Error('Failed to determine final category ID');
223
+ }
224
+
225
+ return currentCategoryID;
226
+ }
227
+
228
+ /**
229
+ * Finds a category by name and parent ID using case-insensitive comparison via RunView.
230
+ * @param categoryName - Name of the category to find
231
+ * @param parentID - Parent category ID (null for root level)
232
+ * @param contextUser - User context for database operations
233
+ * @returns The matching category entity or null if not found
234
+ */
235
+ private async findCategoryByNameAndParent(categoryName: string, parentID: string | null, contextUser: UserInfo): Promise<QueryCategoryEntity | null> {
236
+ try {
237
+ const rv = new RunView();
238
+ const parentFilter = parentID ? `ParentID='${parentID}'` : 'ParentID IS NULL';
239
+ const nameFilter = `LOWER(Name) = LOWER('${categoryName.replace(/'/g, "''")}')`; // Escape single quotes
240
+
241
+ const result = await rv.RunView<QueryCategoryEntity>({
242
+ EntityName: 'Query Categories',
243
+ ExtraFilter: `${nameFilter} AND ${parentFilter}`,
244
+ ResultType: 'entity_object'
245
+ }, contextUser);
246
+
247
+ if (result.Success && result.Results && result.Results.length > 0) {
248
+ return result.Results[0];
249
+ }
250
+
251
+ return null;
252
+ } catch (error) {
253
+ LogError(error);
254
+ return null;
255
+ }
256
+ }
257
+ }
@@ -1,8 +1,10 @@
1
- // Queries are MemberJunction primitive operations that are used to retrieve data from the server from any stored query
2
- import { RunQuery } from '@memberjunction/core';
3
- import { Arg, Ctx, Field, Int, ObjectType, Query, Resolver } from 'type-graphql';
1
+ import { Arg, Ctx, ObjectType, Query, Resolver, Field, Int } from 'type-graphql';
2
+ import { RunQuery, QueryInfo } from '@memberjunction/core';
4
3
  import { AppContext } from '../types.js';
5
4
  import { RequireSystemUser } from '../directives/RequireSystemUser.js';
5
+ import { GraphQLJSONObject } from 'graphql-type-json';
6
+ import { QueryEntity } from '@memberjunction/core-entities';
7
+ import { Metadata } from '@memberjunction/core';
6
8
 
7
9
  @ObjectType()
8
10
  export class RunQueryResultType {
@@ -21,37 +23,104 @@ export class RunQueryResultType {
21
23
  @Field()
22
24
  RowCount: number;
23
25
 
26
+ @Field()
27
+ TotalRowCount: number;
28
+
24
29
  @Field()
25
30
  ExecutionTime: number;
26
31
 
27
32
  @Field()
28
33
  ErrorMessage: string;
34
+
35
+ @Field(() => String, { nullable: true })
36
+ AppliedParameters?: string;
29
37
  }
30
38
 
31
- @Resolver(RunQueryResultType)
32
- export class ReportResolver {
39
+ @Resolver()
40
+ export class RunQueryResolver {
41
+ private async findQuery(QueryID: string, QueryName?: string, CategoryID?: string, CategoryName?: string, refreshMetadataIfNotFound: boolean = false): Promise<QueryInfo | null> {
42
+ const md = new Metadata();
43
+
44
+ // Filter queries based on provided criteria
45
+ const queries = md.Queries.filter(q => {
46
+ if (QueryID) {
47
+ return q.ID.trim().toLowerCase() === QueryID.trim().toLowerCase();
48
+ } else if (QueryName) {
49
+ let matches = q.Name.trim().toLowerCase() === QueryName.trim().toLowerCase();
50
+ if (CategoryID) {
51
+ matches = matches && q.CategoryID?.trim().toLowerCase() === CategoryID.trim().toLowerCase();
52
+ }
53
+ if (CategoryName) {
54
+ matches = matches && q.Category?.trim().toLowerCase() === CategoryName.trim().toLowerCase();
55
+ }
56
+ return matches;
57
+ }
58
+ return false;
59
+ });
60
+
61
+ if (queries.length === 0) {
62
+ if (refreshMetadataIfNotFound) {
63
+ // If we didn't find the query, refresh metadata and try again
64
+ await md.Refresh();
65
+ return this.findQuery(QueryID, QueryName, CategoryID, CategoryName, false); // change the refresh flag to false so we don't loop infinitely
66
+ }
67
+ else {
68
+ return null; // No query found and not refreshing metadata
69
+ }
70
+ }
71
+ else {
72
+ return queries[0];
73
+ }
74
+ }
33
75
  @Query(() => RunQueryResultType)
34
76
  async GetQueryData(@Arg('QueryID', () => String) QueryID: string,
35
77
  @Ctx() context: AppContext,
36
78
  @Arg('CategoryID', () => String, {nullable: true}) CategoryID?: string,
37
- @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string): Promise<RunQueryResultType> {
79
+ @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string,
80
+ @Arg('Parameters', () => GraphQLJSONObject, {nullable: true}) Parameters?: Record<string, any>,
81
+ @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
82
+ @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number): Promise<RunQueryResultType> {
38
83
  const runQuery = new RunQuery();
84
+ console.log('GetQueryData called with:', { QueryID, Parameters, MaxRows, StartRow });
39
85
  const result = await runQuery.RunQuery(
40
86
  {
41
87
  QueryID: QueryID,
42
88
  CategoryID: CategoryID,
43
- CategoryName: CategoryName
89
+ CategoryName: CategoryName,
90
+ Parameters: Parameters,
91
+ MaxRows: MaxRows,
92
+ StartRow: StartRow
44
93
  },
45
94
  context.userPayload.userRecord);
95
+ console.log('RunQuery result:', {
96
+ Success: result.Success,
97
+ ErrorMessage: result.ErrorMessage,
98
+ AppliedParameters: result.AppliedParameters
99
+ });
100
+
101
+ // If QueryName is not populated by the provider, use efficient lookup
102
+ let queryName = result.QueryName;
103
+ if (!queryName) {
104
+ try {
105
+ const queryInfo = await this.findQuery(QueryID, undefined, CategoryID, CategoryName, true);
106
+ if (queryInfo) {
107
+ queryName = queryInfo.Name;
108
+ }
109
+ } catch (error) {
110
+ console.error('Error finding query to get name:', error);
111
+ }
112
+ }
46
113
 
47
114
  return {
48
115
  QueryID: QueryID,
49
- QueryName: result.QueryName,
50
- Success: result.Success,
51
- Results: JSON.stringify(result.Results),
52
- RowCount: result.RowCount,
53
- ExecutionTime: result.ExecutionTime,
54
- ErrorMessage: result.ErrorMessage,
116
+ QueryName: queryName || 'Unknown Query',
117
+ Success: result.Success ?? false,
118
+ Results: JSON.stringify(result.Results ?? null),
119
+ RowCount: result.RowCount ?? 0,
120
+ TotalRowCount: result.TotalRowCount ?? 0,
121
+ ExecutionTime: result.ExecutionTime ?? 0,
122
+ ErrorMessage: result.ErrorMessage || '',
123
+ AppliedParameters: result.AppliedParameters ? JSON.stringify(result.AppliedParameters) : undefined
55
124
  };
56
125
  }
57
126
 
@@ -59,24 +128,32 @@ export class ReportResolver {
59
128
  async GetQueryDataByName(@Arg('QueryName', () => String) QueryName: string,
60
129
  @Ctx() context: AppContext,
61
130
  @Arg('CategoryID', () => String, {nullable: true}) CategoryID?: string,
62
- @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string): Promise<RunQueryResultType> {
131
+ @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string,
132
+ @Arg('Parameters', () => GraphQLJSONObject, {nullable: true}) Parameters?: Record<string, any>,
133
+ @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
134
+ @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number): Promise<RunQueryResultType> {
63
135
  const runQuery = new RunQuery();
64
136
  const result = await runQuery.RunQuery(
65
137
  {
66
138
  QueryName: QueryName,
67
139
  CategoryID: CategoryID,
68
- CategoryName: CategoryName
140
+ CategoryName: CategoryName,
141
+ Parameters: Parameters,
142
+ MaxRows: MaxRows,
143
+ StartRow: StartRow
69
144
  },
70
145
  context.userPayload.userRecord);
71
146
 
72
147
  return {
73
- QueryID: result.QueryID,
148
+ QueryID: result.QueryID || '',
74
149
  QueryName: QueryName,
75
- Success: result.Success,
76
- Results: JSON.stringify(result.Results),
77
- RowCount: result.RowCount,
78
- ExecutionTime: result.ExecutionTime,
79
- ErrorMessage: result.ErrorMessage,
150
+ Success: result.Success ?? false,
151
+ Results: JSON.stringify(result.Results ?? null),
152
+ RowCount: result.RowCount ?? 0,
153
+ TotalRowCount: result.TotalRowCount ?? 0,
154
+ ExecutionTime: result.ExecutionTime ?? 0,
155
+ ErrorMessage: result.ErrorMessage || '',
156
+ AppliedParameters: result.AppliedParameters ? JSON.stringify(result.AppliedParameters) : undefined
80
157
  };
81
158
  }
82
159
 
@@ -85,50 +162,45 @@ export class ReportResolver {
85
162
  async GetQueryDataSystemUser(@Arg('QueryID', () => String) QueryID: string,
86
163
  @Ctx() context: AppContext,
87
164
  @Arg('CategoryID', () => String, {nullable: true}) CategoryID?: string,
88
- @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string): Promise<RunQueryResultType> {
165
+ @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string,
166
+ @Arg('Parameters', () => GraphQLJSONObject, {nullable: true}) Parameters?: Record<string, any>,
167
+ @Arg('MaxRows', () => Int, {nullable: true}) MaxRows?: number,
168
+ @Arg('StartRow', () => Int, {nullable: true}) StartRow?: number): Promise<RunQueryResultType> {
89
169
  const runQuery = new RunQuery();
90
170
  const result = await runQuery.RunQuery(
91
171
  {
92
172
  QueryID: QueryID,
93
173
  CategoryID: CategoryID,
94
- CategoryName: CategoryName
174
+ CategoryName: CategoryName,
175
+ Parameters: Parameters,
176
+ MaxRows: MaxRows,
177
+ StartRow: StartRow
95
178
  },
96
179
  context.userPayload.userRecord);
97
180
 
181
+ // If QueryName is not populated by the provider, use efficient lookup
182
+ let queryName = result.QueryName;
183
+ if (!queryName) {
184
+ try {
185
+ const queryInfo = await this.findQuery(QueryID, undefined, CategoryID, CategoryName, true);
186
+ if (queryInfo) {
187
+ queryName = queryInfo.Name;
188
+ }
189
+ } catch (error) {
190
+ console.error('Error finding query to get name:', error);
191
+ }
192
+ }
193
+
98
194
  return {
99
195
  QueryID: QueryID,
100
- QueryName: result.QueryName,
101
- Success: result.Success,
102
- Results: JSON.stringify(result.Results),
103
- RowCount: result.RowCount,
104
- ExecutionTime: result.ExecutionTime,
105
- ErrorMessage: result.ErrorMessage,
196
+ QueryName: queryName || 'Unknown Query',
197
+ Success: result.Success ?? false,
198
+ Results: JSON.stringify(result.Results ?? null),
199
+ RowCount: result.RowCount ?? 0,
200
+ TotalRowCount: result.TotalRowCount ?? 0,
201
+ ExecutionTime: result.ExecutionTime ?? 0,
202
+ ErrorMessage: result.ErrorMessage || '',
203
+ AppliedParameters: result.AppliedParameters ? JSON.stringify(result.AppliedParameters) : undefined
106
204
  };
107
205
  }
108
-
109
- @RequireSystemUser()
110
- @Query(() => RunQueryResultType)
111
- async GetQueryDataByNameSystemUser(@Arg('QueryName', () => String) QueryName: string,
112
- @Ctx() context: AppContext,
113
- @Arg('CategoryID', () => String, {nullable: true}) CategoryID?: string,
114
- @Arg('CategoryName', () => String, {nullable: true}) CategoryName?: string): Promise<RunQueryResultType> {
115
- const runQuery = new RunQuery();
116
- const result = await runQuery.RunQuery(
117
- {
118
- QueryName: QueryName,
119
- CategoryID: CategoryID,
120
- CategoryName: CategoryName
121
- },
122
- context.userPayload.userRecord);
123
-
124
- return {
125
- QueryID: result.QueryID,
126
- QueryName: QueryName,
127
- Success: result.Success,
128
- Results: JSON.stringify(result.Results),
129
- RowCount: result.RowCount,
130
- ExecutionTime: result.ExecutionTime,
131
- ErrorMessage: result.ErrorMessage,
132
- };
133
- }
134
- }
206
+ }