@mlagie/sql-connector 1.1.3-beta → 1.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
@@ -1,3 +1,7 @@
1
+ # sql-connector
2
+ <img src="https://api.visitorbadge.io/api/VisitorHit?user=lagie-marin&repo=sql-connector-badge&countColor=%237B1E7A" height="20px"/> ![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)
3
+ ![GitHub last commit](https://img.shields.io/github/last-commit/lagie-marin/sql-connector) ![GitHub forks](https://img.shields.io/github/forks/lagie-marin/sql-connector?style=plastic&color=%green)
4
+
1
5
  # Documentation du module `sql-connector`
2
6
 
3
7
  Le module `sql-connector` permet de gérer les connexions à une base de données MySQL, de définir des schémas de tables, et d'interagir avec les données de manière simple et efficace.
@@ -104,6 +108,59 @@ const transferSchema = new Schema({
104
108
  }
105
109
  });
106
110
  ```
111
+
112
+ ## Synchronisation automatique du schéma et gestion des migrations
113
+
114
+ ### Ajout, suppression et renommage de colonnes
115
+
116
+ - **Ajout automatique de colonnes**
117
+ Lorsque vous ajoutez un champ dans le schéma JS, la colonne correspondante est automatiquement ajoutée dans la base de données lors de la synchronisation avec :
118
+ ```js
119
+ await Model.syncAllTables();
120
+ ```
121
+
122
+ - **Suppression automatique de colonnes**
123
+ Si vous retirez un champ du schéma JS, la colonne reste dans la base par défaut.
124
+ Pour supprimer automatiquement les colonnes disparues, utilisez :
125
+ ```js
126
+ await Model.syncAllTables({ dangerousSync: true });
127
+ ```
128
+ ⚠️ Attention, cela supprime les données de ces colonnes.
129
+
130
+ - **Renommage de colonne sans perte de données**
131
+ Pour renommer une colonne, ajoutez la propriété `oldName` dans le schéma :
132
+ ```js
133
+ const userSchema = new Schema({
134
+ role: { type: String, oldName: "rang" }
135
+ });
136
+ ```
137
+ Lors de la synchronisation, la colonne SQL sera renommée sans perte de données.
138
+ Un avertissement s’affichera pour vous rappeler de retirer `oldName` du schéma après migration.
139
+
140
+ ### Suppression et sauvegarde des tables orphelines
141
+
142
+ - Si une table SQL n’a plus de schéma JS associé, elle est supprimée automatiquement lors de la synchronisation.
143
+ - **Avant suppression**, un fichier de backup SQL (INSERTs) est généré dans le dossier courant (ex : `backup_MaTable_1690000000000.sql`).
144
+
145
+ ### Restauration et gestion des backups
146
+
147
+ - Si une table supprimée réapparaît dans le schéma, le module détecte la présence d’un backup et propose :
148
+ 1. **De restaurer les données** (exécution du fichier SQL).
149
+ 2. **De supprimer le backup** après restauration ou non.
150
+ 3. Si vous refusez la suppression, le backup est renommé en `.ignored` et ne sera plus proposé.
151
+
152
+ #### Exemple d’utilisation
153
+
154
+ ```js
155
+ // Synchronisation simple (ajout/renommage de colonnes, suppression de tables orphelines avec backup)
156
+ await Model.syncAllTables();
157
+
158
+ // Synchronisation avec suppression automatique des colonnes disparues
159
+ await Model.syncAllTables({ dangerousSync: true });
160
+ ```
161
+
162
+ ---
163
+
107
164
  ## Class Model
108
165
  Représente un modèle de base de données.
109
166
  * ### Constructeur :
package/index.d.ts CHANGED
@@ -98,7 +98,7 @@ export class Model {
98
98
  * Crée toutes les tables dans l'ordre correct en fonction des foreign keys.
99
99
  * @returns {Promise<void>}
100
100
  */
101
- static createAllTables(): Promise<void>;
101
+ static syncAllTables({ dangerousSync = false } = {}): Promise<void>;
102
102
  /**
103
103
  * Saves data to the database table.
104
104
  * @param {Object} data The data to insert into the table.
package/index.js CHANGED
@@ -4,6 +4,7 @@ const { connect, logout } = require("./src/db/connect");
4
4
  const {Schema} = require("./src/models/Schema");
5
5
  const { sqlType } = require("./src/utils/sqlType");
6
6
  const { sqlTypeMap } = require("./src/utils/sqlTypeMap");
7
- const client = require("./client");
7
+
8
+ let client = {};
8
9
 
9
10
  module.exports = { client, connect, logout, Schema, Model, ModelInstance, sqlType, sqlTypeMap }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlagie/sql-connector",
3
- "version": "1.1.3-beta",
3
+ "version": "1.2.0",
4
4
  "description": "Le module sql-connector permet de gérer les connexions à une base de données MySQL, de définir des schémas de tables, et d'interagir avec les données de manière simple et efficace.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -10,7 +10,10 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/lagie-marin/sql-connector.git"
12
12
  },
13
- "keywords": ["sql", "schema"],
13
+ "keywords": [
14
+ "sql",
15
+ "schema"
16
+ ],
14
17
  "author": "lagie-marin",
15
18
  "license": "MIT",
16
19
  "type": "commonjs",
@@ -21,6 +24,7 @@
21
24
  "private": false,
22
25
  "dependencies": {
23
26
  "@mlagie/logger": "1.0.1",
24
- "mysql2": "^3.14.4"
27
+ "glob": "^11.0.3",
28
+ "mysql2": "3.14.5"
25
29
  }
26
30
  }
@@ -22,6 +22,50 @@ function generateValueSQL(value) {
22
22
  }).join(", ");
23
23
  }
24
24
 
25
+ 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'];
26
+
27
+ /**
28
+ * Checks if a table name is a reserved keyword.
29
+ *
30
+ * @param {string} tableName Le nom de la table à vérifier.
31
+ * @returns {boolean} `true` si le nom de la table est un mot-clé réservé, sinon `false`.
32
+ *
33
+ * @example
34
+ * const isReserved = ifReservedKeywords('SELECT');
35
+ * console.log(isReserved); // true
36
+ *
37
+ * @example
38
+ * const isReserved = ifReservedKeywords('myTable');
39
+ * console.log(isReserved); // false
40
+ */
41
+ function ifReservedKeywords(tableName) {
42
+ if (reservedKeywords.includes(tableName.toUpperCase())) {
43
+ return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function getColumnDefinition(fieldName, field) {
49
+ const fieldType = getFieldType(field);
50
+ let colDef = "";
51
+
52
+ if (Array.isArray(field.enum) && field.enum.length > 0) {
53
+ const enumValues = field.enum.map(v => `'${v.replace(/'/g, "''")}'`).join(", ");
54
+ colDef = `ENUM(${enumValues})`;
55
+ } else {
56
+ if (!sqlTypeMap[fieldType]) throw new Error(`Field ${fieldName} has unsupported type ${fieldType}.`);
57
+ colDef = `${sqlTypeMap[fieldType]}${(sqlTypeMap[fieldType] == "VARCHAR" || sqlTypeMap[fieldType] == "INT") ? `(${field.length > 0 ? field.length : 255})` : ""}`;
58
+ }
59
+ if (field.required) colDef += ' NOT NULL';
60
+ if (field.default !== undefined && field.default != null) colDef += ` DEFAULT "${field.default}"`;
61
+ if (field.default === null) colDef += ` DEFAULT NULL`;
62
+ if (field.unique) colDef += ' UNIQUE';
63
+ if (field.auto_increment) colDef += ' AUTO_INCREMENT';
64
+ if (field.primary_key) colDef += ' PRIMARY KEY';
65
+ if (typeof field.customize === 'string' && field.customize.length != 0) colDef += ` ${field.customize}`;
66
+ return colDef;
67
+ }
68
+
25
69
  /**
26
70
  * Represents a database model.
27
71
  * @class
@@ -43,25 +87,33 @@ class Model {
43
87
  }
44
88
 
45
89
  /**
46
- * Crée toutes les tables dans l'ordre correct en fonction des foreign keys.
90
+ * Synchronise toutes les tables avec leur schéma JS (création + ajout des colonnes manquantes).
47
91
  * @returns {Promise<void>}
48
92
  */
