@mostajs/orm 1.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/LICENSE +21 -0
- package/README.md +548 -0
- package/dist/core/base-repository.d.ts +26 -0
- package/dist/core/base-repository.js +82 -0
- package/dist/core/config.d.ts +62 -0
- package/dist/core/config.js +116 -0
- package/dist/core/errors.d.ts +30 -0
- package/dist/core/errors.js +49 -0
- package/dist/core/factory.d.ts +41 -0
- package/dist/core/factory.js +142 -0
- package/dist/core/normalizer.d.ts +9 -0
- package/dist/core/normalizer.js +19 -0
- package/dist/core/registry.d.ts +43 -0
- package/dist/core/registry.js +78 -0
- package/dist/core/types.d.ts +228 -0
- package/dist/core/types.js +5 -0
- package/dist/dialects/abstract-sql.dialect.d.ts +113 -0
- package/dist/dialects/abstract-sql.dialect.js +1071 -0
- package/dist/dialects/cockroachdb.dialect.d.ts +2 -0
- package/dist/dialects/cockroachdb.dialect.js +23 -0
- package/dist/dialects/db2.dialect.d.ts +2 -0
- package/dist/dialects/db2.dialect.js +190 -0
- package/dist/dialects/hana.dialect.d.ts +2 -0
- package/dist/dialects/hana.dialect.js +199 -0
- package/dist/dialects/hsqldb.dialect.d.ts +2 -0
- package/dist/dialects/hsqldb.dialect.js +114 -0
- package/dist/dialects/mariadb.dialect.d.ts +2 -0
- package/dist/dialects/mariadb.dialect.js +87 -0
- package/dist/dialects/mongo.dialect.d.ts +2 -0
- package/dist/dialects/mongo.dialect.js +480 -0
- package/dist/dialects/mssql.dialect.d.ts +27 -0
- package/dist/dialects/mssql.dialect.js +127 -0
- package/dist/dialects/mysql.dialect.d.ts +24 -0
- package/dist/dialects/mysql.dialect.js +101 -0
- package/dist/dialects/oracle.dialect.d.ts +2 -0
- package/dist/dialects/oracle.dialect.js +206 -0
- package/dist/dialects/postgres.dialect.d.ts +26 -0
- package/dist/dialects/postgres.dialect.js +105 -0
- package/dist/dialects/spanner.dialect.d.ts +2 -0
- package/dist/dialects/spanner.dialect.js +259 -0
- package/dist/dialects/sqlite.dialect.d.ts +2 -0
- package/dist/dialects/sqlite.dialect.js +1027 -0
- package/dist/dialects/sybase.dialect.d.ts +2 -0
- package/dist/dialects/sybase.dialect.js +119 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +26 -0
- package/docs/api-reference.md +1009 -0
- package/docs/dialects.md +673 -0
- package/docs/tutorial.md +846 -0
- package/package.json +91 -0
package/docs/tutorial.md
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
# MostaORM — Tutorial complet
|
|
2
|
+
|
|
3
|
+
> Ce tutoriel vous guide de zéro à une application complète utilisant MostaORM avec SQLite, puis montre comment basculer vers PostgreSQL sans changer une seule ligne de code métier.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table des matières
|
|
8
|
+
|
|
9
|
+
1. [Installation](#1-installation)
|
|
10
|
+
2. [Configuration](#2-configuration)
|
|
11
|
+
3. [Définir une entité (EntitySchema)](#3-définir-une-entité-entityschema)
|
|
12
|
+
4. [Créer un Repository](#4-créer-un-repository)
|
|
13
|
+
5. [Opérations CRUD](#5-opérations-crud)
|
|
14
|
+
6. [Filtres avancés](#6-filtres-avancés)
|
|
15
|
+
7. [Relations entre entités](#7-relations-entre-entités)
|
|
16
|
+
8. [Agrégations](#8-agrégations)
|
|
17
|
+
9. [Recherche plein texte](#9-recherche-plein-texte)
|
|
18
|
+
10. [Changer de dialecte](#10-changer-de-dialecte)
|
|
19
|
+
11. [Application Express complète](#11-application-express-complète)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 1. Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @mosta/orm
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Installez uniquement le driver de votre base de données :
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# SQLite (léger, parfait pour commencer)
|
|
33
|
+
npm install better-sqlite3
|
|
34
|
+
|
|
35
|
+
# PostgreSQL
|
|
36
|
+
npm install pg
|
|
37
|
+
|
|
38
|
+
# MongoDB
|
|
39
|
+
npm install mongoose
|
|
40
|
+
|
|
41
|
+
# MySQL / MariaDB
|
|
42
|
+
npm install mysql2
|
|
43
|
+
npm install mariadb
|
|
44
|
+
|
|
45
|
+
# Autres — voir docs/dialects.md
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 2. Configuration
|
|
51
|
+
|
|
52
|
+
MostaORM se configure via **variables d'environnement** ou directement dans le code.
|
|
53
|
+
|
|
54
|
+
### Via variables d'environnement
|
|
55
|
+
|
|
56
|
+
Créez un fichier `.env` :
|
|
57
|
+
|
|
58
|
+
```env
|
|
59
|
+
DB_DIALECT=sqlite
|
|
60
|
+
SGBD_URI=./data/myapp.db
|
|
61
|
+
DB_SCHEMA_STRATEGY=update
|
|
62
|
+
DB_SHOW_SQL=true
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Le code se réduit à :
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { getDialect } from '@mosta/orm'
|
|
69
|
+
|
|
70
|
+
const dialect = await getDialect() // lit DB_DIALECT + SGBD_URI automatiquement
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Via code
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { createConnection } from '@mosta/orm'
|
|
77
|
+
|
|
78
|
+
const dialect = await createConnection({
|
|
79
|
+
dialect: 'sqlite',
|
|
80
|
+
uri: './data/myapp.db',
|
|
81
|
+
schemaStrategy: 'update', // crée/met à jour les tables automatiquement
|
|
82
|
+
showSql: true, // affiche les requêtes générées
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Valeurs de `schemaStrategy`
|
|
87
|
+
|
|
88
|
+
| Valeur | Comportement | Usage |
|
|
89
|
+
|--------|-------------|-------|
|
|
90
|
+
| `update` | Crée les tables manquantes, ajoute les colonnes | Développement |
|
|
91
|
+
| `create` | Supprime et recrée tout au démarrage | Tests |
|
|
92
|
+
| `create-drop` | Supprime au démarrage ET à l'arrêt | Tests unitaires |
|
|
93
|
+
| `validate` | Vérifie le schéma, refuse de démarrer si incohérent | Production |
|
|
94
|
+
| `none` | Ne touche rien | Production (migrations manuelles) |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 3. Définir une entité (EntitySchema)
|
|
99
|
+
|
|
100
|
+
Un `EntitySchema` est l'équivalent d'une `@Entity` JPA ou d'un modèle Mongoose — il décrit la structure d'une table/collection.
|
|
101
|
+
|
|
102
|
+
### Exemple : entité `Product`
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// schemas/product.schema.ts
|
|
106
|
+
import type { EntitySchema } from '@mosta/orm'
|
|
107
|
+
|
|
108
|
+
export const ProductSchema: EntitySchema = {
|
|
109
|
+
name: 'Product',
|
|
110
|
+
collection: 'products', // nom de la table SQL ou collection MongoDB
|
|
111
|
+
timestamps: true, // ajoute createdAt + updatedAt automatiquement
|
|
112
|
+
|
|
113
|
+
fields: {
|
|
114
|
+
name: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
required: true,
|
|
117
|
+
trim: true,
|
|
118
|
+
},
|
|
119
|
+
description: {
|
|
120
|
+
type: 'string',
|
|
121
|
+
},
|
|
122
|
+
price: {
|
|
123
|
+
type: 'number',
|
|
124
|
+
required: true,
|
|
125
|
+
},
|
|
126
|
+
stock: {
|
|
127
|
+
type: 'number',
|
|
128
|
+
default: 0,
|
|
129
|
+
},
|
|
130
|
+
category: {
|
|
131
|
+
type: 'string',
|
|
132
|
+
enum: ['electronics', 'clothing', 'food', 'books'],
|
|
133
|
+
required: true,
|
|
134
|
+
},
|
|
135
|
+
active: {
|
|
136
|
+
type: 'boolean',
|
|
137
|
+
default: true,
|
|
138
|
+
},
|
|
139
|
+
tags: {
|
|
140
|
+
type: 'array',
|
|
141
|
+
arrayOf: 'string',
|
|
142
|
+
},
|
|
143
|
+
metadata: {
|
|
144
|
+
type: 'json', // stocké comme JSON / JSONB selon le dialecte
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
relations: {
|
|
149
|
+
category: {
|
|
150
|
+
type: 'many-to-one',
|
|
151
|
+
target: 'Category',
|
|
152
|
+
required: false,
|
|
153
|
+
},
|
|
154
|
+
reviews: {
|
|
155
|
+
type: 'one-to-many',
|
|
156
|
+
target: 'Review',
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
indexes: [
|
|
161
|
+
{ fields: { name: 'asc' }, unique: true },
|
|
162
|
+
{ fields: { category: 'asc', price: 'asc' } },
|
|
163
|
+
{ fields: { name: 'text' } }, // index full-text
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Types de champs disponibles
|
|
169
|
+
|
|
170
|
+
| Type | SQL | MongoDB | Description |
|
|
171
|
+
|------|-----|---------|-------------|
|
|
172
|
+
| `string` | `VARCHAR(255)` / `TEXT` | `String` | Texte |
|
|
173
|
+
| `number` | `REAL` / `NUMERIC` | `Number` | Entier ou décimal |
|
|
174
|
+
| `boolean` | `INTEGER (0/1)` | `Boolean` | Vrai/faux |
|
|
175
|
+
| `date` | `DATETIME` / `TIMESTAMP` | `Date` | Date et heure |
|
|
176
|
+
| `json` | `TEXT` / `JSONB` | `Mixed` | Objet structuré |
|
|
177
|
+
| `array` | `TEXT (JSON sérialisé)` | `Array` | Liste de valeurs |
|
|
178
|
+
|
|
179
|
+
### Champ embarqué (sous-document)
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
fields: {
|
|
183
|
+
address: {
|
|
184
|
+
type: 'json', // ou utiliser arrayOf pour des tableaux de sous-docs
|
|
185
|
+
// Pour un tableau de sous-documents structurés :
|
|
186
|
+
},
|
|
187
|
+
schedules: {
|
|
188
|
+
type: 'array',
|
|
189
|
+
arrayOf: {
|
|
190
|
+
kind: 'embedded',
|
|
191
|
+
fields: {
|
|
192
|
+
day: { type: 'string', required: true },
|
|
193
|
+
start: { type: 'string', required: true },
|
|
194
|
+
end: { type: 'string', required: true },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 4. Créer un Repository
|
|
204
|
+
|
|
205
|
+
Un repository encapsule toutes les opérations sur une entité. Étendez `BaseRepository` pour ajouter des méthodes métier.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// repositories/product.repository.ts
|
|
209
|
+
import { BaseRepository, type IDialect } from '@mosta/orm'
|
|
210
|
+
import { ProductSchema } from '../schemas/product.schema.js'
|
|
211
|
+
|
|
212
|
+
export interface Product {
|
|
213
|
+
id: string
|
|
214
|
+
name: string
|
|
215
|
+
description?: string
|
|
216
|
+
price: number
|
|
217
|
+
stock: number
|
|
218
|
+
category: string
|
|
219
|
+
active: boolean
|
|
220
|
+
tags?: string[]
|
|
221
|
+
createdAt?: Date
|
|
222
|
+
updatedAt?: Date
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class ProductRepository extends BaseRepository<Product> {
|
|
226
|
+
constructor(dialect: IDialect) {
|
|
227
|
+
super(ProductSchema, dialect)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Méthodes métier personnalisées
|
|
231
|
+
async findActive(): Promise<Product[]> {
|
|
232
|
+
return this.findAll({ active: true }, { sort: { name: 1 } })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async findByCategory(category: string): Promise<Product[]> {
|
|
236
|
+
return this.findAll({ category, active: true })
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async findInPriceRange(min: number, max: number): Promise<Product[]> {
|
|
240
|
+
return this.findAll({
|
|
241
|
+
price: { $gte: min, $lte: max },
|
|
242
|
+
active: true,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async deactivate(id: string): Promise<Product | null> {
|
|
247
|
+
return this.update(id, { active: false })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async adjustStock(id: string, delta: number): Promise<Product | null> {
|
|
251
|
+
return this.increment(id, 'stock', delta)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Enregistrer les schémas et créer le repository
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
// db.ts
|
|
260
|
+
import { createConnection, registerSchemas } from '@mosta/orm'
|
|
261
|
+
import { ProductSchema } from './schemas/product.schema.js'
|
|
262
|
+
import { ProductRepository } from './repositories/product.repository.js'
|
|
263
|
+
|
|
264
|
+
let productRepo: ProductRepository
|
|
265
|
+
|
|
266
|
+
export async function initDB() {
|
|
267
|
+
// Enregistrer tous les schémas
|
|
268
|
+
registerSchemas([ProductSchema /*, CategorySchema, ReviewSchema */])
|
|
269
|
+
|
|
270
|
+
// Connecter (singleton — sûr d'appeler plusieurs fois)
|
|
271
|
+
const dialect = await createConnection({
|
|
272
|
+
dialect: 'sqlite',
|
|
273
|
+
uri: './data/shop.db',
|
|
274
|
+
schemaStrategy: 'update',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
productRepo = new ProductRepository(dialect)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function getProductRepo(): ProductRepository {
|
|
281
|
+
if (!productRepo) throw new Error('DB not initialized — call initDB() first')
|
|
282
|
+
return productRepo
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 5. Opérations CRUD
|
|
289
|
+
|
|
290
|
+
### Créer
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
const product = await productRepo.create({
|
|
294
|
+
name: 'Laptop Pro 15"',
|
|
295
|
+
price: 1299.99,
|
|
296
|
+
stock: 50,
|
|
297
|
+
category: 'electronics',
|
|
298
|
+
tags: ['laptop', 'portable', 'pro'],
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
console.log(product.id) // UUID généré automatiquement
|
|
302
|
+
console.log(product.createdAt) // Date de création (si timestamps: true)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Lire
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
// Par ID
|
|
309
|
+
const product = await productRepo.findById('abc123')
|
|
310
|
+
|
|
311
|
+
// Avec filtre
|
|
312
|
+
const products = await productRepo.findAll({ category: 'electronics' })
|
|
313
|
+
|
|
314
|
+
// Premier résultat
|
|
315
|
+
const cheapest = await productRepo.findOne(
|
|
316
|
+
{ category: 'electronics' },
|
|
317
|
+
{ sort: { price: 1 } }
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
// Avec pagination
|
|
321
|
+
const page1 = await productRepo.findAll(
|
|
322
|
+
{ active: true },
|
|
323
|
+
{ sort: { createdAt: -1 }, skip: 0, limit: 20 }
|
|
324
|
+
)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### Mettre à jour
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
// Mise à jour partielle — seuls les champs fournis sont modifiés
|
|
331
|
+
const updated = await productRepo.update('abc123', {
|
|
332
|
+
price: 999.99,
|
|
333
|
+
stock: 45,
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Mise à jour de masse
|
|
337
|
+
const count = await productRepo.updateMany(
|
|
338
|
+
{ category: 'electronics', stock: { $lt: 5 } },
|
|
339
|
+
{ active: false }
|
|
340
|
+
)
|
|
341
|
+
console.log(`${count} produits désactivés`)
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Supprimer
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// Par ID
|
|
348
|
+
const deleted = await productRepo.delete('abc123') // true | false
|
|
349
|
+
|
|
350
|
+
// Suppression de masse
|
|
351
|
+
const count = await productRepo.deleteMany({ active: false })
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
### Compter
|
|
355
|
+
|
|
356
|
+
```typescript
|
|
357
|
+
const total = await productRepo.count()
|
|
358
|
+
const active = await productRepo.count({ active: true })
|
|
359
|
+
const expensive = await productRepo.count({ price: { $gt: 1000 } })
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
### Upsert (créer ou mettre à jour)
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
// Équivalent Hibernate saveOrUpdate()
|
|
366
|
+
const product = await productRepo.upsert(
|
|
367
|
+
{ name: 'Laptop Pro 15"' }, // critère de recherche
|
|
368
|
+
{ name: 'Laptop Pro 15"', price: 1299.99, stock: 50, category: 'electronics' }
|
|
369
|
+
)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## 6. Filtres avancés
|
|
375
|
+
|
|
376
|
+
MostaORM offre une API de filtrage inspirée de MongoDB, traduite automatiquement en SQL.
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
// Égalité (implicite)
|
|
380
|
+
await productRepo.findAll({ category: 'electronics' })
|
|
381
|
+
|
|
382
|
+
// Comparaisons
|
|
383
|
+
await productRepo.findAll({ price: { $gt: 100, $lte: 500 } })
|
|
384
|
+
await productRepo.findAll({ stock: { $gte: 1 } })
|
|
385
|
+
|
|
386
|
+
// Inclusion / exclusion
|
|
387
|
+
await productRepo.findAll({ category: { $in: ['electronics', 'books'] } })
|
|
388
|
+
await productRepo.findAll({ category: { $nin: ['food'] } })
|
|
389
|
+
|
|
390
|
+
// Inégalité
|
|
391
|
+
await productRepo.findAll({ category: { $ne: 'food' } })
|
|
392
|
+
|
|
393
|
+
// Existence d'un champ
|
|
394
|
+
await productRepo.findAll({ description: { $exists: true } })
|
|
395
|
+
|
|
396
|
+
// Regex (recherche partielle)
|
|
397
|
+
await productRepo.findAll({ name: { $regex: 'laptop', $regexFlags: 'i' } })
|
|
398
|
+
|
|
399
|
+
// Opérateurs logiques
|
|
400
|
+
await productRepo.findAll({
|
|
401
|
+
$or: [
|
|
402
|
+
{ price: { $lt: 50 } },
|
|
403
|
+
{ category: 'food' },
|
|
404
|
+
],
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
await productRepo.findAll({
|
|
408
|
+
$and: [
|
|
409
|
+
{ active: true },
|
|
410
|
+
{ stock: { $gt: 0 } },
|
|
411
|
+
{ price: { $lte: 100 } },
|
|
412
|
+
],
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Options de requête (QueryOptions)
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
await productRepo.findAll(
|
|
420
|
+
{ active: true },
|
|
421
|
+
{
|
|
422
|
+
sort: { price: 1, name: 1 }, // 1 = ASC, -1 = DESC
|
|
423
|
+
skip: 40, // décalage (pagination)
|
|
424
|
+
limit: 20, // maximum de résultats
|
|
425
|
+
select: ['id', 'name', 'price'], // projection — champs à inclure
|
|
426
|
+
exclude: ['metadata'], // champs à exclure
|
|
427
|
+
}
|
|
428
|
+
)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## 7. Relations entre entités
|
|
434
|
+
|
|
435
|
+
### Définir les schémas liés
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
// schemas/category.schema.ts
|
|
439
|
+
export const CategorySchema: EntitySchema = {
|
|
440
|
+
name: 'Category',
|
|
441
|
+
collection: 'categories',
|
|
442
|
+
timestamps: false,
|
|
443
|
+
fields: {
|
|
444
|
+
name: { type: 'string', required: true, unique: true },
|
|
445
|
+
slug: { type: 'string', required: true, unique: true },
|
|
446
|
+
},
|
|
447
|
+
relations: {},
|
|
448
|
+
indexes: [],
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Dans ProductSchema
|
|
452
|
+
relations: {
|
|
453
|
+
category: {
|
|
454
|
+
type: 'many-to-one',
|
|
455
|
+
target: 'Category',
|
|
456
|
+
select: ['id', 'name', 'slug'], // champs à inclure lors du JOIN/populate
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### Charger avec les relations
|
|
462
|
+
|
|
463
|
+
```typescript
|
|
464
|
+
// Un produit avec sa catégorie chargée
|
|
465
|
+
const product = await productRepo.findByIdWithRelations(
|
|
466
|
+
'abc123',
|
|
467
|
+
['category']
|
|
468
|
+
)
|
|
469
|
+
// product.category = { id: '...', name: 'Electronics', slug: 'electronics' }
|
|
470
|
+
|
|
471
|
+
// Plusieurs produits avec relations
|
|
472
|
+
const products = await productRepo.findWithRelations(
|
|
473
|
+
{ active: true },
|
|
474
|
+
['category', 'reviews'],
|
|
475
|
+
{ sort: { name: 1 } }
|
|
476
|
+
)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### Types de relations
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
relations: {
|
|
483
|
+
// Un produit appartient à une catégorie
|
|
484
|
+
category: {
|
|
485
|
+
type: 'many-to-one',
|
|
486
|
+
target: 'Category',
|
|
487
|
+
required: true,
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
// Un produit a plusieurs avis
|
|
491
|
+
reviews: {
|
|
492
|
+
type: 'one-to-many',
|
|
493
|
+
target: 'Review',
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
// Relation many-to-many via table de jonction
|
|
497
|
+
tags: {
|
|
498
|
+
type: 'many-to-many',
|
|
499
|
+
target: 'Tag',
|
|
500
|
+
through: 'product_tags', // nom de la table de jonction (SQL)
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
// Un produit a un seul profil technique
|
|
504
|
+
specs: {
|
|
505
|
+
type: 'one-to-one',
|
|
506
|
+
target: 'ProductSpec',
|
|
507
|
+
nullable: true,
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
---
|
|
513
|
+
|
|
514
|
+
## 8. Agrégations
|
|
515
|
+
|
|
516
|
+
L'API d'agrégation est inspirée du pipeline MongoDB — traduit en SQL GROUP BY / HAVING.
|
|
517
|
+
|
|
518
|
+
### Compter par catégorie
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
const stats = await productRepo.aggregate([
|
|
522
|
+
{ $match: { active: true } },
|
|
523
|
+
{
|
|
524
|
+
$group: {
|
|
525
|
+
_by: 'category',
|
|
526
|
+
count: { $count: true },
|
|
527
|
+
avgPrice: { $avg: 'price' },
|
|
528
|
+
minPrice: { $min: 'price' },
|
|
529
|
+
maxPrice: { $max: 'price' },
|
|
530
|
+
totalStock: { $sum: 'stock' },
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
{ $sort: { count: -1 } },
|
|
534
|
+
])
|
|
535
|
+
|
|
536
|
+
// Résultat :
|
|
537
|
+
// [
|
|
538
|
+
// { _by: 'electronics', count: 42, avgPrice: 450, minPrice: 9.99, maxPrice: 2999, totalStock: 380 },
|
|
539
|
+
// { _by: 'books', count: 120, avgPrice: 22, minPrice: 2.99, maxPrice: 89, totalStock: 1500 },
|
|
540
|
+
// ]
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### Valeurs distinctes
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
const categories = await productRepo.distinct('category')
|
|
547
|
+
// ['electronics', 'clothing', 'food', 'books']
|
|
548
|
+
|
|
549
|
+
const brands = await productRepo.distinct('brand', { active: true })
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### Accumulateurs disponibles
|
|
553
|
+
|
|
554
|
+
| Accumulateur | SQL | Description |
|
|
555
|
+
|-------------|-----|-------------|
|
|
556
|
+
| `$count: true` | `COUNT(*)` | Nombre de documents |
|
|
557
|
+
| `$sum: 'field'` | `SUM(field)` | Somme d'un champ numérique |
|
|
558
|
+
| `$sum: 1` | `COUNT(*)` | Comptage (alias) |
|
|
559
|
+
| `$avg: 'field'` | `AVG(field)` | Moyenne |
|
|
560
|
+
| `$min: 'field'` | `MIN(field)` | Valeur minimale |
|
|
561
|
+
| `$max: 'field'` | `MAX(field)` | Valeur maximale |
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## 9. Recherche plein texte
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
// Recherche dans tous les champs string du schéma
|
|
569
|
+
const results = await productRepo.search('laptop pro')
|
|
570
|
+
|
|
571
|
+
// Avec options
|
|
572
|
+
const results = await productRepo.search(
|
|
573
|
+
'laptop',
|
|
574
|
+
{ sort: { price: 1 }, limit: 10 }
|
|
575
|
+
)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
> **Note** : La recherche utilise des index `text` sur MongoDB, et `LIKE %query%` sur les dialectes SQL. Pour des recherches avancées, utilisez l'opérateur `$regex` dans les filtres.
|
|
579
|
+
|
|
580
|
+
### Opération sur les tableaux
|
|
581
|
+
|
|
582
|
+
```typescript
|
|
583
|
+
// Ajouter un tag (sans doublon)
|
|
584
|
+
await productRepo.addToSet('abc123', 'tags', 'bestseller')
|
|
585
|
+
|
|
586
|
+
// Retirer un tag
|
|
587
|
+
await productRepo.pull('abc123', 'tags', 'discontinued')
|
|
588
|
+
|
|
589
|
+
// Incrémenter le stock
|
|
590
|
+
await productRepo.increment('abc123', 'stock', -3) // -3 = décrémenter
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## 10. Changer de dialecte
|
|
596
|
+
|
|
597
|
+
C'est la force principale de MostaORM : **zéro changement de code métier** pour changer de base de données.
|
|
598
|
+
|
|
599
|
+
### Avant (SQLite local)
|
|
600
|
+
|
|
601
|
+
```env
|
|
602
|
+
DB_DIALECT=sqlite
|
|
603
|
+
SGBD_URI=./data/shop.db
|
|
604
|
+
DB_SCHEMA_STRATEGY=update
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### Après (PostgreSQL en production)
|
|
608
|
+
|
|
609
|
+
```env
|
|
610
|
+
DB_DIALECT=postgres
|
|
611
|
+
SGBD_URI=postgresql://user:pass@db.example.com:5432/shopdb
|
|
612
|
+
DB_SCHEMA_STRATEGY=validate
|
|
613
|
+
DB_POOL_SIZE=10
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Le repository `ProductRepository` et tout le code métier restent **identiques**.
|
|
617
|
+
|
|
618
|
+
### Migration SQLite → MongoDB
|
|
619
|
+
|
|
620
|
+
```env
|
|
621
|
+
DB_DIALECT=mongodb
|
|
622
|
+
SGBD_URI=mongodb+srv://user:pass@cluster.mongodb.net/shopdb
|
|
623
|
+
DB_SCHEMA_STRATEGY=update
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Installez le driver : `npm install mongoose`
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## 11. Application Express complète
|
|
631
|
+
|
|
632
|
+
Voici une API REST complète avec MostaORM.
|
|
633
|
+
|
|
634
|
+
### Structure du projet
|
|
635
|
+
|
|
636
|
+
```
|
|
637
|
+
my-shop-api/
|
|
638
|
+
├── src/
|
|
639
|
+
│ ├── schemas/
|
|
640
|
+
│ │ └── product.schema.ts
|
|
641
|
+
│ ├── repositories/
|
|
642
|
+
│ │ └── product.repository.ts
|
|
643
|
+
│ ├── routes/
|
|
644
|
+
│ │ └── products.ts
|
|
645
|
+
│ ├── db.ts
|
|
646
|
+
│ └── index.ts
|
|
647
|
+
├── .env
|
|
648
|
+
└── package.json
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### `src/db.ts`
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
import { createConnection, registerSchemas } from '@mosta/orm'
|
|
655
|
+
import { ProductSchema } from './schemas/product.schema.js'
|
|
656
|
+
import { ProductRepository } from './repositories/product.repository.js'
|
|
657
|
+
|
|
658
|
+
let productRepository: ProductRepository
|
|
659
|
+
|
|
660
|
+
export async function initDB(): Promise<void> {
|
|
661
|
+
registerSchemas([ProductSchema])
|
|
662
|
+
|
|
663
|
+
const dialect = await createConnection({
|
|
664
|
+
dialect: (process.env.DB_DIALECT as any) || 'sqlite',
|
|
665
|
+
uri: process.env.SGBD_URI || './data/shop.db',
|
|
666
|
+
schemaStrategy: 'update',
|
|
667
|
+
showSql: process.env.NODE_ENV !== 'production',
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
productRepository = new ProductRepository(dialect)
|
|
671
|
+
console.log('✅ Database connected')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export const repos = {
|
|
675
|
+
get products() { return productRepository },
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### `src/routes/products.ts`
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
import { Router } from 'express'
|
|
683
|
+
import { repos } from '../db.js'
|
|
684
|
+
|
|
685
|
+
const router = Router()
|
|
686
|
+
|
|
687
|
+
// GET /products?category=electronics&page=1&limit=20
|
|
688
|
+
router.get('/', async (req, res) => {
|
|
689
|
+
try {
|
|
690
|
+
const { category, search, page = '1', limit = '20', minPrice, maxPrice } = req.query
|
|
691
|
+
|
|
692
|
+
const filter: any = { active: true }
|
|
693
|
+
if (category) filter.category = category
|
|
694
|
+
if (minPrice || maxPrice) {
|
|
695
|
+
filter.price = {}
|
|
696
|
+
if (minPrice) filter.price.$gte = Number(minPrice)
|
|
697
|
+
if (maxPrice) filter.price.$lte = Number(maxPrice)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const pageNum = parseInt(page as string)
|
|
701
|
+
const limitNum = parseInt(limit as string)
|
|
702
|
+
|
|
703
|
+
let products
|
|
704
|
+
if (search) {
|
|
705
|
+
products = await repos.products.search(search as string, { limit: limitNum })
|
|
706
|
+
} else {
|
|
707
|
+
products = await repos.products.findAll(filter, {
|
|
708
|
+
sort: { name: 1 },
|
|
709
|
+
skip: (pageNum - 1) * limitNum,
|
|
710
|
+
limit: limitNum,
|
|
711
|
+
})
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const total = await repos.products.count(filter)
|
|
715
|
+
|
|
716
|
+
res.json({
|
|
717
|
+
data: products,
|
|
718
|
+
meta: {
|
|
719
|
+
total,
|
|
720
|
+
page: pageNum,
|
|
721
|
+
limit: limitNum,
|
|
722
|
+
totalPages: Math.ceil(total / limitNum),
|
|
723
|
+
},
|
|
724
|
+
})
|
|
725
|
+
} catch (err) {
|
|
726
|
+
res.status(500).json({ error: 'Internal server error' })
|
|
727
|
+
}
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// GET /products/:id
|
|
731
|
+
router.get('/:id', async (req, res) => {
|
|
732
|
+
const product = await repos.products.findById(req.params.id)
|
|
733
|
+
if (!product) return res.status(404).json({ error: 'Product not found' })
|
|
734
|
+
res.json({ data: product })
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
// POST /products
|
|
738
|
+
router.post('/', async (req, res) => {
|
|
739
|
+
try {
|
|
740
|
+
const product = await repos.products.create(req.body)
|
|
741
|
+
res.status(201).json({ data: product })
|
|
742
|
+
} catch (err: any) {
|
|
743
|
+
res.status(400).json({ error: err.message })
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
// PUT /products/:id
|
|
748
|
+
router.put('/:id', async (req, res) => {
|
|
749
|
+
const product = await repos.products.update(req.params.id, req.body)
|
|
750
|
+
if (!product) return res.status(404).json({ error: 'Product not found' })
|
|
751
|
+
res.json({ data: product })
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
// DELETE /products/:id
|
|
755
|
+
router.delete('/:id', async (req, res) => {
|
|
756
|
+
const deleted = await repos.products.delete(req.params.id)
|
|
757
|
+
if (!deleted) return res.status(404).json({ error: 'Product not found' })
|
|
758
|
+
res.status(204).send()
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
// GET /products/stats/by-category
|
|
762
|
+
router.get('/stats/by-category', async (req, res) => {
|
|
763
|
+
const stats = await repos.products.aggregate([
|
|
764
|
+
{ $match: { active: true } },
|
|
765
|
+
{
|
|
766
|
+
$group: {
|
|
767
|
+
_by: 'category',
|
|
768
|
+
count: { $count: true },
|
|
769
|
+
avgPrice: { $avg: 'price' },
|
|
770
|
+
totalStock: { $sum: 'stock' },
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
{ $sort: { count: -1 } },
|
|
774
|
+
])
|
|
775
|
+
res.json({ data: stats })
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
export default router
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### `src/index.ts`
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
import 'dotenv/config'
|
|
785
|
+
import express from 'express'
|
|
786
|
+
import { initDB } from './db.js'
|
|
787
|
+
import productsRouter from './routes/products.js'
|
|
788
|
+
|
|
789
|
+
const app = express()
|
|
790
|
+
app.use(express.json())
|
|
791
|
+
|
|
792
|
+
app.use('/api/products', productsRouter)
|
|
793
|
+
|
|
794
|
+
app.get('/health', (_, res) => res.json({ status: 'ok' }))
|
|
795
|
+
|
|
796
|
+
async function start() {
|
|
797
|
+
await initDB()
|
|
798
|
+
const PORT = process.env.PORT || 3000
|
|
799
|
+
app.listen(PORT, () => {
|
|
800
|
+
console.log(`🚀 Server running on http://localhost:${PORT}`)
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
start().catch(console.error)
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
### `.env`
|
|
808
|
+
|
|
809
|
+
```env
|
|
810
|
+
DB_DIALECT=sqlite
|
|
811
|
+
SGBD_URI=./data/shop.db
|
|
812
|
+
DB_SCHEMA_STRATEGY=update
|
|
813
|
+
DB_SHOW_SQL=true
|
|
814
|
+
PORT=3000
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
### Lancer l'application
|
|
818
|
+
|
|
819
|
+
```bash
|
|
820
|
+
npm install better-sqlite3 express dotenv
|
|
821
|
+
npx tsc
|
|
822
|
+
node dist/index.js
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Test rapide :
|
|
826
|
+
|
|
827
|
+
```bash
|
|
828
|
+
# Créer un produit
|
|
829
|
+
curl -X POST http://localhost:3000/api/products \
|
|
830
|
+
-H 'Content-Type: application/json' \
|
|
831
|
+
-d '{"name":"Laptop Pro","price":999,"stock":10,"category":"electronics"}'
|
|
832
|
+
|
|
833
|
+
# Lister les produits
|
|
834
|
+
curl http://localhost:3000/api/products?category=electronics
|
|
835
|
+
|
|
836
|
+
# Statistiques par catégorie
|
|
837
|
+
curl http://localhost:3000/api/products/stats/by-category
|
|
838
|
+
```
|
|
839
|
+
|
|
840
|
+
---
|
|
841
|
+
|
|
842
|
+
## Étapes suivantes
|
|
843
|
+
|
|
844
|
+
- [docs/dialects.md](./dialects.md) — Configuration détaillée de chaque dialecte
|
|
845
|
+
- [docs/api-reference.md](./api-reference.md) — Référence complète de l'API
|
|
846
|
+
- [examples/](../examples/) — Exemples complets (contact-manager, balloon-booking)
|