prisma-safe-ai-query 1.0.5 → 2.0.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/README.md +506 -125
- package/dist/agent.d.ts +73 -7
- package/dist/agent.js +334 -10
- package/dist/engine.d.ts +33 -6
- package/dist/engine.js +363 -43
- package/dist/index.d.ts +4 -3
- package/dist/index.js +7 -1
- package/dist/introspect.d.ts +42 -0
- package/dist/introspect.js +270 -0
- package/dist/schema.d.ts +26 -36
- package/dist/schema.js +93 -10
- package/package.json +40 -35
package/dist/agent.d.ts
CHANGED
|
@@ -1,17 +1,83 @@
|
|
|
1
|
-
import { SecurityConfig } from "./engine";
|
|
1
|
+
import { SecurityConfig, ParsedDynamicQuery } from "./engine";
|
|
2
2
|
export interface AgentConfig extends SecurityConfig {
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Describe your database tables/columns to help Gemini generate accurate queries.
|
|
5
|
+
* If omitted and schemaPath or autoIntrospect is enabled, schema information will be extracted automatically.
|
|
6
|
+
*/
|
|
4
7
|
dbContext?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Optional path to the schema.prisma file (e.g. "./prisma/schema.prisma").
|
|
10
|
+
* If omitted, it automatically scans standard locations.
|
|
11
|
+
*/
|
|
12
|
+
schemaPath?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Optional PrismaClient or DMMF object to introspect models/relations from.
|
|
15
|
+
*/
|
|
16
|
+
prismaClient?: any;
|
|
17
|
+
/**
|
|
18
|
+
* Automatically introspect models, relations, fields, and enums from schema.prisma or prismaClient.
|
|
19
|
+
* Defaults to true.
|
|
20
|
+
*/
|
|
21
|
+
autoIntrospect?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Whether to generate a human-friendly natural language response summarizing the query results.
|
|
24
|
+
* Defaults to process.env.NATURAL_LANGUAGE_RESPONSE ?? true.
|
|
25
|
+
*/
|
|
26
|
+
naturalLanguageResponse?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface NaturalLanguageResult<T = any> {
|
|
29
|
+
/**
|
|
30
|
+
* Structured records returned from the database execution.
|
|
31
|
+
*/
|
|
32
|
+
data: T;
|
|
33
|
+
/**
|
|
34
|
+
* Conversational natural language summary answering the user's initial question.
|
|
35
|
+
* Only generated if naturalLanguageResponse is true.
|
|
36
|
+
*/
|
|
37
|
+
naturalLanguage?: string;
|
|
38
|
+
/**
|
|
39
|
+
* The sanitized Prisma query details used to fetch the data.
|
|
40
|
+
*/
|
|
41
|
+
query: ParsedDynamicQuery;
|
|
5
42
|
}
|
|
6
43
|
export declare class PrismaGeminiAgent {
|
|
7
44
|
private ai;
|
|
8
45
|
private model;
|
|
46
|
+
private cachedContext?;
|
|
47
|
+
private cachedSchema?;
|
|
9
48
|
constructor(apiKey?: string, model?: string);
|
|
10
49
|
/**
|
|
11
|
-
*
|
|
50
|
+
* Resolves the database context by combining explicit dbContext with automatically introspected schema.
|
|
51
|
+
*/
|
|
52
|
+
private resolveDbContext;
|
|
53
|
+
/**
|
|
54
|
+
* Determines whether natural language response generation is enabled.
|
|
55
|
+
*/
|
|
56
|
+
isNaturalLanguageEnabled(config?: AgentConfig): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Translates a natural language prompt directly into a validated multi-level Prisma query.
|
|
59
|
+
*/
|
|
60
|
+
generateSafeQuery(userPrompt: string, config?: AgentConfig): Promise<ParsedDynamicQuery>;
|
|
61
|
+
/**
|
|
62
|
+
* Summarizes the raw database records into a conversational, natural language answer.
|
|
63
|
+
*/
|
|
64
|
+
formatAnswer(userPrompt: string, databaseResults: any): Promise<string>;
|
|
65
|
+
/**
|
|
66
|
+
* Resolves the exact delegate key on the PrismaClient instance (handles PascalCase vs camelCase).
|
|
67
|
+
*/
|
|
68
|
+
private resolveClientDelegateKey;
|
|
69
|
+
/**
|
|
70
|
+
* Fallback multi-level foreign-key stitcher:
|
|
71
|
+
* If returned rows contain foreign key columns (e.g. `userId`) and the prompt mentions related entities
|
|
72
|
+
* that weren't already populated via Prisma `include`, automatically fetches the linked rows (and their 2nd-level children).
|
|
73
|
+
*/
|
|
74
|
+
private stitchLinkedRelations;
|
|
75
|
+
/**
|
|
76
|
+
* End-to-end execution helper:
|
|
77
|
+
* 1. Introspects schema from prismaClient/schema.prisma and generates the safe multi-level Prisma query
|
|
78
|
+
* 2. Executes it on the provided prisma client (and auto-stitches foreign keys if needed)
|
|
79
|
+
* 3. Recursively strips any blocked fields
|
|
80
|
+
* 4. Optionally generates a natural language summary if enabled (defaults to true)
|
|
12
81
|
*/
|
|
13
|
-
|
|
14
|
-
targetTable?: string;
|
|
15
|
-
where: any;
|
|
16
|
-
}>;
|
|
82
|
+
execute<T = any>(userPrompt: string, prismaClient: any, config?: AgentConfig): Promise<NaturalLanguageResult<T>>;
|
|
17
83
|
}
|
package/dist/agent.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.PrismaGeminiAgent = void 0;
|
|
4
4
|
const genai_1 = require("@google/genai");
|
|
5
5
|
const engine_1 = require("./engine");
|
|
6
|
+
const introspect_1 = require("./introspect");
|
|
6
7
|
const DEFAULT_MODEL = "gemini-3.1-flash-lite-preview";
|
|
7
8
|
class PrismaGeminiAgent {
|
|
8
9
|
constructor(apiKey, model) {
|
|
@@ -14,7 +15,44 @@ class PrismaGeminiAgent {
|
|
|
14
15
|
this.model = model || process.env.GEMINI_MODEL || DEFAULT_MODEL;
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
|
-
*
|
|
18
|
+
* Resolves the database context by combining explicit dbContext with automatically introspected schema.
|
|
19
|
+
*/
|
|
20
|
+
resolveDbContext(config) {
|
|
21
|
+
const contextParts = [];
|
|
22
|
+
if (config?.dbContext) {
|
|
23
|
+
contextParts.push(config.dbContext);
|
|
24
|
+
}
|
|
25
|
+
const autoIntrospect = config?.autoIntrospect ?? true;
|
|
26
|
+
if (autoIntrospect) {
|
|
27
|
+
if (!this.cachedContext || config?.prismaClient || config?.schemaPath) {
|
|
28
|
+
const schemaSource = config?.prismaClient || config?.schemaPath;
|
|
29
|
+
const extracted = (0, introspect_1.extractPrismaSchema)(schemaSource);
|
|
30
|
+
if (extracted.models.length > 0 || extracted.enums.length > 0) {
|
|
31
|
+
this.cachedSchema = extracted;
|
|
32
|
+
this.cachedContext = (0, introspect_1.formatSchemaForAIContext)(extracted);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (this.cachedContext) {
|
|
36
|
+
contextParts.push(this.cachedContext);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return contextParts.join("\n\n");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Determines whether natural language response generation is enabled.
|
|
43
|
+
*/
|
|
44
|
+
isNaturalLanguageEnabled(config) {
|
|
45
|
+
if (typeof config?.naturalLanguageResponse === "boolean") {
|
|
46
|
+
return config.naturalLanguageResponse;
|
|
47
|
+
}
|
|
48
|
+
if (process.env.NATURAL_LANGUAGE_RESPONSE !== undefined) {
|
|
49
|
+
const val = process.env.NATURAL_LANGUAGE_RESPONSE.trim().toLowerCase();
|
|
50
|
+
return val === "true" || val === "1";
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Translates a natural language prompt directly into a validated multi-level Prisma query.
|
|
18
56
|
*/
|
|
19
57
|
async generateSafeQuery(userPrompt, config) {
|
|
20
58
|
const GeminiQuerySchema = {
|
|
@@ -22,15 +60,21 @@ class PrismaGeminiAgent {
|
|
|
22
60
|
properties: {
|
|
23
61
|
targetTable: {
|
|
24
62
|
type: genai_1.Type.STRING,
|
|
25
|
-
description: "The
|
|
63
|
+
description: "The primary database model to query in camelCase (e.g., 'subscription', 'user')",
|
|
64
|
+
},
|
|
65
|
+
logicalOperator: {
|
|
66
|
+
type: genai_1.Type.STRING,
|
|
67
|
+
enum: ["AND", "OR"],
|
|
26
68
|
},
|
|
27
|
-
logicalOperator: { type: genai_1.Type.STRING, enum: ["AND", "OR"] },
|
|
28
69
|
filters: {
|
|
29
70
|
type: genai_1.Type.ARRAY,
|
|
30
71
|
items: {
|
|
31
72
|
type: genai_1.Type.OBJECT,
|
|
32
73
|
properties: {
|
|
33
|
-
field: {
|
|
74
|
+
field: {
|
|
75
|
+
type: genai_1.Type.STRING,
|
|
76
|
+
description: "Field name or relational dot-path (e.g., 'status', 'user.email')",
|
|
77
|
+
},
|
|
34
78
|
operator: {
|
|
35
79
|
type: genai_1.Type.STRING,
|
|
36
80
|
enum: [
|
|
@@ -45,19 +89,65 @@ class PrismaGeminiAgent {
|
|
|
45
89
|
"contains",
|
|
46
90
|
"startsWith",
|
|
47
91
|
"endsWith",
|
|
92
|
+
"some",
|
|
93
|
+
"every",
|
|
94
|
+
"none",
|
|
95
|
+
"is",
|
|
96
|
+
"isNot",
|
|
48
97
|
],
|
|
49
98
|
},
|
|
50
|
-
value: {
|
|
99
|
+
value: {
|
|
100
|
+
type: genai_1.Type.STRING,
|
|
101
|
+
description: "Filter value as string, number, boolean, or JSON array",
|
|
102
|
+
},
|
|
51
103
|
},
|
|
52
104
|
required: ["field", "operator", "value"],
|
|
53
105
|
},
|
|
54
106
|
},
|
|
107
|
+
include: {
|
|
108
|
+
type: genai_1.Type.STRING,
|
|
109
|
+
nullable: true,
|
|
110
|
+
description: 'JSON string representing nested Prisma relations to include across multiple levels. Example: \'{"user":{"include":{"loginAttempts":{"take":1,"orderBy":{"createdAt":"desc"}}}}}\'. Do NOT use both include and select at the root level.',
|
|
111
|
+
},
|
|
112
|
+
select: {
|
|
113
|
+
type: genai_1.Type.STRING,
|
|
114
|
+
nullable: true,
|
|
115
|
+
description: "JSON string for selecting specific fields. Leave null if using include.",
|
|
116
|
+
},
|
|
117
|
+
orderBy: {
|
|
118
|
+
type: genai_1.Type.STRING,
|
|
119
|
+
nullable: true,
|
|
120
|
+
description: 'JSON string for sorting, e.g. \'{"createdAt":"desc"}\'',
|
|
121
|
+
},
|
|
122
|
+
take: {
|
|
123
|
+
type: genai_1.Type.INTEGER,
|
|
124
|
+
nullable: true,
|
|
125
|
+
description: "Limit number of records returned. Must be a positive integer (e.g. 10). Omit or set null if not needed.",
|
|
126
|
+
},
|
|
127
|
+
skip: {
|
|
128
|
+
type: genai_1.Type.INTEGER,
|
|
129
|
+
nullable: true,
|
|
130
|
+
description: "Pagination offset. Non-negative integer.",
|
|
131
|
+
},
|
|
55
132
|
},
|
|
56
133
|
required: ["targetTable", "logicalOperator", "filters"],
|
|
57
134
|
};
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
135
|
+
const resolvedContext = this.resolveDbContext(config);
|
|
136
|
+
const baseRules = `You are an expert Prisma ORM multi-level query planner. Translate the user's natural language prompt into a valid Prisma findMany query object.
|
|
137
|
+
|
|
138
|
+
MULTI-LEVEL RELATIONAL QUERY RULES:
|
|
139
|
+
1. Identify the primary table to query as 'targetTable' in camelCase (e.g. "subscription").
|
|
140
|
+
2. When the user asks for information located in linked/related tables (for example: asking for inactive subscribers in 'subscription', their full name in 'user', and their last login attempt in 'loginAttempts'), you MUST traverse the relations using a nested 'include' JSON object.
|
|
141
|
+
3. For "last", "latest", or "most recent" child records on a 1-to-many relation (e.g. loginAttempts), nest '{"take": 1, "orderBy": {"createdAt": "desc"}}' inside that relation's include block.
|
|
142
|
+
Example for "inactive subscribers, their full names, and their last login attempt":
|
|
143
|
+
- targetTable: "subscription"
|
|
144
|
+
- filters: [{ "field": "status", "operator": "equals", "value": "INACTIVE" }]
|
|
145
|
+
- include: '{"user":{"include":{"loginAttempts":{"take":1,"orderBy":{"createdAt":"desc"}}}}}'
|
|
146
|
+
4. Never specify both 'include' and 'select' at the same level. Prefer 'include' when joining related tables.
|
|
147
|
+
5. Match enum values to the exact casing shown in the schema if an Enum is defined.`;
|
|
148
|
+
const instruction = resolvedContext
|
|
149
|
+
? `${baseRules}\n\nDatabase Context & Models:\n${resolvedContext}`
|
|
150
|
+
: baseRules;
|
|
61
151
|
const intentResponse = await this.ai.models.generateContent({
|
|
62
152
|
model: this.model,
|
|
63
153
|
contents: [{ role: "user", parts: [{ text: userPrompt }] }],
|
|
@@ -71,8 +161,242 @@ class PrismaGeminiAgent {
|
|
|
71
161
|
if (!intentResponse.text) {
|
|
72
162
|
throw new Error("Gemini failed to generate a response.");
|
|
73
163
|
}
|
|
74
|
-
|
|
75
|
-
|
|
164
|
+
const finalConfig = {
|
|
165
|
+
...config,
|
|
166
|
+
schemaContext: config?.schemaContext || this.cachedSchema,
|
|
167
|
+
};
|
|
168
|
+
return engine_1.PrismaAIEngine.parseDynamicQuery(intentResponse.text, finalConfig);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Summarizes the raw database records into a conversational, natural language answer.
|
|
172
|
+
*/
|
|
173
|
+
async formatAnswer(userPrompt, databaseResults) {
|
|
174
|
+
const prompt = `You are a helpful database assistant.
|
|
175
|
+
The user asked: "${userPrompt}"
|
|
176
|
+
|
|
177
|
+
The database returned the following records:
|
|
178
|
+
${JSON.stringify(databaseResults, null, 2)}
|
|
179
|
+
|
|
180
|
+
Provide a clear, natural language answer directly addressing the user's question in a professional, conversational tone. Be concise and accurate based strictly on the returned data. If no records were found, explain that politely.`;
|
|
181
|
+
const response = await this.ai.models.generateContent({
|
|
182
|
+
model: this.model,
|
|
183
|
+
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
184
|
+
config: {
|
|
185
|
+
temperature: 0.2,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
return response.text?.trim() || "";
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolves the exact delegate key on the PrismaClient instance (handles PascalCase vs camelCase).
|
|
192
|
+
*/
|
|
193
|
+
resolveClientDelegateKey(targetTable, prismaClient) {
|
|
194
|
+
if (!prismaClient || typeof prismaClient !== "object")
|
|
195
|
+
return null;
|
|
196
|
+
if (targetTable in prismaClient)
|
|
197
|
+
return targetTable;
|
|
198
|
+
const camel = targetTable.charAt(0).toLowerCase() + targetTable.slice(1);
|
|
199
|
+
if (camel in prismaClient)
|
|
200
|
+
return camel;
|
|
201
|
+
const lower = targetTable.toLowerCase();
|
|
202
|
+
for (const key of Object.keys(prismaClient)) {
|
|
203
|
+
if (key.toLowerCase() === lower || key.toLowerCase() === `${lower}s`) {
|
|
204
|
+
return key;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Fallback multi-level foreign-key stitcher:
|
|
211
|
+
* If returned rows contain foreign key columns (e.g. `userId`) and the prompt mentions related entities
|
|
212
|
+
* that weren't already populated via Prisma `include`, automatically fetches the linked rows (and their 2nd-level children).
|
|
213
|
+
*/
|
|
214
|
+
async stitchLinkedRelations(rows, userPrompt, prismaClient, rootTable, config) {
|
|
215
|
+
if (!Array.isArray(rows) || rows.length === 0 || !prismaClient) {
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
const promptLower = userPrompt.toLowerCase();
|
|
219
|
+
const firstRow = rows[0];
|
|
220
|
+
if (!firstRow || typeof firstRow !== "object")
|
|
221
|
+
return rows;
|
|
222
|
+
// Detect foreign keys like `userId`, `customerId`, `accountId`
|
|
223
|
+
const fkColumns = Object.keys(firstRow).filter((k) => k.endsWith("Id") && k.length > 2);
|
|
224
|
+
for (const fkCol of fkColumns) {
|
|
225
|
+
const relationName = fkCol.slice(0, -2); // e.g., 'userId' -> 'user'
|
|
226
|
+
// Skip if already included by Prisma
|
|
227
|
+
if (firstRow[relationName] !== undefined)
|
|
228
|
+
continue;
|
|
229
|
+
const delegateKey = this.resolveClientDelegateKey(relationName, prismaClient);
|
|
230
|
+
if (!delegateKey ||
|
|
231
|
+
typeof prismaClient[delegateKey]?.findMany !== "function")
|
|
232
|
+
continue;
|
|
233
|
+
// Check if this table is blocked
|
|
234
|
+
try {
|
|
235
|
+
engine_1.PrismaAIEngine["validatePath"](delegateKey, config, "table");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const fkIds = Array.from(new Set(rows
|
|
241
|
+
.map((r) => r[fkCol])
|
|
242
|
+
.filter((v) => v !== null && v !== undefined)));
|
|
243
|
+
if (fkIds.length === 0)
|
|
244
|
+
continue;
|
|
245
|
+
const parentRecords = await prismaClient[delegateKey].findMany({
|
|
246
|
+
where: { id: { in: fkIds } },
|
|
247
|
+
});
|
|
248
|
+
// Check if prompt asks for a 2nd-level child table linked by the same foreign key (e.g. loginAttempts via userId)
|
|
249
|
+
for (const clientKey of Object.keys(prismaClient)) {
|
|
250
|
+
if (clientKey.startsWith("_") ||
|
|
251
|
+
clientKey.startsWith("$") ||
|
|
252
|
+
clientKey === delegateKey ||
|
|
253
|
+
clientKey.toLowerCase() === rootTable.toLowerCase()) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (typeof prismaClient[clientKey]?.findMany !== "function")
|
|
257
|
+
continue;
|
|
258
|
+
// Match if prompt mentions this child table (e.g. "login attempt" -> "loginAttempt" / "loginAttempts")
|
|
259
|
+
const readableKey = clientKey
|
|
260
|
+
.replace(/([A-Z])/g, " $1")
|
|
261
|
+
.toLowerCase()
|
|
262
|
+
.trim();
|
|
263
|
+
const singularReadable = readableKey.endsWith("s")
|
|
264
|
+
? readableKey.slice(0, -1)
|
|
265
|
+
: readableKey;
|
|
266
|
+
if (promptLower.includes(readableKey) ||
|
|
267
|
+
promptLower.includes(singularReadable)) {
|
|
268
|
+
try {
|
|
269
|
+
engine_1.PrismaAIEngine["validatePath"](clientKey, config, "table");
|
|
270
|
+
const childRecords = await prismaClient[clientKey].findMany({
|
|
271
|
+
where: { [fkCol]: { in: fkIds } },
|
|
272
|
+
orderBy: { createdAt: "desc" },
|
|
273
|
+
});
|
|
274
|
+
const wantsLastOnly = /\b(last|latest|most recent)\b/i.test(userPrompt);
|
|
275
|
+
const childrenByFk = new Map();
|
|
276
|
+
for (const child of childRecords) {
|
|
277
|
+
const keyVal = child[fkCol];
|
|
278
|
+
if (!childrenByFk.has(keyVal))
|
|
279
|
+
childrenByFk.set(keyVal, []);
|
|
280
|
+
const list = childrenByFk.get(keyVal);
|
|
281
|
+
if (!wantsLastOnly || list.length < 1) {
|
|
282
|
+
list.push(child);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const pluralField = clientKey.endsWith("s")
|
|
286
|
+
? clientKey
|
|
287
|
+
: `${clientKey}s`;
|
|
288
|
+
for (const parent of parentRecords) {
|
|
289
|
+
parent[pluralField] = childrenByFk.get(parent.id) || [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Ignore if child table doesn't have fkCol or createdAt
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const parentById = new Map(parentRecords.map((p) => [p.id, p]));
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
row[relationName] = parentById.get(row[fkCol]) || null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return rows;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* End-to-end execution helper:
|
|
306
|
+
* 1. Introspects schema from prismaClient/schema.prisma and generates the safe multi-level Prisma query
|
|
307
|
+
* 2. Executes it on the provided prisma client (and auto-stitches foreign keys if needed)
|
|
308
|
+
* 3. Recursively strips any blocked fields
|
|
309
|
+
* 4. Optionally generates a natural language summary if enabled (defaults to true)
|
|
310
|
+
*/
|
|
311
|
+
async execute(userPrompt, prismaClient, config) {
|
|
312
|
+
const mergedConfig = {
|
|
313
|
+
...config,
|
|
314
|
+
prismaClient: config?.prismaClient || prismaClient,
|
|
315
|
+
};
|
|
316
|
+
const query = await this.generateSafeQuery(userPrompt, mergedConfig);
|
|
317
|
+
const delegateKey = this.resolveClientDelegateKey(query.targetTable, prismaClient);
|
|
318
|
+
if (!delegateKey ||
|
|
319
|
+
typeof prismaClient[delegateKey]?.findMany !== "function") {
|
|
320
|
+
throw new Error(`Invalid or unauthorized table target: ${query.targetTable}`);
|
|
321
|
+
}
|
|
322
|
+
// Normalize targetTable to the actual client delegate key
|
|
323
|
+
query.targetTable = delegateKey;
|
|
324
|
+
const findArgs = {
|
|
325
|
+
where: query.where,
|
|
326
|
+
};
|
|
327
|
+
if (query.select && Object.keys(query.select).length > 0) {
|
|
328
|
+
findArgs.select = query.select;
|
|
329
|
+
}
|
|
330
|
+
else if (query.include && Object.keys(query.include).length > 0) {
|
|
331
|
+
findArgs.include = query.include;
|
|
332
|
+
}
|
|
333
|
+
if (query.orderBy)
|
|
334
|
+
findArgs.orderBy = query.orderBy;
|
|
335
|
+
if (typeof query.take === "number")
|
|
336
|
+
findArgs.take = query.take;
|
|
337
|
+
if (typeof query.skip === "number")
|
|
338
|
+
findArgs.skip = query.skip;
|
|
339
|
+
// 1. Build an automatic select clause if neither select nor include was provided by the AI,
|
|
340
|
+
// explicitly omitting all blocked fields from the database query itself
|
|
341
|
+
if (!findArgs.select && !findArgs.include) {
|
|
342
|
+
const lowerTarget = query.targetTable.toLowerCase();
|
|
343
|
+
const model = this.cachedSchema?.models.find((m) => m.name.toLowerCase() === lowerTarget);
|
|
344
|
+
if (model &&
|
|
345
|
+
(mergedConfig?.blockList?.length || mergedConfig?.allowList?.length)) {
|
|
346
|
+
const autoSelect = {};
|
|
347
|
+
for (const field of model.fields) {
|
|
348
|
+
if (field.relation)
|
|
349
|
+
continue;
|
|
350
|
+
let isBlocked = false;
|
|
351
|
+
if (mergedConfig.blockList?.length) {
|
|
352
|
+
isBlocked = mergedConfig.blockList.some((b) => {
|
|
353
|
+
const rule = b.trim();
|
|
354
|
+
return (field.name === rule ||
|
|
355
|
+
field.name.toLowerCase() === rule.toLowerCase() ||
|
|
356
|
+
rule.toLowerCase() ===
|
|
357
|
+
`${lowerTarget}.${field.name.toLowerCase()}`);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
let isAllowed = true;
|
|
361
|
+
if (mergedConfig.allowList?.length) {
|
|
362
|
+
isAllowed = mergedConfig.allowList.some((a) => {
|
|
363
|
+
const rule = a.trim();
|
|
364
|
+
return (field.name === rule ||
|
|
365
|
+
rule.toLowerCase() ===
|
|
366
|
+
`${lowerTarget}.${field.name.toLowerCase()}` ||
|
|
367
|
+
rule.toLowerCase() === lowerTarget);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (!isBlocked && isAllowed) {
|
|
371
|
+
autoSelect[field.name] = true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (Object.keys(autoSelect).length > 0) {
|
|
375
|
+
findArgs.select = autoSelect;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
let rawData = await prismaClient[delegateKey].findMany(findArgs);
|
|
380
|
+
// 2. If the query did not include nested relations but returned foreign key IDs (like userId),
|
|
381
|
+
// automatically stitch linked tables when requested by the prompt
|
|
382
|
+
if (!findArgs.include && Array.isArray(rawData) && rawData.length > 0) {
|
|
383
|
+
rawData = await this.stitchLinkedRelations(rawData, userPrompt, prismaClient, delegateKey, mergedConfig);
|
|
384
|
+
}
|
|
385
|
+
// 3. Post-execution deep scrub: recursively strip any blocked fields from the returned data
|
|
386
|
+
const data = engine_1.PrismaAIEngine.filterBlockedData(rawData, mergedConfig, "", delegateKey);
|
|
387
|
+
const isNL = this.isNaturalLanguageEnabled(mergedConfig);
|
|
388
|
+
if (!isNL) {
|
|
389
|
+
return {
|
|
390
|
+
data,
|
|
391
|
+
query,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const naturalLanguage = await this.formatAnswer(userPrompt, data);
|
|
395
|
+
return {
|
|
396
|
+
data,
|
|
397
|
+
naturalLanguage,
|
|
398
|
+
query,
|
|
399
|
+
};
|
|
76
400
|
}
|
|
77
401
|
}
|
|
78
402
|
exports.PrismaGeminiAgent = PrismaGeminiAgent;
|
package/dist/engine.d.ts
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
|
+
import { ExtractedSchema } from "./introspect";
|
|
1
2
|
export interface SecurityConfig {
|
|
2
|
-
/** Explicitly allow only these fields. If provided,
|
|
3
|
+
/** Explicitly allow only these fields or relation paths. If provided, anything outside is blocked. */
|
|
3
4
|
allowList?: string[];
|
|
4
|
-
/** Explicitly block these fields or tables (supports exact match or dot-notation prefixes like 'user.password'). */
|
|
5
|
+
/** Explicitly block these fields, relations, or tables (supports exact match or dot-notation prefixes like 'user.password'). */
|
|
5
6
|
blockList?: string[];
|
|
7
|
+
/** Optional extracted schema used to strongly cast filter values to their actual Prisma column types (Int, Float, Boolean, Enum, etc.) */
|
|
8
|
+
schemaContext?: ExtractedSchema;
|
|
9
|
+
}
|
|
10
|
+
export interface ParsedDynamicQuery {
|
|
11
|
+
targetTable: string;
|
|
12
|
+
where: Record<string, any>;
|
|
13
|
+
include?: Record<string, any>;
|
|
14
|
+
select?: Record<string, any>;
|
|
15
|
+
orderBy?: any;
|
|
16
|
+
take?: number;
|
|
17
|
+
skip?: number;
|
|
6
18
|
}
|
|
7
19
|
export declare class PrismaAIEngine {
|
|
8
20
|
private static matchesSecurityRule;
|
|
21
|
+
private static validatePath;
|
|
22
|
+
private static coerceScalarValue;
|
|
9
23
|
private static normalizeFilterValue;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Recursively strips blocked fields from returned database records or objects.
|
|
26
|
+
*/
|
|
27
|
+
static filterBlockedData<T = any>(data: T, config?: SecurityConfig, currentPath?: string, rootTable?: string): T;
|
|
28
|
+
private static sanitizeTree;
|
|
29
|
+
private static sanitizeOrderBy;
|
|
30
|
+
private static findModel;
|
|
31
|
+
/**
|
|
32
|
+
* Expands dot-notation field paths (e.g. "user.profile.age" or "user.loginAttempts.success")
|
|
33
|
+
* into nested Prisma filter objects, automatically wrapping list relations with `some`.
|
|
34
|
+
*/
|
|
35
|
+
private static buildNestedCondition;
|
|
36
|
+
/**
|
|
37
|
+
* Resolves a field's type across both direct columns and multi-level dot-notation paths.
|
|
38
|
+
*/
|
|
39
|
+
private static lookupFieldType;
|
|
40
|
+
static parseDynamicQuery(rawAiOutput: unknown, config?: SecurityConfig): ParsedDynamicQuery;
|
|
14
41
|
}
|