@solidxai/core 0.1.11-beta.0 → 0.1.11-beta.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.
- package/dist/repository/scheduled-job.repository.d.ts +3 -0
- package/dist/repository/scheduled-job.repository.d.ts.map +1 -1
- package/dist/repository/scheduled-job.repository.js +60 -0
- package/dist/repository/scheduled-job.repository.js.map +1 -1
- package/dist/seeders/module-metadata-seeder.service.d.ts +32 -1
- package/dist/seeders/module-metadata-seeder.service.d.ts.map +1 -1
- package/dist/seeders/module-metadata-seeder.service.js +1011 -230
- package/dist/seeders/module-metadata-seeder.service.js.map +1 -1
- package/dist/services/action-metadata.service.d.ts +7 -0
- package/dist/services/action-metadata.service.d.ts.map +1 -1
- package/dist/services/action-metadata.service.js +76 -3
- package/dist/services/action-metadata.service.js.map +1 -1
- package/dist/services/role-metadata.service.d.ts +4 -0
- package/dist/services/role-metadata.service.d.ts.map +1 -1
- package/dist/services/role-metadata.service.js +72 -31
- package/dist/services/role-metadata.service.js.map +1 -1
- package/dist/services/view-metadata.service.d.ts +6 -0
- package/dist/services/view-metadata.service.d.ts.map +1 -1
- package/dist/services/view-metadata.service.js +66 -1
- package/dist/services/view-metadata.service.js.map +1 -1
- package/dist-tests/api/authenticate.spec.js +119 -0
- package/dist-tests/api/authenticate.spec.js.map +1 -0
- package/dist-tests/api/crud-service.findOne.cityMaster.spec.js +97 -0
- package/dist-tests/api/crud-service.findOne.cityMaster.spec.js.map +1 -0
- package/dist-tests/api/ping.spec.js +21 -0
- package/dist-tests/api/ping.spec.js.map +1 -0
- package/dist-tests/helpers/auth.js +41 -0
- package/dist-tests/helpers/auth.js.map +1 -0
- package/dist-tests/helpers/env.js +11 -0
- package/dist-tests/helpers/env.js.map +1 -0
- package/docs/agent-hub-grooming.md +301 -0
- package/docs/dashboards/AGENTIC_DASHBOARD_IMPLEMENTATION_PLAN.md +438 -0
- package/docs/dashboards/dashboard-curl-smoke-tests.txt +146 -0
- package/docs/dashboards/delete-legacy-dashboard-metadata.sql +172 -0
- package/docs/datasource-introspection-ddl-analysis.md +326 -0
- package/docs/datasource-introspection-implementation-plan.md +306 -0
- package/docs/grouping-enhancements.md +89 -0
- package/docs/java-spring/README.md +3 -0
- package/docs/java-spring/solid-core-module-deep-dive-report.md +1317 -0
- package/docs/module-package-import-handoff.md +691 -0
- package/docs/seed-changes.md +65 -0
- package/docs/test-data-workflow.md +200 -0
- package/docs/type-declaration-import-issue.md +24 -0
- package/package.json +1 -1
- package/src/repository/scheduled-job.repository.ts +73 -0
- package/src/seeders/module-metadata-seeder.service.ts +1223 -262
- package/src/services/action-metadata.service.ts +87 -2
- package/src/services/role-metadata.service.ts +86 -47
- package/src/services/view-metadata.service.ts +79 -1
- package/.claude/settings.local.json +0 -15
- package/src/services/1.js +0 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Injectable } from '@nestjs/common';
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
2
|
import { ModuleRef } from "@nestjs/core";
|
|
3
3
|
import { InjectEntityManager } from '@nestjs/typeorm';
|
|
4
4
|
import { CRUDService } from 'src/services/crud.service';
|
|
@@ -10,6 +10,79 @@ import { ActionMetadata } from '../entities/action-metadata.entity';
|
|
|
10
10
|
|
|
11
11
|
@Injectable()
|
|
12
12
|
export class ActionMetadataService extends CRUDService<ActionMetadata> {
|
|
13
|
+
private readonly logger = new Logger(ActionMetadataService.name);
|
|
14
|
+
private readonly comparableFields = [
|
|
15
|
+
'name',
|
|
16
|
+
'displayName',
|
|
17
|
+
'type',
|
|
18
|
+
'domain',
|
|
19
|
+
'context',
|
|
20
|
+
'customComponent',
|
|
21
|
+
'customIsModal',
|
|
22
|
+
'serverEndpoint',
|
|
23
|
+
'module',
|
|
24
|
+
'model',
|
|
25
|
+
'view',
|
|
26
|
+
] as const;
|
|
27
|
+
private readonly relationCompareFields = new Set(['module', 'model', 'view']);
|
|
28
|
+
private readonly jsonCompareFields = new Set(['domain', 'context']);
|
|
29
|
+
|
|
30
|
+
private normalizeJsonFieldValue(value: any): any {
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
const trimmedValue = value.trim();
|
|
33
|
+
if (
|
|
34
|
+
(trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
|
|
35
|
+
(trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))
|
|
36
|
+
) {
|
|
37
|
+
try {
|
|
38
|
+
return this.normalizeJsonFieldValue(JSON.parse(trimmedValue));
|
|
39
|
+
} catch {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
return value.map((item) => this.normalizeJsonFieldValue(item));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (value && typeof value === 'object') {
|
|
51
|
+
return Object.keys(value)
|
|
52
|
+
.sort()
|
|
53
|
+
.reduce((result, key) => {
|
|
54
|
+
result[key] = this.normalizeJsonFieldValue(value[key]);
|
|
55
|
+
return result;
|
|
56
|
+
}, {} as Record<string, any>);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private getCanonicalJsonFieldString(value: any): string {
|
|
63
|
+
return JSON.stringify(this.normalizeJsonFieldValue(value) ?? null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private hasChanges(existingSolidAction: ActionMetadata, updateSolidActionDto: any): boolean {
|
|
67
|
+
return this.comparableFields.some((key) => {
|
|
68
|
+
const value = updateSolidActionDto[key];
|
|
69
|
+
if (typeof value === 'undefined') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (this.relationCompareFields.has(key)) {
|
|
74
|
+
const relationValue = value as any;
|
|
75
|
+
return (existingSolidAction as any)[key]?.id !== relationValue?.id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (this.jsonCompareFields.has(key)) {
|
|
79
|
+
return this.getCanonicalJsonFieldString((existingSolidAction as any)[key]) !== this.getCanonicalJsonFieldString(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (existingSolidAction as any)[key] !== value;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
13
86
|
constructor(
|
|
14
87
|
@InjectEntityManager()
|
|
15
88
|
readonly entityManager: EntityManager,
|
|
@@ -37,11 +110,23 @@ export class ActionMetadataService extends CRUDService<ActionMetadata> {
|
|
|
37
110
|
const existingSolidAction = await this.repo.findOne({
|
|
38
111
|
where: {
|
|
39
112
|
name: updateSolidActionDto.name
|
|
40
|
-
}
|
|
113
|
+
},
|
|
114
|
+
relations: {
|
|
115
|
+
module: true,
|
|
116
|
+
model: true,
|
|
117
|
+
view: true,
|
|
118
|
+
},
|
|
41
119
|
})
|
|
42
120
|
|
|
43
121
|
// if found
|
|
44
122
|
if (existingSolidAction) {
|
|
123
|
+
const hasChanges = this.hasChanges(existingSolidAction, updateSolidActionDto);
|
|
124
|
+
|
|
125
|
+
if (!hasChanges) {
|
|
126
|
+
this.logger.debug(`Skipping action upsert for ${updateSolidActionDto.name}; no changes detected.`);
|
|
127
|
+
return existingSolidAction;
|
|
128
|
+
}
|
|
129
|
+
|
|
45
130
|
const updatedSolidActionDto = { ...existingSolidAction, ...updateSolidActionDto };
|
|
46
131
|
return this.repo.save(updatedSolidActionDto);
|
|
47
132
|
}
|
|
@@ -14,6 +14,7 @@ import { RoleMetadata } from '../entities/role-metadata.entity';
|
|
|
14
14
|
@Injectable()
|
|
15
15
|
export class RoleMetadataService extends CRUDService<RoleMetadata> {
|
|
16
16
|
private readonly logger = new Logger(RoleMetadataService.name);
|
|
17
|
+
private readonly permissionAssignmentBatchSize = 500;
|
|
17
18
|
|
|
18
19
|
constructor(
|
|
19
20
|
@InjectEntityManager()
|
|
@@ -126,66 +127,47 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
|
|
|
126
127
|
private async _addPermissionsToRole(roleName: string, permissionNames: string[]): Promise<RoleMetadata> {
|
|
127
128
|
const role = await this.repo.findOne({
|
|
128
129
|
where: { name: roleName },
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
select: {
|
|
131
|
+
id: true,
|
|
132
|
+
name: true,
|
|
131
133
|
}
|
|
132
134
|
});
|
|
133
135
|
if (!role) {
|
|
134
136
|
throw new Error(`Role '${roleName}' not found.`);
|
|
135
137
|
}
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
const joinInfo = this.getRolePermissionJoinInfo();
|
|
140
|
+
const targetPermissions = await this.loadTargetPermissions(permissionNames);
|
|
138
141
|
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
if (!permissionNames || permissionNames.length === 0) {
|
|
143
|
+
await this.entityManager
|
|
144
|
+
.createQueryBuilder()
|
|
145
|
+
.delete()
|
|
146
|
+
.from(joinInfo.tableName)
|
|
147
|
+
.where(`${joinInfo.roleIdColumn} = :roleId`, { roleId: role.id })
|
|
148
|
+
.execute();
|
|
141
149
|
|
|
142
|
-
|
|
143
|
-
if (permissionNames && permissionNames.length != 0) {
|
|
144
|
-
// this.logger.log(`Loading specified permissions.`);
|
|
145
|
-
|
|
146
|
-
newPermissions = await this.permissionRepository.find({ where: { name: In(permissionNames) } });
|
|
147
|
-
if (newPermissions.length !== permissionNames.length) {
|
|
148
|
-
throw new Error(`One or more permissions not found.`);
|
|
149
|
-
}
|
|
150
|
+
await this.insertRolePermissionMappings(role.id, targetPermissions.map((permission) => permission.id), joinInfo);
|
|
150
151
|
}
|
|
151
152
|
else {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
const existingRows = await this.entityManager
|
|
154
|
+
.createQueryBuilder()
|
|
155
|
+
.select(joinInfo.permissionIdColumn, 'permissionId')
|
|
156
|
+
.from(joinInfo.tableName, 'role_permission')
|
|
157
|
+
.where(`${joinInfo.roleIdColumn} = :roleId`, { roleId: role.id })
|
|
158
|
+
.getRawMany();
|
|
159
|
+
|
|
160
|
+
const existingPermissionIds = new Set(existingRows.map((row) => Number(row.permissionId)));
|
|
161
|
+
const missingPermissionIds = targetPermissions
|
|
162
|
+
.map((permission) => permission.id)
|
|
163
|
+
.filter((permissionId) => !existingPermissionIds.has(permissionId));
|
|
164
|
+
|
|
165
|
+
if (missingPermissionIds.length > 0) {
|
|
166
|
+
await this.insertRolePermissionMappings(role.id, missingPermissionIds, joinInfo);
|
|
159
167
|
}
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// if there are already permissions assigned.
|
|
165
|
-
if (role.permissions && role.permissions.length > 0) {
|
|
166
|
-
for (let i = 0; i < newPermissions.length; i++) {
|
|
167
|
-
const newPermission = newPermissions[i];
|
|
168
|
-
let newPermissionFound = true;
|
|
169
|
-
for (let j = 0; j < role.permissions.length; j++) {
|
|
170
|
-
const existingPermission = role.permissions[j];
|
|
171
|
-
if (existingPermission.name === newPermission.name) {
|
|
172
|
-
newPermissionFound = false;
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (newPermissionFound) {
|
|
178
|
-
role.permissions.push(newPermission);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
// else we create a new permissions set.
|
|
184
|
-
else {
|
|
185
|
-
role.permissions = newPermissions;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return await this.repo.save(role);
|
|
170
|
+
return await this.findRoleByName(roleName);
|
|
189
171
|
}
|
|
190
172
|
|
|
191
173
|
async removePermissionsFromRole(roleName: string, permissionNames: string[]): Promise<RoleMetadata> {
|
|
@@ -220,5 +202,62 @@ export class RoleMetadataService extends CRUDService<RoleMetadata> {
|
|
|
220
202
|
return existingPermission;
|
|
221
203
|
}
|
|
222
204
|
|
|
205
|
+
private async loadTargetPermissions(permissionNames: string[] | null): Promise<PermissionMetadata[]> {
|
|
206
|
+
if (permissionNames && permissionNames.length !== 0) {
|
|
207
|
+
const uniquePermissionNames = [...new Set(permissionNames)];
|
|
208
|
+
const permissions = await this.permissionRepository.find({ where: { name: In(uniquePermissionNames) } });
|
|
209
|
+
if (permissions.length !== uniquePermissionNames.length) {
|
|
210
|
+
throw new Error(`One or more permissions not found.`);
|
|
211
|
+
}
|
|
212
|
+
return permissions;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const permissions = await this.permissionRepository.find();
|
|
216
|
+
if (permissions.length === 0) {
|
|
217
|
+
throw new Error(`No permissions configured in the system. Did you forget to run the PermissionSeederService?`);
|
|
218
|
+
}
|
|
219
|
+
return permissions;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private getRolePermissionJoinInfo(): { tableName: string; roleIdColumn: string; permissionIdColumn: string } {
|
|
223
|
+
const relation = this.repo.metadata.relations.find((candidate) => candidate.propertyName === 'permissions');
|
|
224
|
+
if (!relation?.junctionEntityMetadata) {
|
|
225
|
+
throw new Error('Role-permission join table metadata not found.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const roleIdColumn = relation.joinColumns[0]?.databaseName;
|
|
229
|
+
const permissionIdColumn = relation.inverseJoinColumns[0]?.databaseName;
|
|
230
|
+
if (!roleIdColumn || !permissionIdColumn) {
|
|
231
|
+
throw new Error('Role-permission join table column metadata not found.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
tableName: relation.junctionEntityMetadata.tableName,
|
|
236
|
+
roleIdColumn,
|
|
237
|
+
permissionIdColumn,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async insertRolePermissionMappings(
|
|
242
|
+
roleId: number,
|
|
243
|
+
permissionIds: number[],
|
|
244
|
+
joinInfo: { tableName: string; roleIdColumn: string; permissionIdColumn: string },
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
for (let index = 0; index < permissionIds.length; index += this.permissionAssignmentBatchSize) {
|
|
247
|
+
const batch = permissionIds.slice(index, index + this.permissionAssignmentBatchSize);
|
|
248
|
+
await this.entityManager
|
|
249
|
+
.createQueryBuilder()
|
|
250
|
+
.insert()
|
|
251
|
+
.into(joinInfo.tableName)
|
|
252
|
+
.values(
|
|
253
|
+
batch.map((permissionId) => ({
|
|
254
|
+
[joinInfo.roleIdColumn]: roleId,
|
|
255
|
+
[joinInfo.permissionIdColumn]: permissionId,
|
|
256
|
+
})),
|
|
257
|
+
)
|
|
258
|
+
.execute();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
223
262
|
|
|
224
263
|
}
|
|
@@ -21,6 +21,74 @@ import { MenuItemMetadata } from 'src/entities/menu-item-metadata.entity';
|
|
|
21
21
|
|
|
22
22
|
@Injectable()
|
|
23
23
|
export class ViewMetadataService extends CRUDService<ViewMetadata> {
|
|
24
|
+
private readonly comparableFields = [
|
|
25
|
+
'name',
|
|
26
|
+
'displayName',
|
|
27
|
+
'type',
|
|
28
|
+
'context',
|
|
29
|
+
'layout',
|
|
30
|
+
'module',
|
|
31
|
+
'model',
|
|
32
|
+
] as const;
|
|
33
|
+
private readonly relationCompareFields = new Set(['module', 'model']);
|
|
34
|
+
private readonly jsonCompareFields = new Set(['layout', 'context']);
|
|
35
|
+
|
|
36
|
+
private normalizeJsonFieldValue(value: any): any {
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
const trimmedValue = value.trim();
|
|
39
|
+
if (
|
|
40
|
+
(trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
|
|
41
|
+
(trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))
|
|
42
|
+
) {
|
|
43
|
+
try {
|
|
44
|
+
return this.normalizeJsonFieldValue(JSON.parse(trimmedValue));
|
|
45
|
+
} catch {
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return value.map((item) => this.normalizeJsonFieldValue(item));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (value && typeof value === 'object') {
|
|
57
|
+
return Object.keys(value)
|
|
58
|
+
.sort()
|
|
59
|
+
.reduce((result, key) => {
|
|
60
|
+
result[key] = this.normalizeJsonFieldValue(value[key]);
|
|
61
|
+
return result;
|
|
62
|
+
}, {} as Record<string, any>);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private getCanonicalJsonFieldString(value: any): string {
|
|
69
|
+
return JSON.stringify(this.normalizeJsonFieldValue(value) ?? null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private hasChanges(existingSolidView: ViewMetadata, updateSolidViewDto: UpdateViewMetadataDto): boolean {
|
|
73
|
+
return this.comparableFields.some((key) => {
|
|
74
|
+
const value = (updateSolidViewDto as any)[key];
|
|
75
|
+
if (typeof value === 'undefined') {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.relationCompareFields.has(key)) {
|
|
80
|
+
const relationValue = value as any;
|
|
81
|
+
return (existingSolidView as any)[key]?.id !== relationValue?.id;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.jsonCompareFields.has(key)) {
|
|
85
|
+
return this.getCanonicalJsonFieldString((existingSolidView as any)[key]) !== this.getCanonicalJsonFieldString(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (existingSolidView as any)[key] !== value;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
24
92
|
constructor(
|
|
25
93
|
readonly actionMetadataService: ActionMetadataService,
|
|
26
94
|
readonly menuItemMetadataService: MenuItemMetadataService,
|
|
@@ -438,10 +506,20 @@ export class ViewMetadataService extends CRUDService<ViewMetadata> {
|
|
|
438
506
|
|
|
439
507
|
async upsert(updateSolidViewDto: UpdateViewMetadataDto) {
|
|
440
508
|
// First check if module already exists using name
|
|
441
|
-
const existingSolidView = await this.findOneByUserKey(updateSolidViewDto.name
|
|
509
|
+
const existingSolidView = await this.findOneByUserKey(updateSolidViewDto.name, {
|
|
510
|
+
module: true,
|
|
511
|
+
model: true,
|
|
512
|
+
});
|
|
442
513
|
|
|
443
514
|
// if found
|
|
444
515
|
if (existingSolidView) {
|
|
516
|
+
const hasChanges = this.hasChanges(existingSolidView, updateSolidViewDto);
|
|
517
|
+
|
|
518
|
+
if (!hasChanges) {
|
|
519
|
+
this.logger.debug(`Skipping view upsert for ${updateSolidViewDto.name}; no changes detected.`);
|
|
520
|
+
return existingSolidView;
|
|
521
|
+
}
|
|
522
|
+
|
|
445
523
|
const updatedSolidViewDto = { ...existingSolidView, ...updateSolidViewDto };
|
|
446
524
|
return this.repo.save(updatedSolidViewDto);
|
|
447
525
|
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(node -e:*)",
|
|
5
|
-
"WebFetch(domain:docs.solidxai.com)",
|
|
6
|
-
"WebFetch(domain:github.com)",
|
|
7
|
-
"Read(//Users/oswald/projects/Solid_Starters/solidctl/**)",
|
|
8
|
-
"Read(//Users/oswald/projects/Solid_Starters/**)",
|
|
9
|
-
"Bash(find /Users/oswald/projects/Solid_Starters/solid-core-module -type d -name migration* -o -name *database*)"
|
|
10
|
-
],
|
|
11
|
-
"additionalDirectories": [
|
|
12
|
-
"/Users/oswald/projects/Solid_Starters/solidctl"
|
|
13
|
-
]
|
|
14
|
-
}
|
|
15
|
-
}
|
package/src/services/1.js
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
1. Do i need to create a storeStreams method for aws service too?
|
|
2
|
-
- Handle later
|
|
3
|
-
2. queues handling -> if queues is enabled by default, i.e triggerExport(exportTransactionEntity.id).
|
|
4
|
-
- startExport should either return the data or return the transaction id
|
|
5
|
-
3. How to handle scenarios wherein, nested related exist.(do i need to only get the userkey)
|
|
6
|
-
- show the userKey
|