@mlagie/sql-connector 2.1.3 → 2.2.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  ![GitHub package.json version](https://img.shields.io/github/package-json/v/lagie-marin/sql-connector?color=#008000) ![NPM Downloads](https://img.shields.io/npm/d18m/%40mlagie%2Fsql-connector?color=#008000) ![NPM Downloads](https://img.shields.io/npm/dw/%40mlagie%2Fsql-connector?color=#008000) ![GitHub followers](https://img.shields.io/github/followers/lagie-marin?style=plastic&color=color%3D%23008000) ![GitHub repo size](https://img.shields.io/github/repo-size/lagie-marin/sql-connector?color=%green)
4
4
  ![GitHub last commit](https://img.shields.io/github/last-commit/lagie-marin/sql-connector)
5
5
 
6
- [Français](./docs/fr/README.md) | English
6
+ [Français](./docs/fr/README_FR.md) | English
7
7
 
8
8
  sql-connector helps manage MySQL connections, define table schemas, sync tables automatically, and work with database models through a small API.
9
9
 
@@ -186,7 +186,7 @@ Retrieves entries from the table.
186
186
  - **Parameters** `options` *(Object)* - Query options.
187
187
  - **Parameters** `options.select` *(Array<string|SelectAggregation>)* - Fields, aggregations, or transformations to be returned.
188
188
  - **Parameters** `options.where` *(Object / string)* - Filtering conditions (key/value object or raw string condition).
189
- - **Parameters** `options.groupBy` *(string[])* - Fields used to group results.
189
+ - **Parameters** `options.groupBy` *(Array<string|GroupAggregation>)* - Fields, columns, or expressions used to group results.
190
190
  - **Parameters** `options.orderBy` *(Array<string|OrderByOption>)* - Sorting rules.
191
191
  - **Parameters** `options.join` *(JoinOption / JoinOption[])* - Table join configuration structures.
192
192
  - **Parameters** `options.limit` *(number)* - Maximum number of results to return.
@@ -207,6 +207,16 @@ Each element in the `select` array can be either a standard string (raw column n
207
207
  | `count` (Object) | `Object` | Automated conditional aggregation (`CASE WHEN`). Perfect for KPIs and status metrics. | `{ count: { deletedAt: null } }` $\rightarrow$ `COUNT(CASE WHEN` \`deletedAt\``= NULL THEN 1 END)` |
208
208
  | `as` | `string` | Sets a custom output identifier or aggregation alias (SQL `AS`). | `{ count: 'id', as: 'total' }` $\rightarrow$ `COUNT(` \`id\``) AS` \`total\` |
209
209
 
210
+ ### Advanced `groupBy` Options (`GroupAggregation`)
211
+
212
+ The `groupBy` option accepts an array that can mix standard strings (raw column names) and advanced configuration objects for secure and parameterized grouping:
213
+
214
+ | object | Type | Description | Example / Generated SQL |
215
+ |--------------|--------------------|---------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|
216
+ | `col` | `string` | Safely targets an unaggregated table column using structural object isolation. | { col: 'role' } -> `role` |
217
+ | `dateFormat` | `[string, string]` | Securely groups by a formatted Date column using parameterized inputs (Format: [column, format]). | { dateFormat: ['created_at', '%Y-%m'] } -> DATE_FORMAT( `created_at`, '%Y-%m') |
218
+ | `as` | `string` | Targets the specified select alias directly to comply with strict MySQL group structures. | `{ col: 'role', as: 'user_role' }` ->\`user_role\` |
219
+
210
220
  ---
211
221
 
212
222
  ### Complex Structured Options (`orderBy` & `join`)
@@ -240,7 +250,7 @@ User.find({
240
250
  { sum: 'error' },
241
251
  { sum: 'reload' },
242
252
  ],
243
- groupBy: ['period'],
253
+ groupBy: ['period', { dateFormat: ['date_day', '%Y-%m'] }],
244
254
  orderBy: [{ field: 'period', direction: 'ASC' }],
245
255
  limit: 10
246
256
  });
@@ -296,7 +306,7 @@ const ppiStats = await MyTable.find({
296
306
  });
297
307
  ```
298
308
 
299
- #### 3. Table Joins, Time Series Grouping, and Multi-Column Sorting
309
+ #### 3. Table Joins, Mixed-Type Time Series Grouping, and Multi-Column Sorting
300
310
 
301
311
  An advanced query orchestration combining left table joining, date formatting conversions, and sorting:
302
312
 
@@ -309,13 +319,18 @@ const history = await MyTable.find({
309
319
  { dateFormat: ['ProjectPipelines.created_at', '%Y-%m'], as: 'period' },
310
320
  { count: 'ProjectPipelines.id', as: 'pipelines_count' }
311
321
  ],
312
- where: "ProjectPipelines.deletedAt IS NULL", // Raw condition strings are permitted
322
+ where: {
323
+ status: 'ACTIVE'
324
+ },
313
325
  join: {
314
326
  table: 'Projects',
315
327
  on: 'ProjectPipelines.project_id = Projects.id',
316
328
  type: 'LEFT'
317
329
  },
318
- groupBy: ['project_name', 'period'],
330
+ groupBy: [
331
+ 'project_name',
332
+ { dateFormat: ['ProjectPipelines.created_at', '%Y-%m'] }
333
+ ],
319
334
  orderBy: [
320
335
  { field: 'period', direction: 'DESC' },
321
336
  { field: 'project_name', direction: 'ASC' }
@@ -414,7 +429,7 @@ const User = require("user");
414
429
  const uuid = await User.generate_uuid();
415
430
  const my_uuid = await User.generate_uuid("my_uuid");
416
431
 
417
- await User.save{ email: "user@example.com", status: "active", uuid: uuid, my_uuid: my_uuid }
432
+ await User.save({ email: "user@example.com", status: "active", uuid: uuid, my_uuid: my_uuid })
418
433
  ```
419
434
 
420
435
  ## Model instances
@@ -156,7 +156,7 @@ Récupère des enregistrements de la table.
156
156
  - **Paramètres** `options` *(Object)* – Options de la requête.
157
157
  - **Paramètres** `options.select` *(Array<string|SelectAggregation>)* – Champs, agrégations ou transformations à retourner.
158
158
  - **Paramètres** `options.where` *(Object / string)* – Conditions de filtrage (objet clé/valeur ou clause brute sous forme de chaîne).
159
- - **Paramètres** `options.groupBy` *(string[])* – Champs utilisés pour grouper les résultats.
159
+ - **Paramètres** `options.groupBy` *(Array<string|GroupAggregation>)* – Champs, colonnes ou expressions utilisés pour regrouper les résultats.
160
160
  - **Paramètres** `options.orderBy` *(Array<string|OrderByOption>)* – Règles de tri.
161
161
  - **Paramètres** `options.join` *(JoinOption / JoinOption[])* – Structures de configuration pour les jointures de tables.
162
162
  - **Paramètres** `options.limit` *(number)* – Nombre maximal de résultats à retourner.
@@ -177,6 +177,16 @@ Chaque élément du tableau `select` peut être soit une chaîne de caractères
177
177
  | `count` (Object) | `Object` | Agrégation conditionnelle automatisée (`CASE WHEN`). Idéal pour les indicateurs clés (KPIs) et statuts. | `{ count: { deletedAt: null } }` $\rightarrow$ `COUNT(CASE WHEN `\`deletedAt\`` = NULL THEN 1 END)` |
178
178
  | `as` | `string` | Définit un identifiant de sortie personnalisé ou un alias d'agrégation (SQL `AS`). | `{ count: 'id', as: 'total' }` $\rightarrow$ `COUNT(`\`id\``) AS `\`total\` |
179
179
 
180
+ ### Options Avancées de `groupBy` (`GroupAggregation`)
181
+
182
+ L'option `groupBy` accepte un tableau pouvant mélanger des chaînes de caractères simples (noms de colonnes bruts) et des objets de configuration avancés pour un regroupement sécurisé, paramétré et la gestion des alias :
183
+
184
+ | Propriété dans l'objet `groupBy` | Type | Description | Exemple / SQL Généré |
185
+ |----------------------------------|-------------------|----------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------|
186
+ | `col` | `string` | Cible de manière sécurisée une colonne de table non agrégée grâce à l'isolation structurelle de l'objet. | `{ col: 'role' }` → \`role\` |
187
+ | `dateFormat` | `[string, string]`| Regroupe de manière sécurisée par une colonne Date formatée à l'aide d'entrées paramétrées (Format : `[colonne, format]`). | `{ dateFormat: ['created_at', '%Y-%m'] }` → `DATE_FORMAT(` \`created_at\``, '%Y-%m')` |
188
+ | `as` | `string` | Cible directement l'alias défini dans le select pour respecter les contraintes strictes de syntaxe MySQL. | `{ col: 'role', as: 'user_role' }` → \`user_role\` |
189
+
180
190
  ---
181
191
 
182
192
  ### Options complexes structurées (`orderBy` & `join`)
@@ -210,7 +220,7 @@ await User.find({
210
220
  { sum: 'error' },
211
221
  { sum: 'reload' },
212
222
  ],
213
- groupBy: ['period'],
223
+ groupBy: ['period', { dateFormat: ['date_day', '%Y-%m'] }],
214
224
  orderBy: [{ field: 'period', direction: 'ASC' }],
215
225
  limit: 10
216
226
  });
@@ -264,9 +274,7 @@ const ppiStats = await ProjectPipeline.find({
264
274
  team: 'GROUP-1'
265
275
  }
266
276
  });
267
-
268
- // Format du tableau de sortie retourné : [{ active: 6, total_success: 42 }]
269
- ```
277
+ ```
270
278
 
271
279
  #### 3. Jointures de tables, groupement temporel et tri multi-colonnes
272
280
 
@@ -281,13 +289,18 @@ const history = await ProjectPipeline.find({
281
289
  { dateFormat: ['MyTable.created_at', '%Y-%m'], as: 'period' },
282
290
  { count: 'MyTable.id', as: 'pipelines_count' }
283
291
  ],
284
- where: "MyTable.deletedAt IS NULL", // Les chaînes de conditions brutes sont autorisées
292
+ where: {
293
+ status: 'ACTIVE'
294
+ },
285
295
  join: {
286
296
  table: 'Projects',
287
297
  on: 'MyTable.project_id = Projects.id',
288
298
  type: 'LEFT'
289
299
  },
290
- groupBy: ['project_name', 'period'],
300
+ groupBy: [
301
+ 'project_name',
302
+ { dateFormat: ['ProjectPipelines.created_at', '%Y-%m'] }
303
+ ],
291
304
  orderBy: [
292
305
  { field: 'period', direction: 'DESC' },
293
306
  { field: 'project_name', direction: 'ASC' }
@@ -389,7 +402,7 @@ const User = require("user");
389
402
  const uuid = await User.generate_uuid();
390
403
  const my_uuid = await User.generate_uuid("my_uuid");
391
404
 
392
- await User.save{ email: "user@example.com", status: "active", uuid: uuid, my_uuid: my_uuid }
405
+ await User.save({ email: "user@example.com", status: "active", uuid: uuid, my_uuid: my_uuid })
393
406
  ```
394
407
 
395
408
  ## Instances de modèle
package/index.d.ts CHANGED
@@ -11,7 +11,19 @@ export type SqlType =
11
11
  | "Float"
12
12
  | "Text"
13
13
  | "DateTime"
14
- | "Timestamp";
14
+ | "Timestamp"
15
+ | "CurrentTimestamp";
16
+
17
+ export interface SqlTypeMap {
18
+ String: string;
19
+ Number: string;
20
+ Date: string;
21
+ CurrentTimestamp: string; // Ajout de la propriété demandée
22
+ [key: string]: any; // Permet d'accueillir d'autres types SQL généraux
23
+ }
24
+
25
+ export const sqlTypeMap: SqlTypeMap;
26
+
15
27
  export type SqlTypeConstructor =
16
28
  | StringConstructor
17
29
  | NumberConstructor
@@ -39,39 +51,76 @@ export interface SchemaDict {
39
51
  [key: string]: SchemaField;
40
52
  }
41
53
 
54
+ export type SelectAggregation = {
55
+ col?: string;
56
+ sum?: string;
57
+ distinct?: string;
58
+ dateFormat?: [string, string];
59
+ count?: string | string[] | Record<string, any>;
60
+ as?: string;
61
+ };
62
+
63
+ export type SelectItem = string | SelectAggregation;
64
+
65
+ export type WhereOperator =
66
+ | "="
67
+ | "!="
68
+ | ">"
69
+ | "<"
70
+ | ">="
71
+ | "<="
72
+ | "LIKE"
73
+ | "IN"
74
+ | "NOT IN";
75
+
76
+ export type WhereOperatorObject = {
77
+ [K in WhereOperator]?: any;
78
+ };
79
+
80
+ export interface WhereClause {
81
+ AND?: WhereClause[];
82
+ OR?: WhereClause[];
83
+
84
+ [key: string]:
85
+ | any
86
+ | WhereClause[]
87
+ | WhereOperatorObject
88
+ | undefined;
89
+ }
90
+
42
91
  type NormalizeSqlType<T> =
43
92
  T extends StringConstructor
44
- ? "String"
45
- : T extends NumberConstructor
46
- ? "Number"
47
- : T extends BooleanConstructor
48
- ? "Boolean"
49
- : T extends DateConstructor
50
- ? "Date"
51
- : T extends ObjectConstructor
52
- ? "Object"
53
- : T extends ArrayConstructor
54
- ? "Array"
55
- : T extends SqlType
56
- ? T
57
- : T extends { name: SqlType }
58
- ? T["name"]
59
- : never;
93
+ ? "String"
94
+ : T extends NumberConstructor
95
+ ? "Number"
96
+ : T extends BooleanConstructor
97
+ ? "Boolean"
98
+ : T extends DateConstructor
99
+ ? "Date"
100
+ : T extends ObjectConstructor
101
+ ? "Object"
102
+ : T extends ArrayConstructor
103
+ ? "Array"
104
+ : T extends SqlType
105
+ ? T
106
+ : T extends { name: SqlType }
107
+ ? T["name"]
108
+ : never;
60
109
 
61
110
  type InferSqlType<T> =
62
111
  NormalizeSqlType<T> extends "String" | "Text"
63
- ? string
64
- : NormalizeSqlType<T> extends "Number" | "Float"
65
- ? number
66
- : NormalizeSqlType<T> extends "Boolean"
67
- ? boolean
68
- : NormalizeSqlType<T> extends "Date" | "DateTime" | "Timestamp" | "Now"
69
- ? Date
70
- : NormalizeSqlType<T> extends "Object"
71
- ? Record<string, unknown>
72
- : NormalizeSqlType<T> extends "Array"
73
- ? unknown[]
74
- : unknown;
112
+ ? string
113
+ : NormalizeSqlType<T> extends "Number" | "Float"
114
+ ? number
115
+ : NormalizeSqlType<T> extends "Boolean"
116
+ ? boolean
117
+ : NormalizeSqlType<T> extends "Date" | "DateTime" | "Timestamp" | "Now"
118
+ ? Date
119
+ : NormalizeSqlType<T> extends "Object"
120
+ ? Record<string, unknown>
121
+ : NormalizeSqlType<T> extends "Array"
122
+ ? unknown[]
123
+ : unknown;
75
124
 
76
125
  type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
77
126
 
@@ -80,21 +129,21 @@ type InferFieldValue<TField> =
80
129
 
81
130
  type InferFieldNullable<TField> =
82
131
  TField extends { required: true }
83
- ? false
84
- : TField extends { primary_key: true }
85
- ? false
86
- : TField extends { auto_increment: true }
87
- ? false
88
- : TField extends { default: null }
89
- ? true
90
- : HasKey<TField, "default"> extends true
91
- ? false
92
- : true;
132
+ ? false
133
+ : TField extends { primary_key: true }
134
+ ? false
135
+ : TField extends { auto_increment: true }
136
+ ? false
137
+ : TField extends { default: null }
138
+ ? true
139
+ : HasKey<TField, "default"> extends true
140
+ ? false
141
+ : true;
93
142
 
94
143
  export type InferSchema<TSchema extends SchemaDict> = {
95
144
  [K in keyof TSchema]: InferFieldNullable<TSchema[K]> extends true
96
- ? InferFieldValue<TSchema[K]> | null
97
- : InferFieldValue<TSchema[K]>;
145
+ ? InferFieldValue<TSchema[K]> | null
146
+ : InferFieldValue<TSchema[K]>;
98
147
  };
99
148
 
100
149
  export type SchemaLike<TSchema extends SchemaDict = SchemaDict> =
@@ -193,8 +242,8 @@ export class Model<TSchema extends SchemaDict = SchemaDict> {
193
242
  * @returns {Promise<Array<Object>>}
194
243
  */
195
244
  find(options?: {
196
- select?: string[];
197
- where?: Record<string, any> | Partial<InferSchema<TSchema>> | string;
245
+ select?: SelectItem[];
246
+ where?: WhereClause
198
247
  order?: [string, string][];
199
248
  limit?: number;
200
249
  }): Promise<Array<ModelRecord<InferSchema<TSchema>>>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlagie/sql-connector",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "The sql-connector module allows you to manage connections to a MySQL database, define table schemas, and interact with data in a simple and efficient way.",
5
5
  "main": "index.js",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "test:publish": "npm publish --dry-run",
22
22
  "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch",
23
23
  "test:coverage": "rm -r coverage ; node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
24
- "coverage:report": "node scripts/coverage-report.js",
24
+ "coverage:report": "./scripts/coverage-report.sh",
25
25
  "security": "npx eslint . --max-warnings 0 --ignore-pattern './tests/*'"
26
26
  },
27
27
  "repository": {
@@ -1,8 +1,6 @@
1
1
  const { logs, error } = require("@mlagie/logger");
2
2
  const { sqlTypeMap } = require("../utils/sqlTypeMap");
3
3
  const { getConnexion } = require("../db/connexion");
4
- const generateCondition = require("../utils/generateCondition");
5
- const formatObject = require("../utils/formatObject");
6
4
  const { ModelInstance } = require("./ModelInstance");
7
5
  const { buildSelect, buildQueryParts } = require("../utils/buildQuery/buildQuery");
8
6
  const { getSafe, setSafe } = require("../utils/security/safe");
@@ -52,29 +50,6 @@ function formatDefaultSql(defaultValue, fieldType) {
52
50
  return `DEFAULT "${String(defaultValue).replace(/"/g, '\\"')}"`;
53
51
  }
54
52
 
55
- const reservedKeywords = ['ADD', 'ALL', 'ALTER', 'AND', 'AS', 'ASC', 'BETWEEN', 'BY', 'CASE', 'CHECK', 'COLUMN', 'CONSTRAINT', 'CREATE', 'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP', 'DEFAULT', 'DELETE', 'DESC', 'DISTINCT', 'DROP', 'ELSE', 'END', 'ESCAPE', 'EXCEPT', 'EXISTS', 'FOR', 'FOREIGN', 'FROM', 'FULL', 'GROUP', 'HAVING', 'IN', 'INNER', 'INSERT', 'INTERSECT', 'INTO', 'IS', 'JOIN', 'LEFT', 'LIKE', 'LIMIT', 'NOT', 'NULL', 'ON', 'OR', 'ORDER', 'OUTER', 'PRIMARY', 'REFERENCES', 'RIGHT', 'SELECT', 'SET', 'SOME', 'TABLE', 'THEN', 'UNION', 'UNIQUE', 'UPDATE', 'VALUES', 'WHEN', 'WHERE'];
56
-
57
- /**
58
- * Checks if a table name is a reserved keyword.
59
- *
60
- * @param {string} tableName The name of the table to be checked.
61
- * @returns {boolean} `true` if the table name is a reserved keyword, otherwise `false`.
62
- *
63
- * @example
64
- * const isReserved = ifReservedKeywords('SELECT');
65
- * console.log(isReserved); // true
66
- *
67
- * @example
68
- * const isReserved = ifReservedKeywords('myTable');
69
- * console.log(isReserved); // false
70
- */
71
- function ifReservedKeywords(tableName) {
72
- if (reservedKeywords.includes(tableName.toUpperCase())) {
73
- return true;
74
- }
75
- return false;
76
- }
77
-
78
53
  function getColumnDefinition(fieldName, field) {
79
54
  if (field.primary_key && field.unique) {
80
55
  throw new Error(`Field '${fieldName}' cannot be both PRIMARY KEY and UNIQUE.`);
@@ -115,6 +90,8 @@ class Model {
115
90
  * @param {Object} schema The schema of the database table.
116
91
  */
117
92
  constructor(name, schema) {
93
+ if (!name || typeof name !== "string") throw new Error("Model name must be a non-empty string.");
94
+ if (!schema || typeof schema !== "object") throw new Error("Model schema must be a non-empty object.");
118
95
  this.name = name;
119
96
  this.schema = schema;
120
97
 
@@ -207,10 +184,6 @@ class Model {
207
184
 
208
185
  return `${fieldName} ${type == "VARCHAR" ? `${type}(${lengthDefault})` : type}`;
209
186
  });
210
- if (ifReservedKeywords(this.name)) {
211
- error("Error: Invalid table name. Please choose a different name that is not a reserved keyword in SQL_request");
212
- return;
213
- }
214
187
  return `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(this.name)} (${columns.join(', ')}${foreignKey.length > 0 ? ", " + foreignKey.join(', ') : ""}) ENGINE=InnoDB`;
215
188
  }
216
189
 
@@ -292,10 +265,11 @@ class Model {
292
265
  joinClause = ` INNER JOIN ${escapeIdentifier(join.table)} ON ${join.on}`;
293
266
  }
294
267
 
295
- const query = `SELECT ${buildSelect(select)} FROM ${escapeIdentifier(this.name)}${joinClause} ${buildQueryParts(options)}`;
268
+ const { sql: whereClause, values } = buildQueryParts(options);
269
+ const query = `SELECT ${buildSelect(select)} FROM ${escapeIdentifier(this.name)}${joinClause} ${whereClause}`;
296
270
 
297
271
  try {
298
- const result = await getConnexion().promise().execute(query);
272
+ const result = await getConnexion().promise().execute(query, values);
299
273
  const rows = result && Array.isArray(result) ? result[0] : result;
300
274
 
301
275
  if (!rows || rows.length === 0) return [];
@@ -314,7 +288,9 @@ class Model {
314
288
  */
315
289
  async count(filter) {
316
290
  try {
317
- const rows = await getConnexion().promise().execute(`SELECT COUNT(*) as count FROM ${escapeIdentifier(this.name)} ${filter != undefined ? `WHERE ${generateCondition(formatObject(filter))}` : ""}`);
291
+ const { sql: whereClause, values } = buildQueryParts(filter);
292
+
293
+ const rows = await getConnexion().promise().execute(`SELECT COUNT(*) as count FROM ${escapeIdentifier(this.name)} ${whereClause}`, values);
318
294
  const resultRows = rows && Array.isArray(rows) ? rows[0] : [];
319
295
 
320
296
  if (!resultRows || resultRows.length === 0) return 0;
@@ -358,9 +334,11 @@ class Model {
358
334
  * @throws {Error} Throws an error if the SQL query fails.
359
335
  */
360
336
  async delete(filter) {
361
- const sql_request = `DELETE FROM ${escapeIdentifier(this.name)} WHERE ${generateCondition(formatObject(filter))}`;
337
+ const { sql: whereClause, values } = buildQueryParts(filter);
338
+
339
+ const sql_request = `DELETE FROM ${escapeIdentifier(this.name)} WHERE ${whereClause}`;
362
340
  return new Promise((resolve, reject) => {
363
- getConnexion().promise().execute(sql_request).then((rows) => {
341
+ getConnexion().promise().execute(sql_request, values).then((rows) => {
364
342
  if (rows[0].affectedRows === 0) return resolve(0);
365
343
 
366
344
  return resolve(1);
@@ -421,7 +399,7 @@ class Model {
421
399
  return null;
422
400
  } catch (err) {
423
401
  error(`Error executing query gen_uuid: ${err}`);
424
- return null; // Retourne null en cas de plantage SQL
402
+ return null;
425
403
  }
426
404
  }
427
405
  }
@@ -1,8 +1,7 @@
1
1
  const { error } = require("@mlagie/logger");
2
2
  const { getConnexion } = require("../db/connexion");
3
- const formatObject = require("../utils/formatObject");
4
- const generateCondition = require("../utils/generateCondition");
5
3
  const { getSafe, setSafe } = require("../utils/security/safe");
4
+ const { buildQueryParts } = require("../utils/buildQuery/buildQuery");
6
5
 
7
6
  /**
8
7
  * Represents an instance of a database model.
@@ -82,12 +81,16 @@ class ModelInstance {
82
81
  * @throws {Error} Throws an error if the update fails.
83
82
  */
84
83
  async updateOne(model) {
85
- const setClause = generateCondition(formatObject(model), true);
84
+ // 1. Paramétrisation sécurisée de la clause SET
85
+ const setKeys = Object.keys(model);
86
+ if (setKeys.length === 0) return 0;
86
87
 
87
- let whereClause;
88
+ const setClause = setKeys.map(key => `\`${key}\` = ?`).join(', ');
89
+ const values = Object.values(model); // On accumule les valeurs à modifier
90
+
91
+ let targetCriteria;
88
92
  try {
89
93
  const recordsArray = this.getRecordData();
90
-
91
94
  let rawRec = Array.isArray(recordsArray) ? recordsArray[0] : recordsArray;
92
95
 
93
96
  if (typeof rawRec === 'string') {
@@ -101,38 +104,48 @@ class ModelInstance {
101
104
  if (pkKeys.length > 0) {
102
105
  const pkObj = {};
103
106
  for (const k of pkKeys) {
104
- if (rec && Object.prototype.hasOwnProperty.call(rec, k)) setSafe(pkObj, k, getSafe(rec, k));
107
+ if (rec && Object.prototype.hasOwnProperty.call(rec, k)) {
108
+ setSafe(pkObj, k, getSafe(rec, k));
109
+ }
105
110
  }
106
- if (Object.keys(pkObj).length > 0) whereClause = generateCondition(formatObject(pkObj), false, this._schema);
111
+ if (Object.keys(pkObj).length > 0) targetCriteria = pkObj;
107
112
  }
108
113
  }
109
- if (!whereClause) whereClause = generateCondition(formatObject(rec), false, this._schema);
114
+ if (!targetCriteria) targetCriteria = rec;
110
115
  } catch {
111
116
  const originalFallbackRec = this.getRecordData();
112
117
  let fallbackRec = originalFallbackRec;
113
118
  if (Array.isArray(fallbackRec)) fallbackRec = fallbackRec[0];
114
- if (typeof fallbackRec === 'string') { try { fallbackRec = JSON.parse(fallbackRec); } catch { fallbackRec = originalFallbackRec; } }
115
- whereClause = generateCondition(formatObject(fallbackRec), false, this._schema);
119
+ if (typeof fallbackRec === 'string') {
120
+ try { fallbackRec = JSON.parse(fallbackRec); } catch { fallbackRec = originalFallbackRec; }
121
+ }
122
+ targetCriteria = fallbackRec;
116
123
  }
117
124
 
118
- const sql_request = `UPDATE ${this._name} SET ${setClause} WHERE ${whereClause}`;
119
- const [result] = await getConnexion().promise().execute(sql_request).catch((err) => {
120
- error(`Error executing query updateOne: ${err}`);
121
- throw err;
122
- });
125
+ const { sql: whereClause, values: whereValues } = buildQueryParts({ where: targetCriteria });
126
+
127
+ values.push(...whereValues);
123
128
 
124
- const affected = result && (result.affectedRows !== undefined ? result.affectedRows : 0);
129
+ const sql_request = `UPDATE \`${this._name}\` SET ${setClause} ${whereClause}`;
125
130
 
126
- if (affected > 0) {
127
- const record = this.getRecordData();
128
- if (Array.isArray(this._data)) {
129
- if (this._data[0] && typeof this._data[0] === 'object') Object.assign(this._data[0], model);
130
- } else if (record && typeof record === 'object') {
131
- Object.assign(this._data, model);
131
+ try {
132
+ const [result] = await getConnexion().promise().execute(sql_request, values);
133
+ const affected = result && (result.affectedRows !== undefined ? result.affectedRows : 0);
134
+
135
+ if (affected > 0) {
136
+ const record = this.getRecordData();
137
+ if (Array.isArray(this._data)) {
138
+ if (this._data[0] && typeof this._data[0] === 'object') Object.assign(this._data[0], model);
139
+ } else if (record && typeof record === 'object') {
140
+ Object.assign(this._data, model);
141
+ }
132
142
  }
133
- }
134
143
 
135
- return affected;
144
+ return affected;
145
+ } catch (err) {
146
+ error(`Error executing query updateOne: ${err}`);
147
+ throw err;
148
+ }
136
149
  }
137
150
 
138
151
  /**
@@ -142,9 +155,11 @@ class ModelInstance {
142
155
  * @throws {Error} Throws an error if the deletion fails.
143
156
  */
144
157
  async delete(filter) {
145
- const sql_request = `DELETE FROM ${this._name} WHERE ${generateCondition(formatObject(filter))}`;
158
+ const { sql: whereClause, values } = buildQueryParts(filter);
159
+
160
+ const sql_request = `DELETE FROM ${this._name} ${whereClause}`;
146
161
 
147
- const rows = await getConnexion().promise().execute(sql_request).catch((err) => {
162
+ const rows = await getConnexion().promise().execute(sql_request, values).catch((err) => {
148
163
  error(`Error executing query delete: ${err}`);
149
164
  throw err;
150
165
  });
@@ -158,8 +173,9 @@ class ModelInstance {
158
173
  * @throws {Error} Throws an error if the deletion fails.
159
174
  */
160
175
  async deleteOne() {
161
- const sql_request = `DELETE FROM ${this._name} WHERE ${generateCondition(formatObject(this.getRecordData()))}`;
162
- const rows = await getConnexion().promise().execute(sql_request).catch((err) => {
176
+ const { sql: whereClause, values } = buildQueryParts(this.getRecordData());
177
+ const sql_request = `DELETE FROM ${this._name} ${whereClause}`;
178
+ const rows = await getConnexion().promise().execute(sql_request, values).catch((err) => {
163
179
  error(`Error executing query deleteOne: ${err}`);
164
180
  throw err;
165
181
  });
@@ -1,25 +1,26 @@
1
- const formatObject = require("../formatObject");
2
- const generateCondition = require("../generateCondition");
3
1
  const { escapeIdentifier, escapeOrderDirection, escapeValue } = require("../sql");
4
2
  const count = require("./count");
5
3
 
6
4
  function buildGroupByItem(group) {
7
- if (typeof group !== 'string') {
8
- throw new Error("Group by items must be strings");
5
+ let sql;
6
+ if (typeof group !== 'string' && typeof group !== 'object') {
7
+ throw new Error("Group by items must be strings or objects");
9
8
  }
10
-
11
- const trimmedGroup = group.trim();
12
-
13
- if (/^DATE_FORMAT\(/i.test(trimmedGroup)) {
14
- const match = trimmedGroup.match(/^DATE_FORMAT\(([^,]+),\s*('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")\)$/i);
15
- if (match) {
16
- const column = match[1].trim();
17
- const format = match[2].slice(1, -1).replace(/\\'/g, "'").replace(/\\"/g, '"');
18
- return `DATE_FORMAT(${escapeIdentifier(column)}, ${escapeValue(format)})`;
9
+ if (typeof group === 'object') {
10
+ if (group.dateFormat) {
11
+ const [col, format] = group.dateFormat;
12
+ sql = `DATE_FORMAT(${escapeIdentifier(col)}, ${escapeValue(format)})`;
13
+ }
14
+ if (group.col) {
15
+ sql = escapeIdentifier(group.col);
19
16
  }
17
+ if (group.as) {
18
+ sql += ` AS ${escapeIdentifier(group.as)}`;
19
+ }
20
+ return sql;
20
21
  }
21
22
 
22
- return escapeIdentifier(trimmedGroup);
23
+ return escapeIdentifier(group.trim());
23
24
  }
24
25
 
25
26
  function buildField(field) {
@@ -59,16 +60,92 @@ function buildSelect(select = []) {
59
60
  .join(',\n');
60
61
  }
61
62
 
63
+ function buildWhere(where, values = []) {
64
+ const conditions = [];
65
+
66
+ for (const [key, value] of Object.entries(where)) {
67
+ if (key === "OR") {
68
+ if (value.length === 0) {
69
+ throw new Error("OR conditions cannot be empty");
70
+ }
71
+ const clauses = value
72
+ .map((item) => buildWhere(item, values))
73
+ .filter(Boolean);
74
+
75
+ if (clauses.length) {
76
+ conditions.push(`(${clauses.join(" OR ")})`);
77
+ }
78
+
79
+ continue;
80
+ }
81
+
82
+ if (key === "AND") {
83
+ if (value.length === 0) {
84
+ throw new Error("AND conditions cannot be empty");
85
+ }
86
+ const clauses = value
87
+ .map((item) => buildWhere(item, values))
88
+ .filter(Boolean);
89
+
90
+ if (clauses.length) {
91
+ conditions.push(`(${clauses.join(" AND ")})`);
92
+ }
93
+
94
+ continue;
95
+ }
96
+
97
+ if (value && typeof value === "object" && !Array.isArray(value)) {
98
+ const operators = Object.keys(value);
99
+
100
+ const isOperatorObject = operators.some((op) =>
101
+ ["=", "!=", ">", "<", ">=", "<=", "LIKE", "IN", "NOT IN"].includes(op)
102
+ );
103
+
104
+ if (isOperatorObject) {
105
+ for (const [operator, operatorValue] of Object.entries(value)) {
106
+ if (operator === "IN" || operator === "NOT IN") {
107
+ const placeholders = operatorValue
108
+ .map(() => "?")
109
+ .join(",");
110
+
111
+ conditions.push(
112
+ `${escapeIdentifier(key)} ${operator} (${placeholders})`
113
+ );
114
+ values.push(...operatorValue);
115
+ } else {
116
+ conditions.push(`${escapeIdentifier(key)} ${operator} ?`);
117
+ values.push(operatorValue);
118
+ }
119
+ }
120
+
121
+ continue;
122
+ }
123
+ }
124
+
125
+ conditions.push(`${escapeIdentifier(key)} = ?`);
126
+ values.push(value);
127
+ }
128
+
129
+ return conditions.join(" AND ");
130
+ }
131
+
132
+ /**
133
+ * Construit les parties de la requête et extrait les valeurs sécurisées
134
+ * @param {Object} options Options de filtrage, tri et pagination
135
+ * @returns {Object} Un objet contenant la chaîne SQL générée et le tableau des valeurs { sql, values }
136
+ */
62
137
  function buildQueryParts(options) {
63
138
  const parts = [];
139
+ const values = [];
64
140
 
141
+ if (!options) {
142
+ return { sql: '', values: [] };
143
+ }
65
144
  if (options.where) {
66
- if (typeof options.where === 'string') {
67
- if (options.where.trim().toUpperCase().startsWith("WHERE ")) throw new Error("Raw string WHERE clauses are not allowed. Use a structured filter instead.");
68
- else parts.push(`WHERE ${options.where}`);
69
- } else {
70
- parts.push(`WHERE ${generateCondition(formatObject(options.where))}`);
145
+ if (typeof options.where !== 'object' || Array.isArray(options.where)) {
146
+ throw new Error("Raw string WHERE clauses are not allowed. Use a structured filter instead.");
71
147
  }
148
+ parts.push(`WHERE ${buildWhere(options.where, values)}`);
72
149
  }
73
150
 
74
151
  if (options.groupBy) {
@@ -92,10 +169,15 @@ function buildQueryParts(options) {
92
169
  if (!Number.isInteger(options.limit) || options.limit < 0) {
93
170
  throw new Error("Invalid LIMIT value");
94
171
  }
95
- parts.push(`LIMIT ${options.limit}`);
172
+
173
+ parts.push(`LIMIT ?`);
174
+ values.push(options.limit);
96
175
  }
97
176
 
98
- return parts.join('\n\n');
177
+ return {
178
+ sql: parts.join('\n\n'),
179
+ values: values
180
+ };
99
181
  }
100
182
 
101
- module.exports = { buildQueryParts, buildSelect }
183
+ module.exports = { buildQueryParts, buildSelect };
@@ -1,34 +0,0 @@
1
- const { getSafe, setSafe } = require("./security/safe");
2
-
3
- module.exports = function (obj) {
4
- for (const key in obj) {
5
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
6
- const value = getSafe(obj, key);
7
-
8
- if (value instanceof Date) {
9
- // convert Date to MySQL DATETIME (no timezone)
10
- setSafe(obj, key, value.toISOString().slice(0, 19).replace('T', ' '));
11
- continue;
12
- }
13
-
14
- if (typeof value === "string") {
15
- // remove surrounding quotes if present and unescape
16
- let v = value.trim();
17
- // remove wrapping double or single quotes
18
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
19
- v = v.slice(1, -1);
20
- }
21
- // unescape common escaped quotes
22
- v = v.replace(/\\"/g, '"').replace(/\\'/g, "'");
23
- setSafe(obj, key, v);
24
- continue;
25
- }
26
-
27
- if (typeof value === "object") {
28
- // stringify objects and escape single quotes for SQL safety
29
- setSafe(obj, key, JSON.stringify(value).replace(/'/g, "\\'"));
30
- }
31
- }
32
- }
33
- return obj;
34
- }
@@ -1,105 +0,0 @@
1
- const { getSafe } = require("./security/safe");
2
- const { escapeIdentifier, escapeValue } = require("./sql");
3
-
4
- /**
5
- * Generates an SQL_request condition from a filter object.
6
- *
7
- * @param {Object} filter An object containing the key-value pairs to use to generate the condition.
8
- * @param {boolean} [isUpdate=false] A flag to determine whether the condition is used in an update request.
9
- * @returns {string} A character string representing the generated SQL_request condition.
10
- *
11
- * @example
12
- * const filter = { id: 1, name: "John" };
13
- * const condition = generateCondition(filter);
14
- * console.log(condition); // 'id = 1 AND name = "John"'
15
- *
16
- * @example
17
- * const filter = { id: 1, name: "John" };
18
- * const condition = generateCondition(filter, true);
19
- * console.log(condition); // 'id = 1, name = "John"'
20
- */
21
- module.exports = function (filter, isUpdate = false, schema = null) {
22
- const keys = Object.keys(filter);
23
- const values = Object.values(filter);
24
-
25
- const filteredKeys = isUpdate ? keys.filter(key => getSafe(filter, key) !== undefined) : keys;
26
- const filteredValues = isUpdate ? filteredKeys.map(key => getSafe(filter, key)) : values;
27
-
28
- if (!isUpdate && schema && schema.schemaDict) {
29
- const uniqueKeys = filteredKeys.filter(key => {
30
- const field = getSafe(schema.schemaDict, key);
31
- return field && field.unique === true;
32
- });
33
- if (uniqueKeys.length > 0) {
34
- return uniqueKeys.map(key => {
35
- let value = getSafe(filter, key);
36
- const escapedKey = escapeIdentifier(key);
37
- // normalize strings that may contain surrounding quotes or escaped quotes
38
- if (typeof value === 'string') {
39
- value = value.trim();
40
- value = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
41
- }
42
- if (Array.isArray(value)) {
43
- return `${escapedKey} IN (${value.map(v => escapeValue(v)).join(", ")})`;
44
- }
45
- if (typeof value === "object" && value !== null || (typeof value === "string" && value.trim().startsWith("{") && value.trim().endsWith("}"))) {
46
- const jsonVal = typeof value === "string" ? value : JSON.stringify(value);
47
- return `JSON_CONTAINS(${escapedKey}, ${escapeValue(jsonVal)})`;
48
- }
49
- if (value === null || value === "null") return `${escapedKey} IS NULL`;
50
- // if string looks like an ISO datetime, convert to MySQL DATETIME format
51
- if (typeof value === 'string' && /T/.test(value)) {
52
- let val = value.replace(/\.\d+Z$/,'').replace(/Z$/,'').replace('T',' ');
53
- return `${escapedKey} = ${escapeValue(val)}`;
54
- }
55
- return `${escapedKey} = ${escapeValue(value)}`;
56
- }).join(" AND ");
57
- }
58
- }
59
-
60
- // Comportement par défaut
61
- const conditions = filteredKeys.map((key, index) => {
62
- let value = getSafe(filteredValues, index);
63
- const escapedKey = escapeIdentifier(key);
64
-
65
- if (typeof value === 'string') {
66
- value = value.trim();
67
- value = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
68
- }
69
-
70
- if (Array.isArray(value)) {
71
- return `${escapedKey} IN (${value.map(v => escapeValue(v)).join(", ")})`;
72
- }
73
- if (typeof value === "object" && value !== null || (typeof value === "string" && value.trim().startsWith("{") && value.trim().endsWith("}"))) {
74
- const jsonVal = typeof value === "string" ? value : JSON.stringify(value);
75
- if (isUpdate) {
76
- return `${escapedKey} = ${escapeValue(jsonVal)}`;
77
- }
78
- return `JSON_CONTAINS(${escapedKey}, ${escapeValue(jsonVal)})`;
79
- }
80
-
81
- if ((value === null || value === "null") && isUpdate == false) return `${escapedKey} IS NULL`;
82
-
83
- // handle date-like strings when schema tells us the field is temporal
84
- const fieldDef = schema && schema.schemaDict ? getSafe(schema.schemaDict, key) : null;
85
- let fieldType = null;
86
- if (fieldDef) {
87
- if (fieldDef.type && fieldDef.type.name !== undefined) fieldType = fieldDef.type.name;
88
- else if (fieldDef.type !== undefined) fieldType = fieldDef.type;
89
- }
90
- const normalizedFieldType = String(fieldType ?? "").toLowerCase();
91
- const isDateLike = ["date", "datetime", "timestamp", "now"].includes(normalizedFieldType);
92
-
93
- if (typeof value === "string") {
94
- let val = value;
95
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1,-1);
96
- // if ISO timestamp with Z, convert to MySQL DATETIME format
97
- if (isDateLike && /T/.test(val)) {
98
- val = val.replace(/\.\d+Z$/,'').replace(/Z$/,'').replace('T',' ');
99
- }
100
- return `${escapedKey} = ${escapeValue(val)}`;
101
- }
102
- return `${escapedKey} = ${escapeValue(value)}`;
103
- }).join(` ${isUpdate == false ? "AND" : ","} `);
104
- return conditions;
105
- }