49
- static async createAllTables() {
93
+ static async syncAllTables({ dangerousSync = false } = {}) {
94
+ const fs = require('fs');
95
+ const path = require('path');
96
+ const glob = require('glob');
97
+
50
98
  // Dépendances : {table: [tables dont elle dépend]}
51
99
  const dependencies = {};
52
100
  const modelMap = {};
53
101
  for (const model of Model.pendingModels) {
54
102
  modelMap[model.name] = model;
55
103
  dependencies[model.name] = [];
56
- for (const [fieldName, field] of Object.entries(model.schema.schemaDict)) {
104
+ for (const [_, field] of Object.entries(model.schema.schemaDict)) {
57
105
  if (field && field.foreignKey) {
58
- // field.foreignKey peut être "autreTable(colonne)"
59
106
  const refTable = field.foreignKey.split('(')[0].trim();
60
107
  dependencies[model.name].push(refTable);
61
108
  }
62
109
  }
63
110
  }
64
111
 
112
+ // Récupère toutes les tables existantes dans la base
113
+ const conn = getConnexion();
114
+ const [dbTablesRows] = await conn.promise().query("SHOW TABLES");
115
+ const dbTables = dbTablesRows.map(row => Object.values(row)[0]);
116
+
65
117
  // Tri topologique
66
118
  const sorted = [];
67
119
  const visited = {};
@@ -79,19 +131,151 @@ class Model {
79
131
  if (!visited[table]) visit(table);
80
132
  }
81
133
 
82
- // Création des tables dans l'ordre
134
+ // --- Détruit les tables qui n'ont plus de schéma ---
135
+ for (const dbTable of dbTables) {
136
+ if (!modelMap[dbTable]) {
137
+ try {
138
+ const backupPath = `./backup_${dbTable}_${Date.now()}.sql`;
139
+ // Sauvegarde rapide en SQL (INSERTs)
140
+ const [rows] = await conn.promise().query(`SELECT * FROM \`${dbTable}\``);
141
+ if (rows.length > 0) {
142
+ const columns = Object.keys(rows[0]).map(col => `\`${col}\``).join(', ');
143
+ const values = rows.map(row =>
144
+ '(' + Object.values(row).map(val =>
145
+ val === null ? 'NULL' : conn.escape(val)
146
+ ).join(', ') + ')'
147
+ ).join(',\n');
148
+ const insertSQL = `INSERT INTO \`${dbTable}\` (${columns}) VALUES${values};\n`;
149
+ fs.writeFileSync(backupPath, insertSQL, 'utf-8');
150
+ logs(`Sauvegarde SQL de la table '${dbTable}' effectuée dans '${backupPath}'.`);
151
+ } else {
152
+ fs.writeFileSync(backupPath, '', 'utf-8');
153
+ logs(`Table '${dbTable}' vide, fichier '${backupPath}' créé.`);
154
+ }
155
+ } catch (err) {
156
+ error(`Erreur lors de la sauvegarde SQL de la table '${dbTable}': ${err}`);
157
+ }
158
+ logs(`Table '${dbTable}' n'a plus de schéma associé, suppression...`);
159
+ await conn.promise().query(`DROP TABLE IF EXISTS \`${dbTable}\``);
160
+ logs(`Table '${dbTable}' supprimée.`);
161
+ }
162
+ }
163
+
164
+ // Création/synchronisation des tables dans l'ordre
83
165
  for (const table of sorted) {
84
166
  const model = modelMap[table];
85
- await new Promise((resolve, reject) => {
86
- getConnexion().query(model.generateCreateTableStatement(model.schema.schemaDict), (err) => {
87
- if (err) {
88
- error(`Error creating table: ${err} with table name: ${model.name}`);
89
- return reject(err);
167
+
168
+ // 1. Crée la table si elle n'existe pas (version avec promesses)
169
+ try {
170
+ await conn.promise().query(model.generateCreateTableStatement(model.schema.schemaDict));
171
+ await logs(`La table ${model.name} a été créée ou existe déjà`);
172
+ } catch (err) {
173
+ error(`Error creating table: ${err} with table name: ${model.name}`);
174
+ throw err;
175
+ }
176
+
177
+ // --- Restauration automatique si backup SQL trouvé ---
178
+ const backupPattern = `./backup_${model.name}_*.sql`;
179
+ const backupFiles = glob.sync(backupPattern).filter(f => !f.endsWith('.ignored'));
180
+ if (backupFiles.length > 0) {
181
+ const latestBackup = backupFiles.sort().reverse()[0];
182
+ const readline = require('readline');
183
+ const rl = readline.createInterface({
184
+ input: process.stdin,
185
+ output: process.stdout
186
+ });
187
+
188
+ // Demande restauration
189
+ const answer = await new Promise((resolve) => {
190
+ rl.question(
191
+ `Un backup a été trouvé pour la table '${model.name}' (${latestBackup}). Voulez-vous restaurer les données ? (y/N) `,
192
+ (answer) => {
193
+ resolve(answer.trim().toLowerCase());
194
+ }
195
+ );
196
+ });
197
+
198
+ if (answer === 'y') {
199
+ try {
200
+ const sqlContent = fs.readFileSync(latestBackup, 'utf-8');
201
+ await conn.promise().query(sqlContent);
202
+ await logs(`Backup restauré pour la table '${model.name}'.`);
203
+ } catch (err) {
204
+ error(`Erreur lors de la restauration du backup pour '${model.name}': ${err}`);
90
205
  }
91
- logs(`La table ${model.name} a été créé`);
92
- resolve();
206
+ }
207
+
208
+ // Toujours demander la suppression après restauration ou non
209
+ await new Promise((resolve) => {
210
+ rl.question(
211
+ `Voulez-vous supprimer le fichier de backup '${latestBackup}' ? (y/N) `,
212
+ (delAnswer) => {
213
+ if (delAnswer.trim().toLowerCase() === 'y') {
214
+ fs.unlinkSync(latestBackup);
215
+ logs(`Backup supprimé : ${latestBackup}`);
216
+ } else {
217
+ // Renomme le fichier pour ne plus proposer la restauration
218
+ fs.renameSync(latestBackup, latestBackup + '.ignored');
219
+ logs(`Backup ignoré pour la table '${model.name}'. Il ne sera plus proposé.`);
220
+ }
221
+ rl.close();
222
+ resolve();
223
+ }
224
+ );
93
225
  });
94
- });
226
+ }
227
+
228
+ // 2. Synchronise les colonnes (renommage, ajout, suppression)
229
+ const [columns] = await conn.promise().query(
230
+ `SHOW COLUMNS FROM \`${model.name}\``
231
+ );
232
+ let existingCols = columns.map(col => col.Field);
233
+
234
+ // --- Warn si oldName est présent dans le schéma ---
235
+ for (const [fieldName, field] of Object.entries(model.schema.schemaDict)) {
236
+ if (field.oldName) {
237
+ logs(`⚠️ Pensez à retirer la propriété 'oldName' du champ '${fieldName}' dans le schéma JS de '${model.name}' pour éviter des renommages inutiles à l'avenir.`);
238
+ }
239
+ }
240
+
241
+ // --- Étape 1 : Renommage des colonnes ---
242
+ for (const [fieldName, field] of Object.entries(model.schema.schemaDict)) {
243
+ if (field.oldName && existingCols.includes(field.oldName) && !existingCols.includes(fieldName)) {
244
+ // Génère la définition SQL de la nouvelle colonne
245
+ let colDef = getColumnDefinition(fieldName, field);
246
+
247
+ // Renomme la colonne
248
+ const alterSQL = `ALTER TABLE \`${model.name}\` CHANGE COLUMN \`${field.oldName}\` \`${fieldName}\` ${colDef}`;
249
+ await conn.promise().query(alterSQL);
250
+ logs(`Colonne ${field.oldName} renommée en ${fieldName} dans ${model.name}`);
251
+ // Mets à jour existingCols pour la suite
252
+ existingCols = existingCols.map(col => col === field.oldName ? fieldName : col);
253
+ }
254
+ }
255
+
256
+ // --- Étape 2 : Ajout des colonnes manquantes ---
257
+ for (const [fieldName, field] of Object.entries(model.schema.schemaDict)) {
258
+ if (!existingCols.includes(fieldName)) {
259
+ let colDef = getColumnDefinition(fieldName, field);
260
+
261
+ // Ajoute la colonne
262
+ const alterSQL = `ALTER TABLE \`${model.name}\` ADD COLUMN \`${fieldName}\` ${colDef}`;
263
+ await conn.promise().query(alterSQL);
264
+ logs(`Colonne ${fieldName} ajoutée à ${model.name}`);
265
+ existingCols.push(fieldName);
266
+ }
267
+ }
268
+
269
+ // --- Étape 3 : Suppression des colonnes orphelines (dangerousSync) ---
270
+ if (dangerousSync) {
271
+ for (const col of existingCols) {
272
+ if (!Object.keys(model.schema.schemaDict).includes(col)) {
273
+ const alterSQL = `ALTER TABLE \`${model.name}\` DROP COLUMN \`${col}\``;
274
+ await conn.promise().query(alterSQL);
275
+ logs(`Colonne ${col} supprimée de ${model.name}`);
276
+ }
277
+ }
278
+ }
95
279
  }
96
280
  // Vide la liste d'attente
97
281
  Model.pendingModels = [];
@@ -115,26 +299,8 @@ class Model {
115
299
 
116
300
  if (field.type && typeof field == "object") {
117
301
  // Si c'est un enum, ne pas vérifier sqlTypeMap
118
- let columnDefinition = "";
119
- if (Array.isArray(field.enum) && field.enum.length > 0) {
120
- const enumValues = field.enum.map(v => `'${v.replace(/'/g, "''")}'`).join(", ");
121
- columnDefinition = `${fieldName} ENUM(${enumValues})`;
122
- } else {
123
- if (!sqlTypeMap[fieldType]) throw new Error(`Field ${fieldName} has unsupported type ${fieldType}.`);
124
- columnDefinition = `${fieldName} ${sqlTypeMap[fieldType]}${sqlTypeMap[fieldType] == "VARCHAR" || sqlTypeMap[fieldType] == "INT" ? `(${field.length > 0 ? field.length : lengthDefault})` : ""}`;
125
- }
126
-
127
- if (field.required) columnDefinition += ' NOT NULL';
128
- if (field.default !== undefined && field.default != null) columnDefinition += ` DEFAULT "${field.default}"`;
129
- if (field.default === null) columnDefinition += ` DEFAULT NULL`;
130
- if (field.unique) columnDefinition += ' UNIQUE';
131
- if (field.auto_increment) columnDefinition += ' AUTO_INCREMENT';
132
- if (field.foreignKey) foreignKey.push(`FOREIGN KEY (${fieldName}) REFERENCES ${field.foreignKey}`);
133
- if (field.primary_key) columnDefinition += ' PRIMARY KEY';
134
- if (typeof field.customize === 'string' && field.customize.length != 0) columnDefinition += ` ${field.customize}`;
135
- return columnDefinition;
302
+ return getColumnDefinition(fieldName, field);
136
303
  }
137
-
138
304
  if (Array.isArray(field.enum) && field.enum.length > 0) {
139
305
  const enumValues = field.enum.map(v => `'${v.replace(/'/g, "''")}'`).join(", ");
140
306
  return `${fieldName} ENUM(${enumValues})`;
@@ -378,4 +544,4 @@ class Model {
378
544
  }
379
545
  }
380
546
 
381
- module.exports = {Model}
547
+ module.exports = { Model }
package/client.d.ts DELETED
@@ -1,8 +0,0 @@
1
- declare module './client' {
2
- interface Client {
3
- [key: string]: any; // Dynamic methods added on the fly
4
- }
5
-
6
- const client: Client;
7
- export = client;
8
- }
package/client.js DELETED
@@ -1,8 +0,0 @@
1
- /**
2
- * @typedef {Object} Client
3
- * @property {Object.<string, any>} [dynamicMethods] - Dynamic methods added on the fly
4
- */
5
-
6
- /** @type {Client} */
7
- const client = {};
8
- module.exports = client;
@@ -1,24 +0,0 @@
1
- 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'];
2
-
3
- /**
4
- * Checks if a table name is a reserved keyword.
5
- *
6
- * @param {string} tableName Le nom de la table à vérifier.
7
- * @returns {boolean} `true` si le nom de la table est un mot-clé réservé, sinon `false`.
8
- *
9
- * @example
10
- * const isReserved = ifReservedKeywords('SELECT');
11
- * console.log(isReserved); // true
12
- *
13
- * @example
14
- * const isReserved = ifReservedKeywords('myTable');
15
- * console.log(isReserved); // false
16
- */
17
- function ifReservedKeywords(tableName) {
18
- if (reservedKeywords.includes(tableName.toUpperCase())) {
19
- return true;
20
- }
21
- return false;
22
- }
23
-
24
- module.exports = { ifReservedKeywords }