From 1cda22432d67d8fc2862af3fe431baf440bfbca7 Mon Sep 17 00:00:00 2001 From: MADANI Date: Fri, 12 Jun 2026 16:45:16 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat(dialect):=20ajoute=20Firebird=20(16e?= =?UTF-8?q?=20dialecte,=20OLTP=20=E2=80=94=20valid=C3=A9=20live=20sur=20am?= =?UTF-8?q?ia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialecte `firebird` (AbstractSqlDialect + quirks) pour Firebird 3.0+. Driver pur-JS node-firebird (peer optionnel). Connexion serveur ; id UUID pré-généré. Quirks gérés : - pagination ROWS m TO n (pas de LIMIT/OFFSET) ; - boolean → SMALLINT 0/1 (node-firebird binde les booléens en '1'/'0' → BOOLEAN natif lève -303) ; - text/json/array → VARCHAR(4000) (node-firebird hange à la lecture des BLOB sur wire chiffré) ; - DROP TABLE multi-passes (ni IF EXISTS ni CASCADE) ; - introspection RDB$RELATIONS/RDB$RELATION_FIELDS ; - auth Srp + wireCrypt forcés (négociation auto du driver plante) ; - insensible-casse via UPPER(col) LIKE UPPER(?). Câblage : DialectType, DIALECT_LOADERS, DIALECT_CONFIGS, peerDependencies (optionnel). Release 2.7.0 : CHANGELOG, llms.txt, README (16 bases). Validé LIVE sur firebird3.0-server natif (sans Docker) : test-sgbd 20/20. Limites : texte/JSON ≤ 4000 car. ; affected-rows approximatif (non exposé par le driver). Author: Dr Hamid MADANI --- CHANGELOG.md | 26 +++ README.md | 19 +- dist/core/config.js | 4 + dist/core/factory.js | 1 + dist/core/types.d.ts | 2 +- dist/dialects/firebird.dialect.d.ts | 53 ++++++ dist/dialects/firebird.dialect.js | 233 ++++++++++++++++++++++++ llms.txt | 10 +- package.json | 8 +- src/core/config.ts | 4 + src/core/factory.ts | 1 + src/core/types.ts | 3 +- src/dialects/firebird.dialect.ts | 271 ++++++++++++++++++++++++++++ 13 files changed, 617 insertions(+), 18 deletions(-) create mode 100644 dist/dialects/firebird.dialect.d.ts create mode 100644 dist/dialects/firebird.dialect.js create mode 100644 src/dialects/firebird.dialect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dbfca..b536653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to `@mostajs/orm` will be documented in this file. +## [2.7.0] — 2026-06-12 + +### Feat — Dialecte Firebird (16e dialecte · OLTP relationnel) + +Nouveau dialecte `firebird` (`AbstractSqlDialect` + quirks) pour **Firebird 3.0+** +(lignée InterBase). Driver pur-JS `node-firebird` (peer optionnel). Connexion serveur +ou via tunnel ; id UUID pré-généré (pas de generators). + +- **CRUD** complet, relations (many-to-one), filtres, count, upsert. +- Connexion : `firebird://user:password@host:port/chemin.fdb[?role=&plugin=&wireCrypt=&create=true]`. +- **Quirks gérés** : pagination `ROWS m TO n` (pas de LIMIT/OFFSET) ; `boolean → SMALLINT` + 0/1 (node-firebird binde les booléens en `'1'`/`'0'` → BOOLEAN natif lève `-303`) ; + `text/json/array → VARCHAR(4000)` (node-firebird **hange** à la lecture des BLOB sur + wire chiffré) ; `DROP TABLE` multi-passes (ni `IF EXISTS` ni `CASCADE`) ; introspection + `RDB$RELATIONS`/`RDB$RELATION_FIELDS` ; auth `Srp` + `wireCrypt` forcés (négociation auto + du driver plante) ; insensible-casse via `UPPER(col) LIKE UPPER(?)`. + +Câblage : `DialectType`, `DIALECT_LOADERS`, `DIALECT_CONFIGS`, `peerDependencies` +(`node-firebird` optionnel). + +**Validé LIVE** sur un serveur `firebird3.0-server` natif (sans Docker, amia) : +harnais `test-sgbd` **20/20**. Limites connues : contenu texte/JSON ≤ 4000 car. ; +compteur d'affected-rows approximatif (`updateMany`/`deleteMany`) — non exposé par le driver. + +Rapport de validation HTML : générateur réutilisable `test-scripts/sgbd-html-report.mjs`. + ## [2.6.1] — 2026-06-12 ### Chore — packaging npm allégé (aucun changement de code) diff --git a/README.md b/README.md index 541c6c4..a96f185 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # @mostajs/orm -> **Plug & Play ORM to Drive 15 Databases at Once** +> **Plug & Play ORM to Drive 16 Databases at Once** [![npm version](https://img.shields.io/npm/v/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![npm downloads](https://img.shields.io/npm/dm/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE) -[![dialects](https://img.shields.io/badge/dialects-15-success.svg)](#databases) +[![dialects](https://img.shields.io/badge/dialects-16-success.svg)](#databases) [![Types: TypeScript](https://img.shields.io/badge/types-TypeScript-blue.svg)](https://www.typescriptlang.org/) [![bundle size](https://img.shields.io/bundlephobia/minzip/@mostajs/orm)](https://bundlephobia.com/package/@mostajs/orm) -Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 15 databases, zero lock-in, bundler-friendly**. +Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 16 databases, zero lock-in, bundler-friendly**. 📦 **npm** · https://www.npmjs.com/package/@mostajs/orm 🐙 **GitHub** · https://github.com/apolocine/mosta-orm @@ -28,10 +28,10 @@ cd ~/my-app && ./01-quickstart-sqlite.sh # runnable in 30 seconds ## Why @mostajs/orm ? -- 🎯 **One API, 15 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. +- 🎯 **One API, 16 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. - 🪶 **Zero lock-in.** Native drivers, no proprietary query DSL — your SQL/NoSQL stays portable. - 🧬 **Hibernate / JPA semantics.** `@OneToMany`, cascade types, `SAVEPOINT`, schema strategies (`validate`/`update`/`create`/`create-drop`) — concepts battle-tested for 25 years, ported to TypeScript. -- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 15 databases. +- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 16 databases. - 🔁 **Cross-dialect replication built-in.** [`@mostajs/replicator`](https://www.npmjs.com/package/@mostajs/replicator) — CDC + master/slave + failover across SQL ↔ MongoDB. - 🧪 **Bundler-friendly.** Tree-shakable ESM, no `eval`, works with esbuild / Vite / Next.js / Bun out of the box. - 🏷️ **Multi-app DB cohabitation** *(v2.3.0+)*. `DB_TABLE_PREFIX` à la Hibernate `physical_naming_strategy` — let two apps share one Oracle/MSSQL/HANA DB user without colliding on `users`/`roles`/`permissions`. @@ -118,7 +118,7 @@ Working from an **AI dev tool** (Cursor, Cline, Claude…)? Generate schemas, li | | @mostajs/orm | Prisma | Drizzle | TypeORM | |---|:---:|:---:|:---:|:---:| -| SQL dialects | **10** *(PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, HANA, Cockroach, DuckDB…)* | 5 | 5 | 8 | +| SQL dialects | **11** *(PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, HANA, Cockroach, DuckDB, Firebird…)* | 5 | 5 | 8 | | NoSQL dialects | **MongoDB + Firestore native** | ❌ | ❌ | ❌ | | Same API across SQL & NoSQL | ✅ | ❌ | ❌ | ❌ | | Browser / WebContainer / edge | ✅ *(WASM `sqljs` — zero native binary)* | ⚠️ *(Accelerate, paid)* | ⚠️ *(driver)* | ❌ | @@ -278,10 +278,11 @@ If `@mostajs/orm` saves you days of glue code, please : ## Databases -SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore +SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird - **`duckdb`** — OLAP **in-process** engine (file or `:memory:`), SQL ≈ PostgreSQL. Analytics without a server. - **`firestore`** — Google Cloud **Firestore**, NoSQL document store (Mongo-style API). Remote (gRPC/TLS) or local **Java emulator** (no Docker, no key); production via a service-account key. Full-text search delegates to an external search module. +- **`firebird`** — **Firebird 3.0+** OLTP relational engine (InterBase lineage). Pure-JS `node-firebird` driver; `ROWS` pagination, UUID ids. Validated live against a native server. **+ WASM runtimes** — two zero-binary dialects run in WebAssembly, so the same ORM **boots in the browser / Bolt.new / Cloudflare Workers with no native binary**: @@ -347,7 +348,7 @@ Because the WASM build needs **no native binary and no server**, the same typed ```bash npm install @mostajs/orm # + the driver for your dialect : -npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb +npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird npm install @google-cloud/firestore # Firestore — NoSQL document store (dialect: 'firestore'); Java emulator in dev, GCP key in prod npm install sql.js # SQLite in the browser / Bolt.new / Workers — no native binary (dialect: 'sqljs') npm install @electric-sql/pglite # PostgreSQL in the browser — idb:// persistence (dialect: 'pglite') @@ -1063,7 +1064,7 @@ const conn = getNamedConnection('audit') | Package | Description | |---|---| -| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 15 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | +| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 16 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | | [@mostajs/orm-cli](https://www.npmjs.com/package/@mostajs/orm-cli) | `npx @mostajs/orm-cli` — interactive CLI : convert schemas, init databases, scaffold services, replicator + monitor, seeding, bootstrap Prisma migration. | | [@mostajs/orm-adapter](https://www.npmjs.com/package/@mostajs/orm-adapter) | Convert Prisma / JSON Schema / OpenAPI / native `.mjs` to `EntitySchema[]` (bidirectional). | | [@mostajs/replicator](https://www.npmjs.com/package/@mostajs/replicator) | Cross-dialect replication : CQRS master/slave, CDC rules (snapshot + incremental), wildcard `*`, failover (`promoteToMaster`). As of @mostajs/orm v1.13, Mongo FK columns accept UUID strings coming from SQL dialects (populate falls back to `{ id: uuid }` lookup). | diff --git a/dist/core/config.js b/dist/core/config.js index 60fc83e..697759b 100644 --- a/dist/core/config.js +++ b/dist/core/config.js @@ -112,6 +112,10 @@ export const DIALECT_CONFIGS = { installHint: 'npm install @google-cloud/firestore', label: 'Google Cloud Firestore (NoSQL doc — cloud managé / émulateur)', }, + firebird: { + installHint: 'npm install node-firebird', + label: 'Firebird (OLTP relationnel — serveur / embarqué, FB 3.0+)', + }, }; /** * Get the list of supported dialect types diff --git a/dist/core/factory.js b/dist/core/factory.js index 790db67..84b3ee8 100644 --- a/dist/core/factory.js +++ b/dist/core/factory.js @@ -40,6 +40,7 @@ const DIALECT_LOADERS = { sybase: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/sybase.dialect.js'), duckdb: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/duckdb.dialect.js'), firestore: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firestore.dialect.js'), + firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), }; /** * Dynamically load a dialect adapter module. diff --git a/dist/core/types.d.ts b/dist/core/types.d.ts index b6e2fa9..384204b 100644 --- a/dist/core/types.d.ts +++ b/dist/core/types.d.ts @@ -189,7 +189,7 @@ export interface AggregateLimitStage { $limit: number; } export type AggregateStage = AggregateMatchStage | AggregateGroupStage | AggregateSortStage | AggregateLimitStage; -export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore'; +export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) * diff --git a/dist/dialects/firebird.dialect.d.ts b/dist/dialects/firebird.dialect.d.ts new file mode 100644 index 0000000..f752c4b --- /dev/null +++ b/dist/dialects/firebird.dialect.d.ts @@ -0,0 +1,53 @@ +import type { IDialect, DialectType, ConnectionConfig, EntitySchema, FieldDef, QueryOptions } from '../core/types.js'; +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; +/** Forme minimale du driver `node-firebird` (callback-based). */ +interface FbDatabase { + query(sql: string, params: unknown[], cb: (err: unknown, result: unknown) => void): void; + detach(cb?: (err: unknown) => void): void; +} +export declare class FirebirdDialect extends AbstractSqlDialect { + readonly dialectType: DialectType; + /** Exposé pour accès brut en test. */ + db: FbDatabase | null; + quoteIdentifier(name: string): string; + getPlaceholder(_index: number): string; + fieldToSqlType(field: FieldDef): string; + getIdColumnType(): string; + /** Liste des tables utilisateur via la table système RDB$RELATIONS. */ + getTableListQuery(): string; + /** Colonnes existantes via RDB$RELATION_FIELDS (introspection pour ALTER ADD COLUMN). */ + protected getExistingColumns(tableName: string): Promise>; + protected supportsIfNotExists(): boolean; + protected supportsReturning(): boolean; + /** Firebird n'a pas d'ILIKE : insensible à la casse via UPPER(col) LIKE UPPER(?). */ + protected buildRegexCondition(col: string, flags?: string): string; + /** + * Pagination Firebird : `ROWS TO ` (1-based, en SUFFIXE après ORDER BY). + * Pas de LIMIT/OFFSET avant FB 4. Couvre limit seul, skip seul, et les deux. + */ + protected buildLimitOffset(options?: QueryOptions): string; + private queryAsync; + private toError; + doConnect(config: ConnectionConfig): Promise; + doDisconnect(): Promise; + doTestConnection(): Promise; + doExecuteQuery(sql: string, params: unknown[]): Promise; + /** + * node-firebird n'expose pas de compteur d'affected-rows fiable pour les DML + * (INSERT/UPDATE/DELETE → result généralement undefined ; RETURNING → tableau). + * LIMITATION connue : updateMany()/deleteMany() peuvent renvoyer un compte approximatif. + * À revisiter en validation live (parsing isc_info_sql_records ou RETURNING). + */ + doExecuteRun(sql: string, params: unknown[]): Promise<{ + changes: number; + }>; + protected getDropTableSql(tableName: string): string; + /** + * Sans `DROP ... CASCADE`, on supprime en PLUSIEURS PASSES pour résoudre l'ordre + * des clés étrangères (table référençante avant référencée). Idempotent : une + * erreur (FK bloquante OU table absente) est ignorée et retentée à la passe suivante. + */ + dropSchema(schemas: EntitySchema[]): Promise; +} +export declare function createDialect(): IDialect; +export {}; diff --git a/dist/dialects/firebird.dialect.js b/dist/dialects/firebird.dialect.js new file mode 100644 index 0000000..569e6b0 --- /dev/null +++ b/dist/dialects/firebird.dialect.js @@ -0,0 +1,233 @@ +// Firebird Dialect — extends AbstractSqlDialect. +// RDBMS relationnel open-source (lignée InterBase), OLTP, embarqué ou serveur. +// Cible Firebird 3.0+ (BOOLEAN natif). Driver : npm install node-firebird (pur-JS, MPL-2.0). +// +// Quirks gérés (cf. docs/NOUVEAUX-DIALECTES-DUCKDB-CLICKHOUSE-CASSANDRA-FIREBIRD.md §4) : +// - identifiants quotés "lowercase" → sensibles à la casse, cohérents partout ; +// - VARCHAR exige une longueur ; text/json → BLOB SUB_TYPE TEXT (lu en string via blobAsText) ; +// - tables système RDB$RELATIONS / RDB$RELATION_FIELDS (introspection) ; +// - pagination ROWS TO (1-based) — PAS de LIMIT/OFFSET (< FB 4) ; +// - placeholders '?' ; id = UUID pré-généré (VARCHAR(36)) → contourne les generators ; +// - BOOLEAN natif (FB 3.0+). +// +// ⚠ STATUT : code écrit, VALIDATION SUR MOTEUR RÉEL EN ATTENTE (pas de serveur Firebird en CI ; +// rejoint CockroachDB/DB2/HANA/Spanner/Sybase). À valider via test-sgbd.ts firebird. +// +// Author: Dr Hamid MADANI +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; +// ============================================================ +// Type Mapping — DAL FieldType → Firebird column type +// ============================================================ +const FIREBIRD_TYPE_MAP = { + string: 'VARCHAR(255)', + // text/json/array en VARCHAR(4000) et NON en BLOB : node-firebird 2.3.2 HANGE à la + // lecture des BLOB sur wire chiffré (le serveur FB3 impose wireCrypt). VARCHAR évite + // le BLOB. LIMITE : contenu > 4000 caractères non supporté (et le total de colonnes + // larges reste borné par la taille de ligne Firebird ~64 Ko). + text: 'VARCHAR(4000)', + number: 'DOUBLE PRECISION', + // SMALLINT 0/1 et NON le BOOLEAN natif FB3 : node-firebird binde les booléens JS + // comme chaînes '1'/'0' → la colonne BOOLEAN lève -303 (Conversion error). SMALLINT + // accepte l'entier 0/1 (convention par défaut de serialize/deserializeBoolean). + boolean: 'SMALLINT', + date: 'TIMESTAMP', + json: 'VARCHAR(4000)', // JSON sérialisé en texte + array: 'VARCHAR(4000)', +}; +// ============================================================ +// FirebirdDialect +// ============================================================ +export class FirebirdDialect extends AbstractSqlDialect { + dialectType = 'firebird'; + /** Exposé pour accès brut en test. */ + db = null; + // --- Abstract implementations --- + quoteIdentifier(name) { + // Quotes doubles → identifiant sensible à la casse, conservé en minuscules partout. + return `"${name.replace(/"/g, '""')}"`; + } + getPlaceholder(_index) { + return '?'; + } + fieldToSqlType(field) { + return FIREBIRD_TYPE_MAP[field.type] || 'VARCHAR(255)'; + } + getIdColumnType() { + // UUID pré-généré côté ORM (comme les autres dialectes) → pas de generator Firebird. + return 'VARCHAR(36)'; + } + /** Liste des tables utilisateur via la table système RDB$RELATIONS. */ + getTableListQuery() { + return ('SELECT TRIM(RDB$RELATION_NAME) AS name FROM RDB$RELATIONS ' + + 'WHERE RDB$VIEW_BLR IS NULL AND (RDB$SYSTEM_FLAG IS NULL OR RDB$SYSTEM_FLAG = 0)'); + } + /** Colonnes existantes via RDB$RELATION_FIELDS (introspection pour ALTER ADD COLUMN). */ + async getExistingColumns(tableName) { + try { + const rows = await this.executeQuery('SELECT TRIM(RDB$FIELD_NAME) AS name FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ?', [tableName]); + return new Set(rows.map(r => r.name).filter(Boolean)); + } + catch { + return new Set(); + } + } + // --- Hooks (quirks Firebird) --- + // CREATE TABLE IF NOT EXISTS absent avant FB 5.0 → l'abstrait garde via tableExists(). + supportsIfNotExists() { return false; } + // Chemin sûr INSERT puis SELECT (évite RETURNING via le driver). + supportsReturning() { return false; } + // boolean ↔ SMALLINT 0/1 : on garde les défauts de l'abstrait (serializeBoolean → v?1:0, + // deserializeBoolean → v===1|true|'1') ; voir le type-map ci-dessus. + /** Firebird n'a pas d'ILIKE : insensible à la casse via UPPER(col) LIKE UPPER(?). */ + buildRegexCondition(col, flags) { + if (flags?.includes('i')) + return `UPPER(${col}) LIKE UPPER(${this.nextPlaceholder()})`; + return `${col} LIKE ${this.nextPlaceholder()}`; + } + /** + * Pagination Firebird : `ROWS TO ` (1-based, en SUFFIXE après ORDER BY). + * Pas de LIMIT/OFFSET avant FB 4. Couvre limit seul, skip seul, et les deux. + */ + buildLimitOffset(options) { + const limit = options?.limit; + const skip = options?.skip ?? 0; + if (!limit && !skip) + return ''; + const from = skip + 1; + const to = limit ? skip + limit : Number.MAX_SAFE_INTEGER; + return ` ROWS ${from} TO ${to}`; + } + // --- callback → promise --- + queryAsync(sql, params) { + return new Promise((res, rej) => { + if (!this.db) { + rej(new Error('Firebird not connected. Call connect() first.')); + return; + } + this.db.query(sql, params, (err, result) => (err ? rej(this.toError(err)) : res(result))); + }); + } + toError(err) { + if (err instanceof Error) + return err; + const m = err?.message ?? JSON.stringify(err); + return new Error(`Firebird: ${m}`); + } + // --- Connection lifecycle --- + async doConnect(config) { + let Firebird; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node-firebird'); + Firebird = (mod.default ?? mod); + } + catch (e) { + throw new Error(`Firebird driver not found. Install it: npm install node-firebird\n` + + `Original error: ${e instanceof Error ? e.message : String(e)}`); + } + // URI : firebird://user:password@host:port/chemin-ou-alias[?role=&create=true] + const u = new URL(config.uri.replace(/^firebird:\/\//, 'http://')); + const database = decodeURIComponent(u.pathname.replace(/^\//, '')); // /abs/path → abs/path ; //abs → /abs + const options = { + host: u.hostname || '127.0.0.1', + port: u.port ? Number(u.port) : 3050, + database, + user: decodeURIComponent(u.username) || 'SYSDBA', + password: decodeURIComponent(u.password) || 'masterkey', + lowercase_keys: true, // colonnes en minuscules (cohérent avec quoteIdentifier) + blobAsText: true, // BLOB SUB_TYPE TEXT lu directement en string + encoding: 'UTF8', + }; + const role = u.searchParams.get('role'); + if (role) + options.role = role; + // FB 3.0 : le serveur exige souvent le chiffrement wire + plugin Srp. La négociation + // auto du driver plante (tente Srp256 absent). On force des défauts sûrs FB3, + // surchargeables : ?wireCrypt=disable|enable & ?plugin=Srp|Srp256|Legacy_Auth + options.wireCrypt = u.searchParams.get('wireCrypt') === 'disable' ? 0 : 1; // DISABLE=0 / ENABLE=1 + options.pluginName = u.searchParams.get('plugin') ?? 'Srp'; + // create=true → crée la base si absente (pratique en dev, comme les dialectes fichier). + const create = u.searchParams.get('create') === 'true' || config.schemaStrategy === 'create-drop'; + this.db = await new Promise((res, rej) => { + const cb = (err, db) => (err ? rej(this.toError(err)) : res(db)); + if (create) + Firebird.attachOrCreate(options, cb); + else + Firebird.attach(options, cb); + }); + } + async doDisconnect() { + if (!this.db) + return; + const db = this.db; + this.db = null; + await new Promise((res) => db.detach(() => res())); + } + async doTestConnection() { + if (!this.db) + return false; + try { + // Firebird exige un FROM : RDB$DATABASE est la table système mono-ligne. + await this.queryAsync('SELECT 1 FROM RDB$DATABASE', []); + return true; + } + catch (e) { + this.log('TEST_CONNECTION', `down: ${e.message}`); + return false; + } + } + // --- Query execution --- + async doExecuteQuery(sql, params) { + const result = await this.queryAsync(sql, params); + return (Array.isArray(result) ? result : []); + } + /** + * node-firebird n'expose pas de compteur d'affected-rows fiable pour les DML + * (INSERT/UPDATE/DELETE → result généralement undefined ; RETURNING → tableau). + * LIMITATION connue : updateMany()/deleteMany() peuvent renvoyer un compte approximatif. + * À revisiter en validation live (parsing isc_info_sql_records ou RETURNING). + */ + async doExecuteRun(sql, params) { + const result = await this.queryAsync(sql, params); + return { changes: Array.isArray(result) ? result.length : 1 }; + } + // --- DROP : Firebird n'a NI `IF EXISTS` NI `CASCADE` sur DROP TABLE --- + getDropTableSql(tableName) { + return `DROP TABLE ${this.quoteIdentifier(this.getPrefixedName(tableName))}`; + } + /** + * Sans `DROP ... CASCADE`, on supprime en PLUSIEURS PASSES pour résoudre l'ordre + * des clés étrangères (table référençante avant référencée). Idempotent : une + * erreur (FK bloquante OU table absente) est ignorée et retentée à la passe suivante. + */ + async dropSchema(schemas) { + const targets = new Set(); + for (const s of schemas) { + targets.add(s.collection); + for (const rel of Object.values(s.relations || {})) { + if (rel.type === 'many-to-many' && rel.through) + targets.add(rel.through); + } + } + const dropped = []; + const maxPasses = targets.size + 1; + for (let pass = 0; pass < maxPasses && targets.size > 0; pass++) { + for (const name of [...targets]) { + try { + await this.executeRun(this.getDropTableSql(name), []); + targets.delete(name); + dropped.push(name); + } + catch (e) { + this.log('DROP_TABLE', `${name} retenté (${e.message.slice(0, 60)})`); + } + } + } + return dropped; + } +} +// ============================================================ +// Factory export +// ============================================================ +export function createDialect() { + return new FirebirdDialect(); +} diff --git a/llms.txt b/llms.txt index c6aceb5..efde8d8 100644 --- a/llms.txt +++ b/llms.txt @@ -1,15 +1,15 @@ # @mostajs/orm — fiche LLM -> ORM multi-dialecte inspiré d'Hibernate — une seule API, 15 bases de données, zéro lock-in. +> ORM multi-dialecte inspiré d'Hibernate — une seule API, 16 bases de données, zéro lock-in. -- Version: 2.6.1 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI -- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** +- Version: 2.7.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI +- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** ; **dialecte `firebird` (OLTP relationnel FB 3.0+, validé live, 2.7.0)** ## RÔLE Couche d'accès aux données unifiée pour Node.js/TypeScript. Le développeur décrit ses entités sous forme d'objets `EntitySchema` (fields, relations, indexes) et obtient une API CRUD/query identique quel que soit le SGBD cible : MongoDB, SQLite, PostgreSQL, MySQL, MariaDB, Oracle, MSSQL, CockroachDB, DB2, SAP HANA, HSQLDB, Spanner, Sybase, -DuckDB (OLAP in-process) et Firestore (NoSQL documentaire, façon Mongo). +DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo) et Firebird (OLTP, FB 3.0+). Deux dialectes WASM (zéro binaire natif) bootent dans le navigateur, les WebContainers (StackBlitz / Bolt.new) et l'edge — même API/SQL que leur moteur respectif : `sqljs` (SQLite WASM, via sql.js) et `pglite` (PostgreSQL WASM, via @electric-sql/pglite ; @@ -23,7 +23,7 @@ ConnectionConfig = persistence.xml). C'est le module fondation de l'écosystème npm i @mostajs/orm (driver natif requis selon dialecte : better-sqlite3, pg, mysql2, mariadb, oracledb, mssql, ibm_db, @sap/hana-client, @google-cloud/spanner, @google-cloud/firestore, duckdb, -mongoose — peer/optional deps) +node-firebird, mongoose — peer/optional deps) Pour le navigateur / WebContainer / edge (WASM pur, aucun binaire natif — à utiliser dans Bolt.new / StackBlitz / Cloudflare Workers où better-sqlite3/pg ne chargent pas) : - SQLite : `npm i sql.js` + `{ dialect: 'sqljs', uri: ':memory:' }` ; persistance fichier diff --git a/package.json b/package.json index c109a32..0c796a9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mostajs/orm", - "version": "2.6.1", - "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 13 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", + "version": "2.7.0", + "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 16 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", "author": "Dr Hamid MADANI ", "license": "AGPL-3.0-or-later", "type": "module", @@ -103,6 +103,7 @@ "mongoose": ">=7.0.0", "mssql": ">=10.0.0", "mysql2": ">=3.0.0", + "node-firebird": ">=1.1.0", "oracledb": ">=6.0.0", "pg": ">=8.0.0", "sql.js": ">=1.8.0" @@ -126,6 +127,9 @@ "@google-cloud/firestore": { "optional": true }, + "node-firebird": { + "optional": true + }, "pg": { "optional": true }, diff --git a/src/core/config.ts b/src/core/config.ts index e870a40..0d32211 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -124,6 +124,10 @@ export const DIALECT_CONFIGS: Record = { installHint: 'npm install @google-cloud/firestore', label: 'Google Cloud Firestore (NoSQL doc — cloud managé / émulateur)', }, + firebird: { + installHint: 'npm install node-firebird', + label: 'Firebird (OLTP relationnel — serveur / embarqué, FB 3.0+)', + }, }; /** diff --git a/src/core/factory.ts b/src/core/factory.ts index 0c9f9ab..4d33fa3 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -52,6 +52,7 @@ const DIALECT_LOADERS: Record Promise<{ createDialect: () => sybase: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/sybase.dialect.js'), duckdb: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/duckdb.dialect.js'), firestore: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firestore.dialect.js'), + firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), }; /** diff --git a/src/core/types.ts b/src/core/types.ts index 687cedb..4bd8511 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -284,7 +284,8 @@ export type DialectType = | 'spanner' | 'sybase' | 'duckdb' - | 'firestore'; + | 'firestore' + | 'firebird'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) diff --git a/src/dialects/firebird.dialect.ts b/src/dialects/firebird.dialect.ts new file mode 100644 index 0000000..9ab1e17 --- /dev/null +++ b/src/dialects/firebird.dialect.ts @@ -0,0 +1,271 @@ +// Firebird Dialect — extends AbstractSqlDialect. +// RDBMS relationnel open-source (lignée InterBase), OLTP, embarqué ou serveur. +// Cible Firebird 3.0+ (BOOLEAN natif). Driver : npm install node-firebird (pur-JS, MPL-2.0). +// +// Quirks gérés (cf. docs/NOUVEAUX-DIALECTES-DUCKDB-CLICKHOUSE-CASSANDRA-FIREBIRD.md §4) : +// - identifiants quotés "lowercase" → sensibles à la casse, cohérents partout ; +// - VARCHAR exige une longueur ; text/json → BLOB SUB_TYPE TEXT (lu en string via blobAsText) ; +// - tables système RDB$RELATIONS / RDB$RELATION_FIELDS (introspection) ; +// - pagination ROWS TO (1-based) — PAS de LIMIT/OFFSET (< FB 4) ; +// - placeholders '?' ; id = UUID pré-généré (VARCHAR(36)) → contourne les generators ; +// - BOOLEAN natif (FB 3.0+). +// +// ⚠ STATUT : code écrit, VALIDATION SUR MOTEUR RÉEL EN ATTENTE (pas de serveur Firebird en CI ; +// rejoint CockroachDB/DB2/HANA/Spanner/Sybase). À valider via test-sgbd.ts firebird. +// +// Author: Dr Hamid MADANI + +import type { + IDialect, + DialectType, + ConnectionConfig, + EntitySchema, + FieldDef, + QueryOptions, +} from '../core/types.js'; +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; + +// ============================================================ +// Type Mapping — DAL FieldType → Firebird column type +// ============================================================ + +const FIREBIRD_TYPE_MAP: Record = { + string: 'VARCHAR(255)', + // text/json/array en VARCHAR(4000) et NON en BLOB : node-firebird 2.3.2 HANGE à la + // lecture des BLOB sur wire chiffré (le serveur FB3 impose wireCrypt). VARCHAR évite + // le BLOB. LIMITE : contenu > 4000 caractères non supporté (et le total de colonnes + // larges reste borné par la taille de ligne Firebird ~64 Ko). + text: 'VARCHAR(4000)', + number: 'DOUBLE PRECISION', + // SMALLINT 0/1 et NON le BOOLEAN natif FB3 : node-firebird binde les booléens JS + // comme chaînes '1'/'0' → la colonne BOOLEAN lève -303 (Conversion error). SMALLINT + // accepte l'entier 0/1 (convention par défaut de serialize/deserializeBoolean). + boolean: 'SMALLINT', + date: 'TIMESTAMP', + json: 'VARCHAR(4000)', // JSON sérialisé en texte + array: 'VARCHAR(4000)', +}; + +/** Forme minimale du driver `node-firebird` (callback-based). */ +interface FbDatabase { + query(sql: string, params: unknown[], cb: (err: unknown, result: unknown) => void): void; + detach(cb?: (err: unknown) => void): void; +} +interface FbApi { + attach(options: Record, cb: (err: unknown, db: FbDatabase) => void): void; + attachOrCreate(options: Record, cb: (err: unknown, db: FbDatabase) => void): void; +} + +// ============================================================ +// FirebirdDialect +// ============================================================ + +export class FirebirdDialect extends AbstractSqlDialect { + readonly dialectType: DialectType = 'firebird'; + /** Exposé pour accès brut en test. */ + db: FbDatabase | null = null; + + // --- Abstract implementations --- + + quoteIdentifier(name: string): string { + // Quotes doubles → identifiant sensible à la casse, conservé en minuscules partout. + return `"${name.replace(/"/g, '""')}"`; + } + + getPlaceholder(_index: number): string { + return '?'; + } + + fieldToSqlType(field: FieldDef): string { + return FIREBIRD_TYPE_MAP[field.type] || 'VARCHAR(255)'; + } + + getIdColumnType(): string { + // UUID pré-généré côté ORM (comme les autres dialectes) → pas de generator Firebird. + return 'VARCHAR(36)'; + } + + /** Liste des tables utilisateur via la table système RDB$RELATIONS. */ + getTableListQuery(): string { + return ( + 'SELECT TRIM(RDB$RELATION_NAME) AS name FROM RDB$RELATIONS ' + + 'WHERE RDB$VIEW_BLR IS NULL AND (RDB$SYSTEM_FLAG IS NULL OR RDB$SYSTEM_FLAG = 0)' + ); + } + + /** Colonnes existantes via RDB$RELATION_FIELDS (introspection pour ALTER ADD COLUMN). */ + protected async getExistingColumns(tableName: string): Promise> { + try { + const rows = await this.executeQuery<{ name: string }>( + 'SELECT TRIM(RDB$FIELD_NAME) AS name FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ?', + [tableName], + ); + return new Set(rows.map(r => r.name).filter(Boolean)); + } catch { return new Set(); } + } + + // --- Hooks (quirks Firebird) --- + + // CREATE TABLE IF NOT EXISTS absent avant FB 5.0 → l'abstrait garde via tableExists(). + protected supportsIfNotExists(): boolean { return false; } + // Chemin sûr INSERT puis SELECT (évite RETURNING via le driver). + protected supportsReturning(): boolean { return false; } + // boolean ↔ SMALLINT 0/1 : on garde les défauts de l'abstrait (serializeBoolean → v?1:0, + // deserializeBoolean → v===1|true|'1') ; voir le type-map ci-dessus. + + /** Firebird n'a pas d'ILIKE : insensible à la casse via UPPER(col) LIKE UPPER(?). */ + protected buildRegexCondition(col: string, flags?: string): string { + if (flags?.includes('i')) return `UPPER(${col}) LIKE UPPER(${this.nextPlaceholder()})`; + return `${col} LIKE ${this.nextPlaceholder()}`; + } + + /** + * Pagination Firebird : `ROWS TO ` (1-based, en SUFFIXE après ORDER BY). + * Pas de LIMIT/OFFSET avant FB 4. Couvre limit seul, skip seul, et les deux. + */ + protected buildLimitOffset(options?: QueryOptions): string { + const limit = options?.limit; + const skip = options?.skip ?? 0; + if (!limit && !skip) return ''; + const from = skip + 1; + const to = limit ? skip + limit : Number.MAX_SAFE_INTEGER; + return ` ROWS ${from} TO ${to}`; + } + + // --- callback → promise --- + + private queryAsync(sql: string, params: unknown[]): Promise { + return new Promise((res, rej) => { + if (!this.db) { rej(new Error('Firebird not connected. Call connect() first.')); return; } + this.db.query(sql, params, (err, result) => (err ? rej(this.toError(err)) : res(result))); + }); + } + private toError(err: unknown): Error { + if (err instanceof Error) return err; + const m = (err as { message?: string })?.message ?? JSON.stringify(err); + return new Error(`Firebird: ${m}`); + } + + // --- Connection lifecycle --- + + async doConnect(config: ConnectionConfig): Promise { + let Firebird: FbApi; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'node-firebird' as string); + Firebird = ((mod as { default?: unknown }).default ?? mod) as FbApi; + } catch (e: unknown) { + throw new Error( + `Firebird driver not found. Install it: npm install node-firebird\n` + + `Original error: ${e instanceof Error ? e.message : String(e)}` + ); + } + // URI : firebird://user:password@host:port/chemin-ou-alias[?role=&create=true] + const u = new URL(config.uri.replace(/^firebird:\/\//, 'http://')); + const database = decodeURIComponent(u.pathname.replace(/^\//, '')); // /abs/path → abs/path ; //abs → /abs + const options: Record = { + host: u.hostname || '127.0.0.1', + port: u.port ? Number(u.port) : 3050, + database, + user: decodeURIComponent(u.username) || 'SYSDBA', + password: decodeURIComponent(u.password) || 'masterkey', + lowercase_keys: true, // colonnes en minuscules (cohérent avec quoteIdentifier) + blobAsText: true, // BLOB SUB_TYPE TEXT lu directement en string + encoding: 'UTF8', + }; + const role = u.searchParams.get('role'); + if (role) options.role = role; + // FB 3.0 : le serveur exige souvent le chiffrement wire + plugin Srp. La négociation + // auto du driver plante (tente Srp256 absent). On force des défauts sûrs FB3, + // surchargeables : ?wireCrypt=disable|enable & ?plugin=Srp|Srp256|Legacy_Auth + options.wireCrypt = u.searchParams.get('wireCrypt') === 'disable' ? 0 : 1; // DISABLE=0 / ENABLE=1 + options.pluginName = u.searchParams.get('plugin') ?? 'Srp'; + // create=true → crée la base si absente (pratique en dev, comme les dialectes fichier). + const create = u.searchParams.get('create') === 'true' || config.schemaStrategy === 'create-drop'; + + this.db = await new Promise((res, rej) => { + const cb = (err: unknown, db: FbDatabase) => (err ? rej(this.toError(err)) : res(db)); + if (create) Firebird.attachOrCreate(options, cb); + else Firebird.attach(options, cb); + }); + } + + async doDisconnect(): Promise { + if (!this.db) return; + const db = this.db; + this.db = null; + await new Promise((res) => db.detach(() => res())); + } + + async doTestConnection(): Promise { + if (!this.db) return false; + try { + // Firebird exige un FROM : RDB$DATABASE est la table système mono-ligne. + await this.queryAsync('SELECT 1 FROM RDB$DATABASE', []); + return true; + } catch (e) { + this.log('TEST_CONNECTION', `down: ${(e as Error).message}`); + return false; + } + } + + // --- Query execution --- + + async doExecuteQuery(sql: string, params: unknown[]): Promise { + const result = await this.queryAsync(sql, params); + return (Array.isArray(result) ? result : []) as T[]; + } + + /** + * node-firebird n'expose pas de compteur d'affected-rows fiable pour les DML + * (INSERT/UPDATE/DELETE → result généralement undefined ; RETURNING → tableau). + * LIMITATION connue : updateMany()/deleteMany() peuvent renvoyer un compte approximatif. + * À revisiter en validation live (parsing isc_info_sql_records ou RETURNING). + */ + async doExecuteRun(sql: string, params: unknown[]): Promise<{ changes: number }> { + const result = await this.queryAsync(sql, params); + return { changes: Array.isArray(result) ? result.length : 1 }; + } + + // --- DROP : Firebird n'a NI `IF EXISTS` NI `CASCADE` sur DROP TABLE --- + + protected getDropTableSql(tableName: string): string { + return `DROP TABLE ${this.quoteIdentifier(this.getPrefixedName(tableName))}`; + } + + /** + * Sans `DROP ... CASCADE`, on supprime en PLUSIEURS PASSES pour résoudre l'ordre + * des clés étrangères (table référençante avant référencée). Idempotent : une + * erreur (FK bloquante OU table absente) est ignorée et retentée à la passe suivante. + */ + async dropSchema(schemas: EntitySchema[]): Promise { + const targets = new Set(); + for (const s of schemas) { + targets.add(s.collection); + for (const rel of Object.values(s.relations || {})) { + if (rel.type === 'many-to-many' && rel.through) targets.add(rel.through); + } + } + const dropped: string[] = []; + const maxPasses = targets.size + 1; + for (let pass = 0; pass < maxPasses && targets.size > 0; pass++) { + for (const name of [...targets]) { + try { + await this.executeRun(this.getDropTableSql(name), []); + targets.delete(name); + dropped.push(name); + } catch (e) { + this.log('DROP_TABLE', `${name} retenté (${(e as Error).message.slice(0, 60)})`); + } + } + } + return dropped; + } +} + +// ============================================================ +// Factory export +// ============================================================ + +export function createDialect(): IDialect { + return new FirebirdDialect(); +} From 172d05ac79d6a43b92f3e6e6e628f9a6bdef360d Mon Sep 17 00:00:00 2001 From: MADANI Date: Fri, 12 Jun 2026 18:08:47 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(dialect):=20ajoute=20ClickHouse=20(17e?= =?UTF-8?q?=20dialecte,=20OLAP=20=E2=80=94=20valid=C3=A9=20live=20sur=20am?= =?UTF-8?q?ia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialecte `clickhouse` (AbstractSqlDialect + quirks) pour ClickHouse (MergeTree, HTTP). Driver officiel @clickhouse/client (peer optionnel). Scope append/analytique. Quirks gérés : - generateCreateTable → ENGINE = MergeTree() ORDER BY id ; - paramètres typés {pN:Type} (conversion des `?` positionnels) ; - colonnes Nullable(T) (sauf id) ; - UPDATE/DELETE → mutations ALTER TABLE … UPDATE/DELETE rendues SYNCHRONES via mutations_sync=2 (read-after-write fiable) ; - dates 'YYYY-MM-DD HH:MM:SS' (sentinel 'now' géré) ; - ILIKE pour l'insensible-casse ; pas de CREATE INDEX. Câblage : DialectType, DIALECT_LOADERS, DIALECT_CONFIGS, peerDependencies (optionnel). Release 2.8.0 : CHANGELOG, llms.txt, README (17 bases). Validé LIVE sur clickhouse-server natif (sans Docker, amia) : test-sgbd 20/20. Limites : unicité non garantie ; mutations coûteuses (réserver à l'analytique). Author: Dr Hamid MADANI --- CHANGELOG.md | 22 +++ README.md | 19 ++- dist/core/config.js | 4 + dist/core/factory.js | 1 + dist/core/types.d.ts | 2 +- dist/dialects/clickhouse.dialect.d.ts | 55 +++++++ dist/dialects/clickhouse.dialect.js | 213 ++++++++++++++++++++++++ llms.txt | 10 +- package.json | 8 +- src/core/config.ts | 4 + src/core/factory.ts | 1 + src/core/types.ts | 3 +- src/dialects/clickhouse.dialect.ts | 226 ++++++++++++++++++++++++++ 13 files changed, 550 insertions(+), 18 deletions(-) create mode 100644 dist/dialects/clickhouse.dialect.d.ts create mode 100644 dist/dialects/clickhouse.dialect.js create mode 100644 src/dialects/clickhouse.dialect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b536653..b16b159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to `@mostajs/orm` will be documented in this file. +## [2.8.0] — 2026-06-12 + +### Feat — Dialecte ClickHouse (17e dialecte · OLAP colonnaire) + +Nouveau dialecte `clickhouse` (`AbstractSqlDialect` + quirks) pour **ClickHouse** +(OLAP MergeTree, interface HTTP). Driver officiel `@clickhouse/client` (peer optionnel). +**Scope « append/analytique »** — pas de PK/UNIQUE/FK au sens OLTP. + +- **CRUD** complet, relations (many-to-one), filtres (`ILIKE`), count, upsert. +- Connexion : `http://user:password@host:8123/database`. +- **Quirks gérés** : `CREATE TABLE … ENGINE = MergeTree() ORDER BY id` (generateCreateTable + surchargé) ; paramètres **typés** `{pN:Type}` (conversion des `?` positionnels) ; colonnes + `Nullable(T)` (sauf id) ; **UPDATE/DELETE → mutations** `ALTER TABLE … UPDATE/DELETE` + rendues **synchrones** via `mutations_sync=2` (read-after-write fiable) ; dates au format + `YYYY-MM-DD HH:MM:SS` ; pas de `CREATE INDEX`. + +Câblage : `DialectType`, `DIALECT_LOADERS`, `DIALECT_CONFIGS`, `peerDependencies` +(`@clickhouse/client` optionnel). + +**Validé LIVE** sur un `clickhouse-server` natif (sans Docker, amia) : harnais `test-sgbd` +**20/20**. Limites : unicité non garantie ; mutations coûteuses (réserver à l'analytique). + ## [2.7.0] — 2026-06-12 ### Feat — Dialecte Firebird (16e dialecte · OLTP relationnel) diff --git a/README.md b/README.md index a96f185..93752ad 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # @mostajs/orm -> **Plug & Play ORM to Drive 16 Databases at Once** +> **Plug & Play ORM to Drive 17 Databases at Once** [![npm version](https://img.shields.io/npm/v/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![npm downloads](https://img.shields.io/npm/dm/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE) -[![dialects](https://img.shields.io/badge/dialects-16-success.svg)](#databases) +[![dialects](https://img.shields.io/badge/dialects-17-success.svg)](#databases) [![Types: TypeScript](https://img.shields.io/badge/types-TypeScript-blue.svg)](https://www.typescriptlang.org/) [![bundle size](https://img.shields.io/bundlephobia/minzip/@mostajs/orm)](https://bundlephobia.com/package/@mostajs/orm) -Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 16 databases, zero lock-in, bundler-friendly**. +Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 17 databases, zero lock-in, bundler-friendly**. 📦 **npm** · https://www.npmjs.com/package/@mostajs/orm 🐙 **GitHub** · https://github.com/apolocine/mosta-orm @@ -28,10 +28,10 @@ cd ~/my-app && ./01-quickstart-sqlite.sh # runnable in 30 seconds ## Why @mostajs/orm ? -- 🎯 **One API, 16 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. +- 🎯 **One API, 17 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. - 🪶 **Zero lock-in.** Native drivers, no proprietary query DSL — your SQL/NoSQL stays portable. - 🧬 **Hibernate / JPA semantics.** `@OneToMany`, cascade types, `SAVEPOINT`, schema strategies (`validate`/`update`/`create`/`create-drop`) — concepts battle-tested for 25 years, ported to TypeScript. -- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 16 databases. +- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 17 databases. - 🔁 **Cross-dialect replication built-in.** [`@mostajs/replicator`](https://www.npmjs.com/package/@mostajs/replicator) — CDC + master/slave + failover across SQL ↔ MongoDB. - 🧪 **Bundler-friendly.** Tree-shakable ESM, no `eval`, works with esbuild / Vite / Next.js / Bun out of the box. - 🏷️ **Multi-app DB cohabitation** *(v2.3.0+)*. `DB_TABLE_PREFIX` à la Hibernate `physical_naming_strategy` — let two apps share one Oracle/MSSQL/HANA DB user without colliding on `users`/`roles`/`permissions`. @@ -118,7 +118,7 @@ Working from an **AI dev tool** (Cursor, Cline, Claude…)? Generate schemas, li | | @mostajs/orm | Prisma | Drizzle | TypeORM | |---|:---:|:---:|:---:|:---:| -| SQL dialects | **11** *(PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, HANA, Cockroach, DuckDB, Firebird…)* | 5 | 5 | 8 | +| SQL dialects | **12** *(PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, HANA, Cockroach, DuckDB, Firebird, ClickHouse…)* | 5 | 5 | 8 | | NoSQL dialects | **MongoDB + Firestore native** | ❌ | ❌ | ❌ | | Same API across SQL & NoSQL | ✅ | ❌ | ❌ | ❌ | | Browser / WebContainer / edge | ✅ *(WASM `sqljs` — zero native binary)* | ⚠️ *(Accelerate, paid)* | ⚠️ *(driver)* | ❌ | @@ -278,11 +278,12 @@ If `@mostajs/orm` saves you days of glue code, please : ## Databases -SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird +SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird · ClickHouse - **`duckdb`** — OLAP **in-process** engine (file or `:memory:`), SQL ≈ PostgreSQL. Analytics without a server. - **`firestore`** — Google Cloud **Firestore**, NoSQL document store (Mongo-style API). Remote (gRPC/TLS) or local **Java emulator** (no Docker, no key); production via a service-account key. Full-text search delegates to an external search module. - **`firebird`** — **Firebird 3.0+** OLTP relational engine (InterBase lineage). Pure-JS `node-firebird` driver; `ROWS` pagination, UUID ids. Validated live against a native server. +- **`clickhouse`** — **ClickHouse** OLAP columnar engine (MergeTree, HTTP). Official `@clickhouse/client` driver. **Append/analytical scope**: `UPDATE`/`DELETE` are mutations (made synchronous), no PK/UNIQUE/FK. Validated live against a native server. **+ WASM runtimes** — two zero-binary dialects run in WebAssembly, so the same ORM **boots in the browser / Bolt.new / Cloudflare Workers with no native binary**: @@ -348,7 +349,7 @@ Because the WASM build needs **no native binary and no server**, the same typed ```bash npm install @mostajs/orm # + the driver for your dialect : -npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird +npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird, @clickhouse/client npm install @google-cloud/firestore # Firestore — NoSQL document store (dialect: 'firestore'); Java emulator in dev, GCP key in prod npm install sql.js # SQLite in the browser / Bolt.new / Workers — no native binary (dialect: 'sqljs') npm install @electric-sql/pglite # PostgreSQL in the browser — idb:// persistence (dialect: 'pglite') @@ -1064,7 +1065,7 @@ const conn = getNamedConnection('audit') | Package | Description | |---|---| -| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 16 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | +| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 17 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | | [@mostajs/orm-cli](https://www.npmjs.com/package/@mostajs/orm-cli) | `npx @mostajs/orm-cli` — interactive CLI : convert schemas, init databases, scaffold services, replicator + monitor, seeding, bootstrap Prisma migration. | | [@mostajs/orm-adapter](https://www.npmjs.com/package/@mostajs/orm-adapter) | Convert Prisma / JSON Schema / OpenAPI / native `.mjs` to `EntitySchema[]` (bidirectional). | | [@mostajs/replicator](https://www.npmjs.com/package/@mostajs/replicator) | Cross-dialect replication : CQRS master/slave, CDC rules (snapshot + incremental), wildcard `*`, failover (`promoteToMaster`). As of @mostajs/orm v1.13, Mongo FK columns accept UUID strings coming from SQL dialects (populate falls back to `{ id: uuid }` lookup). | diff --git a/dist/core/config.js b/dist/core/config.js index 697759b..7ef975c 100644 --- a/dist/core/config.js +++ b/dist/core/config.js @@ -116,6 +116,10 @@ export const DIALECT_CONFIGS = { installHint: 'npm install node-firebird', label: 'Firebird (OLTP relationnel — serveur / embarqué, FB 3.0+)', }, + clickhouse: { + installHint: 'npm install @clickhouse/client', + label: 'ClickHouse (OLAP colonnaire — append/analytique, HTTP)', + }, }; /** * Get the list of supported dialect types diff --git a/dist/core/factory.js b/dist/core/factory.js index 84b3ee8..5579ed3 100644 --- a/dist/core/factory.js +++ b/dist/core/factory.js @@ -41,6 +41,7 @@ const DIALECT_LOADERS = { duckdb: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/duckdb.dialect.js'), firestore: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firestore.dialect.js'), firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), + clickhouse: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/clickhouse.dialect.js'), }; /** * Dynamically load a dialect adapter module. diff --git a/dist/core/types.d.ts b/dist/core/types.d.ts index 384204b..a4ed88a 100644 --- a/dist/core/types.d.ts +++ b/dist/core/types.d.ts @@ -189,7 +189,7 @@ export interface AggregateLimitStage { $limit: number; } export type AggregateStage = AggregateMatchStage | AggregateGroupStage | AggregateSortStage | AggregateLimitStage; -export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird'; +export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird' | 'clickhouse'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) * diff --git a/dist/dialects/clickhouse.dialect.d.ts b/dist/dialects/clickhouse.dialect.d.ts new file mode 100644 index 0000000..5e9c7ad --- /dev/null +++ b/dist/dialects/clickhouse.dialect.d.ts @@ -0,0 +1,55 @@ +import type { IDialect, DialectType, ConnectionConfig, EntitySchema, FieldDef } from '../core/types.js'; +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; +/** Forme minimale du client @clickhouse/client. */ +interface ChClient { + query(p: { + query: string; + query_params?: Record; + format?: string; + }): Promise<{ + json(): Promise; + }>; + command(p: { + query: string; + query_params?: Record; + }): Promise; + ping(): Promise<{ + success: boolean; + }>; + close(): Promise; +} +export declare class ClickHouseDialect extends AbstractSqlDialect { + readonly dialectType: DialectType; + db: ChClient | null; + quoteIdentifier(name: string): string; + getPlaceholder(_index: number): string; + fieldToSqlType(field: FieldDef): string; + getIdColumnType(): string; + getTableListQuery(): string; + protected getExistingColumns(tableName: string): Promise>; + protected supportsIfNotExists(): boolean; + protected supportsReturning(): boolean; + protected supportsAlterTableAddForeignKey(): boolean; + protected supportsPartialIndex(): boolean; + protected serializeBoolean(v: boolean): unknown; + protected deserializeBoolean(v: unknown): boolean; + /** ClickHouse DateTime : 'YYYY-MM-DD HH:MM:SS' (UTC). Gère les sentinels "now". */ + protected serializeDate(value: unknown): unknown; + /** insensible à la casse : ClickHouse a ILIKE. */ + protected buildRegexCondition(col: string, flags?: string): string; + protected generateIndexes(): string[]; + protected generateCreateTable(schema: EntitySchema): string; + protected getDropTableSql(tableName: string): string; + private bind; + /** Réécrit UPDATE/DELETE en mutations ClickHouse (ALTER TABLE … UPDATE/DELETE). */ + private toMutation; + doConnect(config: ConnectionConfig): Promise; + doDisconnect(): Promise; + doTestConnection(): Promise; + doExecuteQuery(sql: string, params: unknown[]): Promise; + doExecuteRun(sql: string, params: unknown[]): Promise<{ + changes: number; + }>; +} +export declare function createDialect(): IDialect; +export {}; diff --git a/dist/dialects/clickhouse.dialect.js b/dist/dialects/clickhouse.dialect.js new file mode 100644 index 0000000..cb184da --- /dev/null +++ b/dist/dialects/clickhouse.dialect.js @@ -0,0 +1,213 @@ +// ClickHouse Dialect — extends AbstractSqlDialect (scope « append/analytique »). +// OLAP colonnaire distribué (MergeTree), interface HTTP. Driver: npm install @clickhouse/client +// +// ⚠ PARADIGME NON-OLTP (cf. docs/NOUVEAUX-DIALECTES-…-FIREBIRD.md §2) : +// - pas de contrainte PK / UNIQUE / FK (la « primary key » MergeTree = clé de tri) ; +// - UPDATE/DELETE = MUTATIONS `ALTER TABLE … UPDATE/DELETE` (rendues SYNCHRONES via +// le réglage mutations_sync) — coûteuses, à réserver à un usage append-mostly ; +// - INSERT par batch privilégié ; unicité NON garantie. +// +// Spécificités driver gérées : +// - paramètres TYPÉS `{pN:Type}` (pas de `?`) → conversion dans doExecuteQuery/Run ; +// - `ENGINE = MergeTree() ORDER BY id` obligatoire au CREATE → generateCreateTable surchargé ; +// - colonnes Nullable(T) (sauf id) ; dates au format 'YYYY-MM-DD HH:MM:SS'. +// +// Author: Dr Hamid MADANI +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; +const CLICKHOUSE_TYPE_MAP = { + string: 'String', + text: 'String', + number: 'Float64', + boolean: 'UInt8', + date: 'DateTime', + json: 'String', + array: 'String', +}; +export class ClickHouseDialect extends AbstractSqlDialect { + dialectType = 'clickhouse'; + db = null; + // --- Abstract implementations --- + quoteIdentifier(name) { + return `\`${name.replace(/`/g, '``')}\``; // ClickHouse : backticks + } + getPlaceholder(_index) { return '?'; } // converti en {pN:Type} à l'exécution + fieldToSqlType(field) { + return `Nullable(${CLICKHOUSE_TYPE_MAP[field.type] || 'String'})`; + } + getIdColumnType() { return 'String'; } // non-nullable (clé de tri MergeTree) + getTableListQuery() { + return 'SELECT name FROM system.tables WHERE database = currentDatabase()'; + } + async getExistingColumns(tableName) { + try { + const rows = await this.executeQuery('SELECT name FROM system.columns WHERE database = currentDatabase() AND table = ?', [tableName]); + return new Set(rows.map(r => r.name).filter(Boolean)); + } + catch { + return new Set(); + } + } + // --- Hooks --- + supportsIfNotExists() { return true; } + supportsReturning() { return false; } + supportsAlterTableAddForeignKey() { return false; } // pas de FK + supportsPartialIndex() { return false; } + serializeBoolean(v) { return v ? 1 : 0; } + deserializeBoolean(v) { return v === 1 || v === '1' || v === true; } + /** ClickHouse DateTime : 'YYYY-MM-DD HH:MM:SS' (UTC). Gère les sentinels "now". */ + serializeDate(value) { + if (value === 'now' || value === '__MOSTA_NOW__') + value = new Date(); + const d = value instanceof Date ? value : new Date(value); + if (isNaN(d.getTime())) + return value; + return d.toISOString().slice(0, 19).replace('T', ' '); + } + /** insensible à la casse : ClickHouse a ILIKE. */ + buildRegexCondition(col, flags) { + return `${col} ${flags?.includes('i') ? 'ILIKE' : 'LIKE'} ${this.nextPlaceholder()}`; + } + // ClickHouse n'a pas d'index « contrainte » SQL classique → pas de CREATE INDEX. + generateIndexes() { return []; } + // --- DDL : CREATE TABLE … ENGINE = MergeTree() --- + generateCreateTable(schema) { + const q = (n) => this.quoteIdentifier(n); + const cols = [` ${q('id')} ${this.getIdColumnType()}`]; + const fkCols = new Set(); + for (const [rn, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') + continue; + fkCols.add(rel.joinColumn || rn); + } + for (const [name, field] of Object.entries(schema.fields || {})) { + if (name === 'id' || fkCols.has(name)) + continue; + let c = ` ${q(name)} ${this.fieldToSqlType(field)}`; + const isNow = field.default === 'now' || field.default === '__MOSTA_NOW__'; + if (field.default !== undefined && !isNow && field.default !== null) { + const dv = this.serializeValue(field.default, field); + if (typeof dv === 'string') + c += ` DEFAULT '${dv.replace(/'/g, "\\'")}'`; + else if (typeof dv === 'number') + c += ` DEFAULT ${dv}`; + } + cols.push(c); + } + for (const [name, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') + continue; + cols.push(` ${q(rel.joinColumn || name)} Nullable(${this.getIdColumnType()})`); + } + if (schema.timestamps) { + cols.push(` ${q('createdAt')} ${this.fieldToSqlType({ type: 'date' })}`); + cols.push(` ${q('updatedAt')} ${this.fieldToSqlType({ type: 'date' })}`); + } + if (schema.softDelete) + cols.push(` ${q('deletedAt')} ${this.fieldToSqlType({ type: 'date' })}`); + const tbl = q(this.getPrefixedName(schema.collection)); + return `CREATE TABLE IF NOT EXISTS ${tbl} (\n${cols.join(',\n')}\n) ENGINE = MergeTree() ORDER BY ${q('id')}`; + } + // --- DROP : ClickHouse n'a pas CASCADE --- + getDropTableSql(tableName) { + return `DROP TABLE IF EXISTS ${this.quoteIdentifier(this.getPrefixedName(tableName))}`; + } + // --- Conversion `?` positionnels → paramètres typés ClickHouse {pN:Type} --- + bind(sql, params) { + const query_params = {}; + let i = 0; + const query = sql.replace(/\?/g, () => { + const v = params[i++]; + if (v === null || v === undefined) + return 'NULL'; + const name = `p${i - 1}`; + let type, val = v; + if (typeof v === 'boolean') { + type = 'UInt8'; + val = v ? 1 : 0; + } + else if (typeof v === 'number') { + type = Number.isInteger(v) ? 'Int64' : 'Float64'; + } + else { + type = 'String'; + val = String(v); + } + query_params[name] = val; + return `{${name}:${type}}`; + }); + return { query, query_params }; + } + /** Réécrit UPDATE/DELETE en mutations ClickHouse (ALTER TABLE … UPDATE/DELETE). */ + toMutation(sql) { + let s = sql.trim(); + let m = s.match(/^UPDATE\s+(.+?)\s+SET\s+([\s\S]+)$/i); + if (m) + return `ALTER TABLE ${m[1]} UPDATE ${m[2]}`; + m = s.match(/^DELETE\s+FROM\s+(\S+)\s+WHERE\s+([\s\S]+)$/i); + if (m) + return `ALTER TABLE ${m[1]} DELETE WHERE ${m[2]}`; + m = s.match(/^DELETE\s+FROM\s+(\S+)\s*$/i); + if (m) + return `ALTER TABLE ${m[1]} DELETE WHERE 1 = 1`; + return s; + } + // --- Connection lifecycle --- + async doConnect(config) { + let createClient; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ '@clickhouse/client'); + createClient = mod.createClient; + } + catch (e) { + throw new Error(`ClickHouse driver not found. Install it: npm install @clickhouse/client\n` + + `Original error: ${e instanceof Error ? e.message : String(e)}`); + } + // URI : http(s)://user:password@host:port/database + const u = new URL(config.uri); + const database = u.pathname.replace(/^\//, '') || 'default'; + this.db = createClient({ + url: `${u.protocol}//${u.hostname}:${u.port || 8123}`, + username: decodeURIComponent(u.username) || 'default', + password: decodeURIComponent(u.password) || '', + database, + // Mutations SYNCHRONES → update/delete visibles immédiatement (read-after-write). + clickhouse_settings: { mutations_sync: '2' }, + }); + } + async doDisconnect() { + const db = this.db; + this.db = null; + if (db) + await db.close(); + } + async doTestConnection() { + if (!this.db) + return false; + try { + return (await this.db.ping()).success; + } + catch (e) { + this.log('TEST_CONNECTION', `down: ${e.message}`); + return false; + } + } + // --- Query execution --- + async doExecuteQuery(sql, params) { + if (!this.db) + throw new Error('ClickHouse not connected. Call connect() first.'); + const { query, query_params } = this.bind(sql, params); + const rs = await this.db.query({ query, query_params, format: 'JSONEachRow' }); + return await rs.json(); + } + async doExecuteRun(sql, params) { + if (!this.db) + throw new Error('ClickHouse not connected. Call connect() first.'); + const { query, query_params } = this.bind(this.toMutation(sql), params); + await this.db.command({ query, query_params }); + // ClickHouse n'expose pas d'affected-rows fiable sur INSERT/mutation. + return { changes: 1 }; + } +} +export function createDialect() { + return new ClickHouseDialect(); +} diff --git a/llms.txt b/llms.txt index efde8d8..6308480 100644 --- a/llms.txt +++ b/llms.txt @@ -1,15 +1,15 @@ # @mostajs/orm — fiche LLM -> ORM multi-dialecte inspiré d'Hibernate — une seule API, 16 bases de données, zéro lock-in. +> ORM multi-dialecte inspiré d'Hibernate — une seule API, 17 bases de données, zéro lock-in. -- Version: 2.7.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI -- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** ; **dialecte `firebird` (OLTP relationnel FB 3.0+, validé live, 2.7.0)** +- Version: 2.8.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI +- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** ; **dialecte `firebird` (OLTP relationnel FB 3.0+, validé live, 2.7.0)** ; **dialecte `clickhouse` (OLAP colonnaire MergeTree, append/analytique, validé live, 2.8.0)** ## RÔLE Couche d'accès aux données unifiée pour Node.js/TypeScript. Le développeur décrit ses entités sous forme d'objets `EntitySchema` (fields, relations, indexes) et obtient une API CRUD/query identique quel que soit le SGBD cible : MongoDB, SQLite, PostgreSQL, MySQL, MariaDB, Oracle, MSSQL, CockroachDB, DB2, SAP HANA, HSQLDB, Spanner, Sybase, -DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo) et Firebird (OLTP, FB 3.0+). +DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo), Firebird (OLTP) et ClickHouse (OLAP colonnaire). Deux dialectes WASM (zéro binaire natif) bootent dans le navigateur, les WebContainers (StackBlitz / Bolt.new) et l'edge — même API/SQL que leur moteur respectif : `sqljs` (SQLite WASM, via sql.js) et `pglite` (PostgreSQL WASM, via @electric-sql/pglite ; @@ -23,7 +23,7 @@ ConnectionConfig = persistence.xml). C'est le module fondation de l'écosystème npm i @mostajs/orm (driver natif requis selon dialecte : better-sqlite3, pg, mysql2, mariadb, oracledb, mssql, ibm_db, @sap/hana-client, @google-cloud/spanner, @google-cloud/firestore, duckdb, -node-firebird, mongoose — peer/optional deps) +node-firebird, @clickhouse/client, mongoose — peer/optional deps) Pour le navigateur / WebContainer / edge (WASM pur, aucun binaire natif — à utiliser dans Bolt.new / StackBlitz / Cloudflare Workers où better-sqlite3/pg ne chargent pas) : - SQLite : `npm i sql.js` + `{ dialect: 'sqljs', uri: ':memory:' }` ; persistance fichier diff --git a/package.json b/package.json index 0c796a9..5ae658c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mostajs/orm", - "version": "2.7.0", - "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 16 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", + "version": "2.8.0", + "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 17 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", "author": "Dr Hamid MADANI ", "license": "AGPL-3.0-or-later", "type": "module", @@ -91,6 +91,7 @@ "ts-morph": "^28.0.0" }, "peerDependencies": { + "@clickhouse/client": ">=1.0.0", "@electric-sql/pglite": ">=0.2.0", "@google-cloud/firestore": ">=7.0.0", "@google-cloud/spanner": ">=7.0.0", @@ -109,6 +110,9 @@ "sql.js": ">=1.8.0" }, "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, "sql.js": { "optional": true }, diff --git a/src/core/config.ts b/src/core/config.ts index 0d32211..1dbc48f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -128,6 +128,10 @@ export const DIALECT_CONFIGS: Record = { installHint: 'npm install node-firebird', label: 'Firebird (OLTP relationnel — serveur / embarqué, FB 3.0+)', }, + clickhouse: { + installHint: 'npm install @clickhouse/client', + label: 'ClickHouse (OLAP colonnaire — append/analytique, HTTP)', + }, }; /** diff --git a/src/core/factory.ts b/src/core/factory.ts index 4d33fa3..afd0fce 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -53,6 +53,7 @@ const DIALECT_LOADERS: Record Promise<{ createDialect: () => duckdb: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/duckdb.dialect.js'), firestore: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firestore.dialect.js'), firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), + clickhouse: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/clickhouse.dialect.js'), }; /** diff --git a/src/core/types.ts b/src/core/types.ts index 4bd8511..cb26256 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -285,7 +285,8 @@ export type DialectType = | 'sybase' | 'duckdb' | 'firestore' - | 'firebird'; + | 'firebird' + | 'clickhouse'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) diff --git a/src/dialects/clickhouse.dialect.ts b/src/dialects/clickhouse.dialect.ts new file mode 100644 index 0000000..9eef4c5 --- /dev/null +++ b/src/dialects/clickhouse.dialect.ts @@ -0,0 +1,226 @@ +// ClickHouse Dialect — extends AbstractSqlDialect (scope « append/analytique »). +// OLAP colonnaire distribué (MergeTree), interface HTTP. Driver: npm install @clickhouse/client +// +// ⚠ PARADIGME NON-OLTP (cf. docs/NOUVEAUX-DIALECTES-…-FIREBIRD.md §2) : +// - pas de contrainte PK / UNIQUE / FK (la « primary key » MergeTree = clé de tri) ; +// - UPDATE/DELETE = MUTATIONS `ALTER TABLE … UPDATE/DELETE` (rendues SYNCHRONES via +// le réglage mutations_sync) — coûteuses, à réserver à un usage append-mostly ; +// - INSERT par batch privilégié ; unicité NON garantie. +// +// Spécificités driver gérées : +// - paramètres TYPÉS `{pN:Type}` (pas de `?`) → conversion dans doExecuteQuery/Run ; +// - `ENGINE = MergeTree() ORDER BY id` obligatoire au CREATE → generateCreateTable surchargé ; +// - colonnes Nullable(T) (sauf id) ; dates au format 'YYYY-MM-DD HH:MM:SS'. +// +// Author: Dr Hamid MADANI + +import type { + IDialect, + DialectType, + ConnectionConfig, + EntitySchema, + FieldDef, +} from '../core/types.js'; +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; + +const CLICKHOUSE_TYPE_MAP: Record = { + string: 'String', + text: 'String', + number: 'Float64', + boolean: 'UInt8', + date: 'DateTime', + json: 'String', + array: 'String', +}; + +/** Forme minimale du client @clickhouse/client. */ +interface ChClient { + query(p: { query: string; query_params?: Record; format?: string }): Promise<{ json(): Promise }>; + command(p: { query: string; query_params?: Record }): Promise; + ping(): Promise<{ success: boolean }>; + close(): Promise; +} + +export class ClickHouseDialect extends AbstractSqlDialect { + readonly dialectType: DialectType = 'clickhouse'; + db: ChClient | null = null; + + // --- Abstract implementations --- + + quoteIdentifier(name: string): string { + return `\`${name.replace(/`/g, '``')}\``; // ClickHouse : backticks + } + getPlaceholder(_index: number): string { return '?'; } // converti en {pN:Type} à l'exécution + fieldToSqlType(field: FieldDef): string { + return `Nullable(${CLICKHOUSE_TYPE_MAP[field.type] || 'String'})`; + } + getIdColumnType(): string { return 'String'; } // non-nullable (clé de tri MergeTree) + + getTableListQuery(): string { + return 'SELECT name FROM system.tables WHERE database = currentDatabase()'; + } + protected async getExistingColumns(tableName: string): Promise> { + try { + const rows = await this.executeQuery<{ name: string }>( + 'SELECT name FROM system.columns WHERE database = currentDatabase() AND table = ?', + [tableName], + ); + return new Set(rows.map(r => r.name).filter(Boolean)); + } catch { return new Set(); } + } + + // --- Hooks --- + + protected supportsIfNotExists(): boolean { return true; } + protected supportsReturning(): boolean { return false; } + protected supportsAlterTableAddForeignKey(): boolean { return false; } // pas de FK + protected supportsPartialIndex(): boolean { return false; } + protected serializeBoolean(v: boolean): unknown { return v ? 1 : 0; } + protected deserializeBoolean(v: unknown): boolean { return v === 1 || v === '1' || v === true; } + + /** ClickHouse DateTime : 'YYYY-MM-DD HH:MM:SS' (UTC). Gère les sentinels "now". */ + protected serializeDate(value: unknown): unknown { + if (value === 'now' || value === '__MOSTA_NOW__') value = new Date(); + const d = value instanceof Date ? value : new Date(value as string); + if (isNaN(d.getTime())) return value; + return d.toISOString().slice(0, 19).replace('T', ' '); + } + + /** insensible à la casse : ClickHouse a ILIKE. */ + protected buildRegexCondition(col: string, flags?: string): string { + return `${col} ${flags?.includes('i') ? 'ILIKE' : 'LIKE'} ${this.nextPlaceholder()}`; + } + + // ClickHouse n'a pas d'index « contrainte » SQL classique → pas de CREATE INDEX. + protected generateIndexes(): string[] { return []; } + + // --- DDL : CREATE TABLE … ENGINE = MergeTree() --- + + protected generateCreateTable(schema: EntitySchema): string { + const q = (n: string) => this.quoteIdentifier(n); + const cols: string[] = [` ${q('id')} ${this.getIdColumnType()}`]; + + const fkCols = new Set(); + for (const [rn, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') continue; + fkCols.add(rel.joinColumn || rn); + } + for (const [name, field] of Object.entries(schema.fields || {})) { + if (name === 'id' || fkCols.has(name)) continue; + let c = ` ${q(name)} ${this.fieldToSqlType(field)}`; + const isNow = field.default === 'now' || field.default === '__MOSTA_NOW__'; + if (field.default !== undefined && !isNow && field.default !== null) { + const dv = this.serializeValue(field.default, field); + if (typeof dv === 'string') c += ` DEFAULT '${dv.replace(/'/g, "\\'")}'`; + else if (typeof dv === 'number') c += ` DEFAULT ${dv}`; + } + cols.push(c); + } + for (const [name, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') continue; + cols.push(` ${q(rel.joinColumn || name)} Nullable(${this.getIdColumnType()})`); + } + if (schema.timestamps) { + cols.push(` ${q('createdAt')} ${this.fieldToSqlType({ type: 'date' })}`); + cols.push(` ${q('updatedAt')} ${this.fieldToSqlType({ type: 'date' })}`); + } + if (schema.softDelete) cols.push(` ${q('deletedAt')} ${this.fieldToSqlType({ type: 'date' })}`); + + const tbl = q(this.getPrefixedName(schema.collection)); + return `CREATE TABLE IF NOT EXISTS ${tbl} (\n${cols.join(',\n')}\n) ENGINE = MergeTree() ORDER BY ${q('id')}`; + } + + // --- DROP : ClickHouse n'a pas CASCADE --- + protected getDropTableSql(tableName: string): string { + return `DROP TABLE IF EXISTS ${this.quoteIdentifier(this.getPrefixedName(tableName))}`; + } + + // --- Conversion `?` positionnels → paramètres typés ClickHouse {pN:Type} --- + + private bind(sql: string, params: unknown[]): { query: string; query_params: Record } { + const query_params: Record = {}; + let i = 0; + const query = sql.replace(/\?/g, () => { + const v = params[i++]; + if (v === null || v === undefined) return 'NULL'; + const name = `p${i - 1}`; + let type: string, val: unknown = v; + if (typeof v === 'boolean') { type = 'UInt8'; val = v ? 1 : 0; } + else if (typeof v === 'number') { type = Number.isInteger(v) ? 'Int64' : 'Float64'; } + else { type = 'String'; val = String(v); } + query_params[name] = val; + return `{${name}:${type}}`; + }); + return { query, query_params }; + } + + /** Réécrit UPDATE/DELETE en mutations ClickHouse (ALTER TABLE … UPDATE/DELETE). */ + private toMutation(sql: string): string { + let s = sql.trim(); + let m = s.match(/^UPDATE\s+(.+?)\s+SET\s+([\s\S]+)$/i); + if (m) return `ALTER TABLE ${m[1]} UPDATE ${m[2]}`; + m = s.match(/^DELETE\s+FROM\s+(\S+)\s+WHERE\s+([\s\S]+)$/i); + if (m) return `ALTER TABLE ${m[1]} DELETE WHERE ${m[2]}`; + m = s.match(/^DELETE\s+FROM\s+(\S+)\s*$/i); + if (m) return `ALTER TABLE ${m[1]} DELETE WHERE 1 = 1`; + return s; + } + + // --- Connection lifecycle --- + + async doConnect(config: ConnectionConfig): Promise { + let createClient: (cfg: Record) => ChClient; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ '@clickhouse/client' as string); + createClient = (mod as { createClient: typeof createClient }).createClient; + } catch (e: unknown) { + throw new Error( + `ClickHouse driver not found. Install it: npm install @clickhouse/client\n` + + `Original error: ${e instanceof Error ? e.message : String(e)}` + ); + } + // URI : http(s)://user:password@host:port/database + const u = new URL(config.uri); + const database = u.pathname.replace(/^\//, '') || 'default'; + this.db = createClient({ + url: `${u.protocol}//${u.hostname}:${u.port || 8123}`, + username: decodeURIComponent(u.username) || 'default', + password: decodeURIComponent(u.password) || '', + database, + // Mutations SYNCHRONES → update/delete visibles immédiatement (read-after-write). + clickhouse_settings: { mutations_sync: '2' }, + }); + } + + async doDisconnect(): Promise { + const db = this.db; this.db = null; + if (db) await db.close(); + } + + async doTestConnection(): Promise { + if (!this.db) return false; + try { return (await this.db.ping()).success; } + catch (e) { this.log('TEST_CONNECTION', `down: ${(e as Error).message}`); return false; } + } + + // --- Query execution --- + + async doExecuteQuery(sql: string, params: unknown[]): Promise { + if (!this.db) throw new Error('ClickHouse not connected. Call connect() first.'); + const { query, query_params } = this.bind(sql, params); + const rs = await this.db.query({ query, query_params, format: 'JSONEachRow' }); + return await rs.json(); + } + + async doExecuteRun(sql: string, params: unknown[]): Promise<{ changes: number }> { + if (!this.db) throw new Error('ClickHouse not connected. Call connect() first.'); + const { query, query_params } = this.bind(this.toMutation(sql), params); + await this.db.command({ query, query_params }); + // ClickHouse n'expose pas d'affected-rows fiable sur INSERT/mutation. + return { changes: 1 }; + } +} + +export function createDialect(): IDialect { + return new ClickHouseDialect(); +} From e96ac6998d67dfe881b9f0bb82e4e5139ade9fea Mon Sep 17 00:00:00 2001 From: MADANI Date: Fri, 12 Jun 2026 18:38:39 +0100 Subject: [PATCH 3/6] =?UTF-8?q?feat(dialect):=20ajoute=20Redis=20(18e=20di?= =?UTF-8?q?alecte,=20NoSQL=20doc=20Redis=20Stack=20=E2=80=94=20valid=C3=A9?= =?UTF-8?q?=20live=20amia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialecte `redis` (IDialect dédié, façon Mongo) sur Redis Stack. Driver ioredis. Pas un cache KV : stockage documentaire + recherche serveur. - Stockage RedisJSON : JSON.SET/GET, JSON.NUMINCRBY (increment atomique). - Requêtes RediSearch : un index FT.CREATE par entité à l'initSchema ; FT.SEARCH avec traduction des filtres @mostajs ($eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$regex) → TAG/ NUMERIC/TEXT ; SORTBY/LIMIT ; count via LIMIT 0 0 ; soft-delete via champ _deleted ; échappement des valeurs TAG (UUID). - CRUD complet, relations M2O par lookup (JSON.GET), upsert, addToSet/pull. Câblage : DialectType, DIALECT_LOADERS, DIALECT_CONFIGS, peerDependencies (optionnel). Release 2.9.0 : CHANGELOG, llms.txt, README (18 bases) + note positionnement Redis. Validé LIVE sur redis-stack-server natif (modules search + ReJSON, sans Docker, amia) : test-sgbd 20/20. Author: Dr Hamid MADANI --- CHANGELOG.md | 24 ++ README.md | 21 +- dist/core/config.js | 4 + dist/core/factory.js | 1 + dist/core/types.d.ts | 2 +- dist/dialects/redis.dialect.d.ts | 53 ++++ dist/dialects/redis.dialect.js | 408 +++++++++++++++++++++++++++++++ llms.txt | 10 +- package.json | 8 +- src/core/config.ts | 4 + src/core/factory.ts | 1 + src/core/types.ts | 3 +- src/dialects/redis.dialect.ts | 396 ++++++++++++++++++++++++++++++ 13 files changed, 917 insertions(+), 18 deletions(-) create mode 100644 dist/dialects/redis.dialect.d.ts create mode 100644 dist/dialects/redis.dialect.js create mode 100644 src/dialects/redis.dialect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b16b159..ae7f414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to `@mostajs/orm` will be documented in this file. +## [2.9.0] — 2026-06-12 + +### Feat — Dialecte Redis (18e dialecte · NoSQL documentaire, Redis Stack) + +Nouveau dialecte `redis` (`IDialect` dédié, façon Mongo) sur **Redis Stack**. Driver +`ioredis` (peer optionnel). **Pas un simple cache KV** : stockage documentaire JSON + +recherche serveur. + +- Stockage **RedisJSON** : `JSON.SET/GET`, `JSON.NUMINCRBY` (increment atomique). +- Requêtes **RediSearch** : un index `FT.CREATE` par entité à l'`initSchema` ; `FT.SEARCH` + avec **traduction des filtres** `@mostajs` (`$eq/$ne/$gt/$gte/$lt/$lte/$in/$nin/$regex`) + → TAG / NUMERIC / TEXT ; `SORTBY`/`LIMIT` ; count via `LIMIT 0 0` ; soft-delete via champ + indexé `_deleted`. Échappement des valeurs TAG (UUID). +- **CRUD** complet, relations many-to-one par lookup (`JSON.GET`), upsert, `addToSet`/`pull`. +- Connexion : `redis://host:6379`. + +Câblage : `DialectType`, `DIALECT_LOADERS`, `DIALECT_CONFIGS`, `peerDependencies` +(`ioredis` optionnel). + +**Validé LIVE** sur un `redis-stack-server` natif (modules `search` + `ReJSON`, sans +Docker, amia) : harnais `test-sgbd` **20/20**. Périmètre : full-text via RediSearch ; +agrégation lourde déléguée (`FT.AGGREGATE`). Positionnement (cf. étude croisée interne) : +couche données opérationnelle temps-réel qui alimente les couches analytiques/décisionnelles. + ## [2.8.0] — 2026-06-12 ### Feat — Dialecte ClickHouse (17e dialecte · OLAP colonnaire) diff --git a/README.md b/README.md index 93752ad..ab9cf80 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # @mostajs/orm -> **Plug & Play ORM to Drive 17 Databases at Once** +> **Plug & Play ORM to Drive 18 Databases at Once** [![npm version](https://img.shields.io/npm/v/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![npm downloads](https://img.shields.io/npm/dm/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE) -[![dialects](https://img.shields.io/badge/dialects-17-success.svg)](#databases) +[![dialects](https://img.shields.io/badge/dialects-18-success.svg)](#databases) [![Types: TypeScript](https://img.shields.io/badge/types-TypeScript-blue.svg)](https://www.typescriptlang.org/) [![bundle size](https://img.shields.io/bundlephobia/minzip/@mostajs/orm)](https://bundlephobia.com/package/@mostajs/orm) -Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 17 databases, zero lock-in, bundler-friendly**. +Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 18 databases, zero lock-in, bundler-friendly**. 📦 **npm** · https://www.npmjs.com/package/@mostajs/orm 🐙 **GitHub** · https://github.com/apolocine/mosta-orm @@ -28,10 +28,10 @@ cd ~/my-app && ./01-quickstart-sqlite.sh # runnable in 30 seconds ## Why @mostajs/orm ? -- 🎯 **One API, 17 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. +- 🎯 **One API, 18 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. - 🪶 **Zero lock-in.** Native drivers, no proprietary query DSL — your SQL/NoSQL stays portable. - 🧬 **Hibernate / JPA semantics.** `@OneToMany`, cascade types, `SAVEPOINT`, schema strategies (`validate`/`update`/`create`/`create-drop`) — concepts battle-tested for 25 years, ported to TypeScript. -- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 17 databases. +- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 18 databases. - 🔁 **Cross-dialect replication built-in.** [`@mostajs/replicator`](https://www.npmjs.com/package/@mostajs/replicator) — CDC + master/slave + failover across SQL ↔ MongoDB. - 🧪 **Bundler-friendly.** Tree-shakable ESM, no `eval`, works with esbuild / Vite / Next.js / Bun out of the box. - 🏷️ **Multi-app DB cohabitation** *(v2.3.0+)*. `DB_TABLE_PREFIX` à la Hibernate `physical_naming_strategy` — let two apps share one Oracle/MSSQL/HANA DB user without colliding on `users`/`roles`/`permissions`. @@ -119,7 +119,7 @@ Working from an **AI dev tool** (Cursor, Cline, Claude…)? Generate schemas, li | | @mostajs/orm | Prisma | Drizzle | TypeORM | |---|:---:|:---:|:---:|:---:| | SQL dialects | **12** *(PG, MySQL, MariaDB, SQLite, MSSQL, Oracle, DB2, HANA, Cockroach, DuckDB, Firebird, ClickHouse…)* | 5 | 5 | 8 | -| NoSQL dialects | **MongoDB + Firestore native** | ❌ | ❌ | ❌ | +| NoSQL dialects | **MongoDB + Firestore + Redis native** | ❌ | ❌ | ❌ | | Same API across SQL & NoSQL | ✅ | ❌ | ❌ | ❌ | | Browser / WebContainer / edge | ✅ *(WASM `sqljs` — zero native binary)* | ⚠️ *(Accelerate, paid)* | ⚠️ *(driver)* | ❌ | | Cross-dialect replication | ✅ *(via [@mostajs/replicator](https://www.npmjs.com/package/@mostajs/replicator))* | ❌ | ❌ | ❌ | @@ -278,12 +278,15 @@ If `@mostajs/orm` saves you days of glue code, please : ## Databases -SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird · ClickHouse +SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird · ClickHouse · Redis - **`duckdb`** — OLAP **in-process** engine (file or `:memory:`), SQL ≈ PostgreSQL. Analytics without a server. - **`firestore`** — Google Cloud **Firestore**, NoSQL document store (Mongo-style API). Remote (gRPC/TLS) or local **Java emulator** (no Docker, no key); production via a service-account key. Full-text search delegates to an external search module. - **`firebird`** — **Firebird 3.0+** OLTP relational engine (InterBase lineage). Pure-JS `node-firebird` driver; `ROWS` pagination, UUID ids. Validated live against a native server. - **`clickhouse`** — **ClickHouse** OLAP columnar engine (MergeTree, HTTP). Official `@clickhouse/client` driver. **Append/analytical scope**: `UPDATE`/`DELETE` are mutations (made synchronous), no PK/UNIQUE/FK. Validated live against a native server. +- **`redis`** — **Redis Stack** as a real-time document store (`ioredis`). Not a plain key-value cache here: entities are stored as native JSON with **RedisJSON** (`JSON.SET/GET`, atomic `JSON.NUMINCRBY`) and queried **server-side** with **RediSearch** (`FT.CREATE` per entity, `FT.SEARCH` translating `@mostajs` filters to TAG/NUMERIC/TEXT — O(log n), no key scan). Same `@mostajs/orm` API as MongoDB/Firestore. + +> **Why Redis here?** Beyond MongoDB/Firestore, the `redis` dialect positions Redis as the **low-latency operational data layer** of the stack — live documents, sessions, queues, search and (via RedisTimeSeries) time-series — the substrate that **feeds the analytical / decision layers** of the ecosystem (e.g. operations-research workloads: assignment, queueing, forecasting). It stores, indexes and serves; it does not compute optima. Heavy aggregation stays delegated (`FT.AGGREGATE` / analytics module). **+ WASM runtimes** — two zero-binary dialects run in WebAssembly, so the same ORM **boots in the browser / Bolt.new / Cloudflare Workers with no native binary**: @@ -349,7 +352,7 @@ Because the WASM build needs **no native binary and no server**, the same typed ```bash npm install @mostajs/orm # + the driver for your dialect : -npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird, @clickhouse/client +npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird, @clickhouse/client, ioredis npm install @google-cloud/firestore # Firestore — NoSQL document store (dialect: 'firestore'); Java emulator in dev, GCP key in prod npm install sql.js # SQLite in the browser / Bolt.new / Workers — no native binary (dialect: 'sqljs') npm install @electric-sql/pglite # PostgreSQL in the browser — idb:// persistence (dialect: 'pglite') @@ -1065,7 +1068,7 @@ const conn = getNamedConnection('audit') | Package | Description | |---|---| -| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 17 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | +| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 18 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | | [@mostajs/orm-cli](https://www.npmjs.com/package/@mostajs/orm-cli) | `npx @mostajs/orm-cli` — interactive CLI : convert schemas, init databases, scaffold services, replicator + monitor, seeding, bootstrap Prisma migration. | | [@mostajs/orm-adapter](https://www.npmjs.com/package/@mostajs/orm-adapter) | Convert Prisma / JSON Schema / OpenAPI / native `.mjs` to `EntitySchema[]` (bidirectional). | | [@mostajs/replicator](https://www.npmjs.com/package/@mostajs/replicator) | Cross-dialect replication : CQRS master/slave, CDC rules (snapshot + incremental), wildcard `*`, failover (`promoteToMaster`). As of @mostajs/orm v1.13, Mongo FK columns accept UUID strings coming from SQL dialects (populate falls back to `{ id: uuid }` lookup). | diff --git a/dist/core/config.js b/dist/core/config.js index 7ef975c..0776d4d 100644 --- a/dist/core/config.js +++ b/dist/core/config.js @@ -120,6 +120,10 @@ export const DIALECT_CONFIGS = { installHint: 'npm install @clickhouse/client', label: 'ClickHouse (OLAP colonnaire — append/analytique, HTTP)', }, + redis: { + installHint: 'npm install ioredis', + label: 'Redis Stack (NoSQL doc — RedisJSON + RediSearch)', + }, }; /** * Get the list of supported dialect types diff --git a/dist/core/factory.js b/dist/core/factory.js index 5579ed3..b33247f 100644 --- a/dist/core/factory.js +++ b/dist/core/factory.js @@ -42,6 +42,7 @@ const DIALECT_LOADERS = { firestore: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firestore.dialect.js'), firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), clickhouse: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/clickhouse.dialect.js'), + redis: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/redis.dialect.js'), }; /** * Dynamically load a dialect adapter module. diff --git a/dist/core/types.d.ts b/dist/core/types.d.ts index a4ed88a..75a87c6 100644 --- a/dist/core/types.d.ts +++ b/dist/core/types.d.ts @@ -189,7 +189,7 @@ export interface AggregateLimitStage { $limit: number; } export type AggregateStage = AggregateMatchStage | AggregateGroupStage | AggregateSortStage | AggregateLimitStage; -export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird' | 'clickhouse'; +export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird' | 'clickhouse' | 'redis'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) * diff --git a/dist/dialects/redis.dialect.d.ts b/dist/dialects/redis.dialect.d.ts new file mode 100644 index 0000000..4be52e3 --- /dev/null +++ b/dist/dialects/redis.dialect.d.ts @@ -0,0 +1,53 @@ +import type { IDialect, DialectType, ConnectionConfig, EntitySchema, FilterQuery as DALFilter, QueryOptions, AggregateStage, TxHandle } from '../core/types.js'; +export declare class RedisDialect implements IDialect { + readonly dialectType: DialectType; + private config; + private db; + /** Cache des types de champs par collection (pour traduire les filtres en FT). */ + private fieldTypes; + private prefix; + private keyOf; + private keyPrefix; + private indexOf; + private client; + private genId; + private _seed; + private rnd; + private withTimestamps; + private typesFor; + private clauseFor; + private buildQuery; + /** Exécute FT.SEARCH et renvoie les documents JSON. */ + private ftSearch; + connect(config: ConnectionConfig): Promise; + disconnect(): Promise; + testConnection(): Promise; + initSchema(schemas: EntitySchema[]): Promise; + private ensureIndex; + find(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): Promise; + findOne(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): Promise; + findById(schema: EntitySchema, id: string, options?: QueryOptions): Promise; + create(schema: EntitySchema, data: Record): Promise; + update(schema: EntitySchema, id: string, data: Record): Promise; + updateMany(schema: EntitySchema, filter: DALFilter, data: Record): Promise; + delete(schema: EntitySchema, id: string): Promise; + deleteMany(schema: EntitySchema, filter: DALFilter): Promise; + count(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): Promise; + distinct(schema: EntitySchema, field: string, filter: DALFilter, options?: QueryOptions): Promise; + aggregate(_schema: EntitySchema, _stages: AggregateStage[], _options?: QueryOptions): Promise; + private populate; + findWithRelations(schema: EntitySchema, filter: DALFilter, relations: string[], options?: QueryOptions): Promise; + findByIdWithRelations(schema: EntitySchema, id: string, relations: string[], options?: QueryOptions): Promise; + upsert(schema: EntitySchema, filter: DALFilter, data: Record): Promise; + increment(schema: EntitySchema, id: string, field: string, amount: number): Promise>; + addToSet(schema: EntitySchema, id: string, field: string, value: unknown): Promise | null>; + pull(schema: EntitySchema, id: string, field: string, value: unknown): Promise | null>; + search(schema: EntitySchema, query: string, fields: string[], options?: QueryOptions): Promise; + $transaction(cb: (tx: IDialect) => Promise): Promise; + beginTx(): Promise; + dropTable(tableName: string): Promise; + truncateTable(tableName: string): Promise; + dropSchema(schemas: EntitySchema[]): Promise; + truncateAll(schemas: EntitySchema[]): Promise; +} +export declare function createDialect(): IDialect; diff --git a/dist/dialects/redis.dialect.js b/dist/dialects/redis.dialect.js new file mode 100644 index 0000000..9cf9e1f --- /dev/null +++ b/dist/dialects/redis.dialect.js @@ -0,0 +1,408 @@ +// Redis Dialect — implements IDialect (NoSQL documentaire, façon Mongo) sur Redis Stack. +// Stockage : RedisJSON (JSON.SET/GET/NUMINCRBY) ; requêtes : RediSearch (FT.CREATE/FT.SEARCH). +// - chaque entité = document JSON à la clé `:` ; +// - un index FT par entité (créé à initSchema) → filtre/tri/count côté serveur (O(log n)) ; +// - relations M2O par lookup (JSON.GET), façon populate Mongo/Firestore. +// Driver : npm install ioredis (Redis Stack : redis-stack-server, modules search + ReJSON). +// Cf. docs/EXTENSIONS-REDIS-ELASTICSEARCH-EMBARQUE.md +// Author: Dr Hamid MADANI +function ftTypeOf(field) { + switch (field.type) { + case 'number': return 'NUMERIC'; + case 'text': return 'TEXT'; + case 'boolean': + case 'string': + case 'date': + default: return 'TAG'; + } +} +// Échappe les caractères spéciaux RediSearch dans une valeur TAG (UUID `-`, etc.). +function escTag(v) { + return String(v).replace(/[ ,.<>{}\[\]"':;!@#$%^&*()\-+=~/\\]/g, '\\$&'); +} +export class RedisDialect { + dialectType = 'redis'; + config = null; + db = null; + /** Cache des types de champs par collection (pour traduire les filtres en FT). */ + fieldTypes = new Map(); + // --- Helpers --- + prefix() { return this.config?.tablePrefix ?? ''; } + keyOf(schema, id) { return `${this.prefix()}${schema.collection}:${id}`; } + keyPrefix(collection) { return `${this.prefix()}${collection}:`; } + indexOf(collection) { return `idx:${this.prefix()}${collection}`; } + client() { + if (!this.db) + throw new Error('Redis not connected. Call connect() first.'); + return this.db; + } + genId() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.floor(this.rnd() * 16); + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); + } + _seed = 987654321; + rnd() { this._seed = (this._seed * 1103515245 + 12345 + Date.now()) % 2147483648; return this._seed / 2147483648; } + withTimestamps(data, schema, isCreate) { + if (!schema.timestamps) + return data; + const now = new Date().toISOString(); + return isCreate ? { createdAt: now, updatedAt: now, ...data } : { ...data, updatedAt: now }; + } + typesFor(schema) { + let t = this.fieldTypes.get(schema.collection); + if (t) + return t; + t = {}; + for (const [name, f] of Object.entries(schema.fields || {})) { + if (f.type === 'json' || f.type === 'array') + continue; // non indexés + t[name] = ftTypeOf(f); + } + for (const [name, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') + continue; + t[rel.joinColumn || name] = 'TAG'; + } + if (schema.timestamps) { + t.createdAt = 'TAG'; + t.updatedAt = 'TAG'; + } + if (schema.softDelete) + t._deleted = 'NUMERIC'; + this.fieldTypes.set(schema.collection, t); + return t; + } + // --- Traduction filtre @mostajs → requête RediSearch --- + clauseFor(field, ftType, cond) { + const tag = (v) => `@${field}:{${escTag(v)}}`; + const num = (lo, hi) => `@${field}:[${lo} ${hi}]`; + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + const parts = []; + for (const [op, val] of Object.entries(cond)) { + switch (op) { + case '$eq': + parts.push(ftType === 'NUMERIC' ? num(String(val), String(val)) : tag(val)); + break; + case '$ne': + parts.push(`-(${ftType === 'NUMERIC' ? num(String(val), String(val)) : tag(val)})`); + break; + case '$gt': + parts.push(num(`(${Number(val)}`, '+inf')); + break; + case '$gte': + parts.push(num(`${Number(val)}`, '+inf')); + break; + case '$lt': + parts.push(num('-inf', `(${Number(val)}`)); + break; + case '$lte': + parts.push(num('-inf', `${Number(val)}`)); + break; + case '$in': + parts.push(`@${field}:{${val.map(escTag).join('|')}}`); + break; + case '$nin': + parts.push(`-@${field}:{${val.map(escTag).join('|')}}`); + break; + case '$exists': + parts.push(val ? `@${field}:*` : `-@${field}:*`); + break; + case '$regex': + parts.push(`@${field}:*${escTag(val)}*`); + break; + default: throw new Error(`Redis: opérateur de filtre inconnu "${op}".`); + } + } + return parts.join(' '); + } + return ftType === 'NUMERIC' ? num(String(cond), String(cond)) : tag(cond); + } + buildQuery(schema, filter, options) { + const types = this.typesFor(schema); + const parts = []; + for (const [field, cond] of Object.entries(filter)) { + if (field === '$or') { + const cls = cond.map(c => `(${this.buildQuery(schema, c)})`); + parts.push(`(${cls.join(' | ')})`); + continue; + } + const ft = types[field] ?? 'TAG'; + parts.push(this.clauseFor(field, ft, cond)); + } + // soft-delete : exclure les supprimés sauf includeDeleted + if (schema.softDelete && !options?.includeDeleted && !('_deleted' in filter)) { + parts.push('@_deleted:[0 0]'); + } + return parts.length ? parts.join(' ') : '*'; + } + /** Exécute FT.SEARCH et renvoie les documents JSON. */ + async ftSearch(schema, query, options, countOnly = false) { + const args = [this.indexOf(schema.collection), query, 'DIALECT', 2]; + if (countOnly) { + args.push('LIMIT', 0, 0); + } + else { + if (options?.sort) { + const [f, dir] = Object.entries(options.sort)[0]; + args.push('SORTBY', f, (String(dir) === 'desc' || String(dir) === '-1') ? 'DESC' : 'ASC'); + } + args.push('LIMIT', options?.skip ?? 0, options?.limit ?? 10000); + // RETURN : count = nombre TOTAL de tokens suivants ($, AS, __doc = 3). + args.push('RETURN', 3, '$', 'AS', '__doc'); + } + const reply = await this.client().call('FT.SEARCH', ...args); + const total = Number(reply[0]); + const rows = []; + if (!countOnly) { + // reply = [total, key1, [ '__doc', '' ], key2, [...], ...] + for (let i = 1; i < reply.length; i += 2) { + const fields = reply[i + 1]; + const idx = fields.indexOf('__doc'); + if (idx !== -1) { + let doc = JSON.parse(fields[idx + 1]); + if (options?.select?.length) { + const sel = new Set(['id', ...options.select]); + doc = Object.fromEntries(Object.entries(doc).filter(([k]) => sel.has(k))); + } + rows.push(doc); + } + } + } + return { total, rows }; + } + // --- Lifecycle --- + async connect(config) { + this.config = config; + let Redis; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'ioredis'); + Redis = (mod.default ?? mod); + } + catch (e) { + throw new Error(`Redis driver not found. Install it: npm install ioredis\nOriginal error: ${e instanceof Error ? e.message : String(e)}`); + } + this.db = new Redis(config.uri); + } + async disconnect() { + try { + await this.db?.quit(); + } + catch { /* ignore */ } + this.db = null; + } + async testConnection() { + try { + return (await this.db?.ping()) === 'PONG'; + } + catch { + return false; + } // scan-ignore: testConnection retourne explicitement boolean + } + // --- Schema : crée un index RediSearch par entité --- + async initSchema(schemas) { + const strategy = this.config?.schemaStrategy ?? 'none'; + for (const schema of schemas) { + if (strategy === 'create' || strategy === 'create-drop') { + try { + await this.client().call('FT.DROPINDEX', this.indexOf(schema.collection)); + } + catch { /* pas d'index */ } + await this.truncateTable(schema.collection); + } + if (strategy === 'none' || strategy === 'validate') + continue; + await this.ensureIndex(schema); + } + } + async ensureIndex(schema) { + const types = this.typesFor(schema); + const schemaArgs = []; + for (const [field, ft] of Object.entries(types)) { + schemaArgs.push(`$.${field}`, 'AS', field, ft); + if (ft !== 'TEXT') + schemaArgs.push('SORTABLE'); + } + try { + await this.client().call('FT.CREATE', this.indexOf(schema.collection), 'ON', 'JSON', 'PREFIX', 1, this.keyPrefix(schema.collection), 'SCHEMA', ...schemaArgs); + } + catch (e) { + if (!/already exists/i.test(e.message)) + throw e; + } + } + // --- CRUD --- + async find(schema, filter, options) { + const { rows } = await this.ftSearch(schema, this.buildQuery(schema, filter, options), options); + return rows; + } + async findOne(schema, filter, options) { + const rows = await this.find(schema, filter, { ...options, limit: 1 }); + return rows[0] ?? null; + } + async findById(schema, id, options) { + const raw = await this.client().call('JSON.GET', this.keyOf(schema, id)); + if (!raw) + return null; + const doc = JSON.parse(raw); + if (schema.softDelete && !options?.includeDeleted && doc.deletedAt != null) + return null; + return doc; + } + async create(schema, data) { + const id = data.id ?? this.genId(); + const payload = this.withTimestamps({ ...data, id }, schema, true); + if (schema.softDelete) { + payload.deletedAt = payload.deletedAt ?? null; + payload._deleted = 0; + } + await this.client().call('JSON.SET', this.keyOf(schema, id), '$', JSON.stringify(payload)); + return payload; + } + async update(schema, id, data) { + const raw = await this.client().call('JSON.GET', this.keyOf(schema, id)); + if (!raw) + return null; + const cur = JSON.parse(raw); + const { id: _ig, ...rest } = data; + void _ig; + const next = this.withTimestamps({ ...cur, ...rest }, schema, false); + await this.client().call('JSON.SET', this.keyOf(schema, id), '$', JSON.stringify(next)); + return next; + } + async updateMany(schema, filter, data) { + const rows = await this.find(schema, filter); + let n = 0; + for (const r of rows) + if (await this.update(schema, r.id, data)) + n++; + return n; + } + async delete(schema, id) { + if (schema.softDelete) { + return (await this.update(schema, id, { deletedAt: new Date().toISOString(), _deleted: 1 })) != null; + } + return (await this.client().del(this.keyOf(schema, id))) > 0; + } + async deleteMany(schema, filter) { + const rows = await this.find(schema, filter); + let n = 0; + for (const r of rows) + if (await this.delete(schema, r.id)) + n++; + return n; + } + // --- Queries --- + async count(schema, filter, options) { + const { total } = await this.ftSearch(schema, this.buildQuery(schema, filter, options), options, true); + return total; + } + async distinct(schema, field, filter, options) { + const rows = await this.find(schema, filter, options); + return [...new Set(rows.map(r => r[field]))]; + } + async aggregate(_schema, _stages, _options) { + throw new Error('Redis: aggregate() non implémenté (utiliser FT.AGGREGATE — évolution).'); + } + // --- Relations (lookup M2O) --- + async populate(schema, doc, relations) { + for (const relName of relations) { + const rel = schema.relations?.[relName]; + if (!rel || rel.type === 'one-to-many' || rel.type === 'many-to-many') + continue; + const fk = rel.joinColumn ?? relName; + const refId = doc[fk] ?? doc[relName]; + if (typeof refId === 'string') { + const raw = await this.client().call('JSON.GET', `${this.prefix()}${rel.target.toLowerCase()}s:${refId}`); + if (raw) + doc[relName] = JSON.parse(raw); + } + } + return doc; + } + async findWithRelations(schema, filter, relations, options) { + const rows = await this.find(schema, filter, options); + return Promise.all(rows.map(r => this.populate(schema, r, relations))); + } + async findByIdWithRelations(schema, id, relations, options) { + const doc = await this.findById(schema, id, options); + return doc ? this.populate(schema, doc, relations) : null; + } + // --- Upsert --- + async upsert(schema, filter, data) { + const existing = await this.findOne(schema, filter); + if (existing) + return (await this.update(schema, existing.id, data)); + return this.create(schema, data); + } + // --- Atomic / array ops (RedisJSON) --- + async increment(schema, id, field, amount) { + await this.client().call('JSON.NUMINCRBY', this.keyOf(schema, id), `$.${field}`, amount); + if (schema.timestamps) + await this.client().call('JSON.SET', this.keyOf(schema, id), '$.updatedAt', JSON.stringify(new Date().toISOString())); + return (await this.findById(schema, id)); + } + async addToSet(schema, id, field, value) { + const doc = await this.findById(schema, id); + if (!doc) + return null; + const arr = Array.isArray(doc[field]) ? doc[field] : []; + if (!arr.includes(value)) + arr.push(value); + return this.update(schema, id, { [field]: arr }); + } + async pull(schema, id, field, value) { + const doc = await this.findById(schema, id); + if (!doc) + return null; + const arr = Array.isArray(doc[field]) ? doc[field] : []; + return this.update(schema, id, { [field]: arr.filter(x => x !== value) }); + } + // --- Text search : RediSearch full-text sur les champs TEXT --- + async search(schema, query, fields, options) { + const types = this.typesFor(schema); + const textFields = fields.filter(f => types[f]); // indexés + const q = textFields.length + ? textFields.map(f => `@${f}:*${escTag(query)}*`).join(' | ') + : `*${escTag(query)}*`; + const { rows } = await this.ftSearch(schema, q, options); + return rows; + } + // --- Transactions : pass-through (multi-clés non atomique ici) --- + async $transaction(cb) { return cb(this); } + async beginTx() { + throw new Error('Redis: API tx manuelle non supportée — utiliser $transaction(cb).'); + } + // --- Drops / truncate --- + async dropTable(tableName) { await this.truncateTable(tableName); } + async truncateTable(tableName) { + const c = this.client(); + let cursor = '0'; + do { + const [next, batch] = await c.call('SCAN', cursor, 'MATCH', `${this.keyPrefix(tableName)}*`, 'COUNT', 200); + if (batch.length) + await c.del(...batch); + cursor = next; + } while (cursor !== '0'); + } + async dropSchema(schemas) { + const dropped = []; + for (const s of schemas) { + try { + await this.client().call('FT.DROPINDEX', this.indexOf(s.collection)); + } + catch { /* pas d'index */ } + await this.truncateTable(s.collection); + dropped.push(s.collection); + } + return dropped; + } + async truncateAll(schemas) { return this.dropSchema(schemas); } +} +// ============================================================ +// Factory export +// ============================================================ +export function createDialect() { + return new RedisDialect(); +} diff --git a/llms.txt b/llms.txt index 6308480..c7cb480 100644 --- a/llms.txt +++ b/llms.txt @@ -1,15 +1,15 @@ # @mostajs/orm — fiche LLM -> ORM multi-dialecte inspiré d'Hibernate — une seule API, 17 bases de données, zéro lock-in. +> ORM multi-dialecte inspiré d'Hibernate — une seule API, 18 bases de données, zéro lock-in. -- Version: 2.8.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI -- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** ; **dialecte `firebird` (OLTP relationnel FB 3.0+, validé live, 2.7.0)** ; **dialecte `clickhouse` (OLAP colonnaire MergeTree, append/analytique, validé live, 2.8.0)** +- Version: 2.9.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI +- Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** ; **dialecte `firebird` (OLTP relationnel FB 3.0+, validé live, 2.7.0)** ; **dialecte `clickhouse` (OLAP colonnaire MergeTree, append/analytique, validé live, 2.8.0)** ; **dialecte `redis` (NoSQL doc Redis Stack : RedisJSON + RediSearch, validé live, 2.9.0)** ## RÔLE Couche d'accès aux données unifiée pour Node.js/TypeScript. Le développeur décrit ses entités sous forme d'objets `EntitySchema` (fields, relations, indexes) et obtient une API CRUD/query identique quel que soit le SGBD cible : MongoDB, SQLite, PostgreSQL, MySQL, MariaDB, Oracle, MSSQL, CockroachDB, DB2, SAP HANA, HSQLDB, Spanner, Sybase, -DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo), Firebird (OLTP) et ClickHouse (OLAP colonnaire). +DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo), Firebird (OLTP), ClickHouse (OLAP) et Redis (NoSQL doc, RedisJSON+RediSearch). Deux dialectes WASM (zéro binaire natif) bootent dans le navigateur, les WebContainers (StackBlitz / Bolt.new) et l'edge — même API/SQL que leur moteur respectif : `sqljs` (SQLite WASM, via sql.js) et `pglite` (PostgreSQL WASM, via @electric-sql/pglite ; @@ -23,7 +23,7 @@ ConnectionConfig = persistence.xml). C'est le module fondation de l'écosystème npm i @mostajs/orm (driver natif requis selon dialecte : better-sqlite3, pg, mysql2, mariadb, oracledb, mssql, ibm_db, @sap/hana-client, @google-cloud/spanner, @google-cloud/firestore, duckdb, -node-firebird, @clickhouse/client, mongoose — peer/optional deps) +node-firebird, @clickhouse/client, ioredis, mongoose — peer/optional deps) Pour le navigateur / WebContainer / edge (WASM pur, aucun binaire natif — à utiliser dans Bolt.new / StackBlitz / Cloudflare Workers où better-sqlite3/pg ne chargent pas) : - SQLite : `npm i sql.js` + `{ dialect: 'sqljs', uri: ':memory:' }` ; persistance fichier diff --git a/package.json b/package.json index 5ae658c..162b7fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mostajs/orm", - "version": "2.8.0", - "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 17 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", + "version": "2.9.0", + "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 18 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", "author": "Dr Hamid MADANI ", "license": "AGPL-3.0-or-later", "type": "module", @@ -92,6 +92,7 @@ }, "peerDependencies": { "@clickhouse/client": ">=1.0.0", + "ioredis": ">=5.0.0", "@electric-sql/pglite": ">=0.2.0", "@google-cloud/firestore": ">=7.0.0", "@google-cloud/spanner": ">=7.0.0", @@ -113,6 +114,9 @@ "@clickhouse/client": { "optional": true }, + "ioredis": { + "optional": true + }, "sql.js": { "optional": true }, diff --git a/src/core/config.ts b/src/core/config.ts index 1dbc48f..13c4e8d 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -132,6 +132,10 @@ export const DIALECT_CONFIGS: Record = { installHint: 'npm install @clickhouse/client', label: 'ClickHouse (OLAP colonnaire — append/analytique, HTTP)', }, + redis: { + installHint: 'npm install ioredis', + label: 'Redis Stack (NoSQL doc — RedisJSON + RediSearch)', + }, }; /** diff --git a/src/core/factory.ts b/src/core/factory.ts index afd0fce..7183fdf 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -54,6 +54,7 @@ const DIALECT_LOADERS: Record Promise<{ createDialect: () => firestore: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firestore.dialect.js'), firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), clickhouse: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/clickhouse.dialect.js'), + redis: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/redis.dialect.js'), }; /** diff --git a/src/core/types.ts b/src/core/types.ts index cb26256..88389b2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -286,7 +286,8 @@ export type DialectType = | 'duckdb' | 'firestore' | 'firebird' - | 'clickhouse'; + | 'clickhouse' + | 'redis'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) diff --git a/src/dialects/redis.dialect.ts b/src/dialects/redis.dialect.ts new file mode 100644 index 0000000..6cd232b --- /dev/null +++ b/src/dialects/redis.dialect.ts @@ -0,0 +1,396 @@ +// Redis Dialect — implements IDialect (NoSQL documentaire, façon Mongo) sur Redis Stack. +// Stockage : RedisJSON (JSON.SET/GET/NUMINCRBY) ; requêtes : RediSearch (FT.CREATE/FT.SEARCH). +// - chaque entité = document JSON à la clé `:` ; +// - un index FT par entité (créé à initSchema) → filtre/tri/count côté serveur (O(log n)) ; +// - relations M2O par lookup (JSON.GET), façon populate Mongo/Firestore. +// Driver : npm install ioredis (Redis Stack : redis-stack-server, modules search + ReJSON). +// Cf. docs/EXTENSIONS-REDIS-ELASTICSEARCH-EMBARQUE.md +// Author: Dr Hamid MADANI + +import type { + IDialect, + DialectType, + ConnectionConfig, + EntitySchema, + FieldDef, + FilterQuery as DALFilter, + QueryOptions, + AggregateStage, + TxHandle, +} from '../core/types.js'; + +interface RedisClient { + call(cmd: string, ...args: (string | number)[]): Promise; + del(...keys: string[]): Promise; + ping(): Promise; + quit(): Promise; +} + +// FieldType @mostajs → type d'index RediSearch +type FtType = 'TAG' | 'NUMERIC' | 'TEXT'; +function ftTypeOf(field: FieldDef): FtType { + switch (field.type) { + case 'number': return 'NUMERIC'; + case 'text': return 'TEXT'; + case 'boolean': + case 'string': + case 'date': + default: return 'TAG'; + } +} + +// Échappe les caractères spéciaux RediSearch dans une valeur TAG (UUID `-`, etc.). +function escTag(v: unknown): string { + return String(v).replace(/[ ,.<>{}\[\]"':;!@#$%^&*()\-+=~/\\]/g, '\\$&'); +} + +export class RedisDialect implements IDialect { + readonly dialectType: DialectType = 'redis'; + private config: ConnectionConfig | null = null; + private db: RedisClient | null = null; + /** Cache des types de champs par collection (pour traduire les filtres en FT). */ + private fieldTypes = new Map>(); + + // --- Helpers --- + + private prefix(): string { return this.config?.tablePrefix ?? ''; } + private keyOf(schema: EntitySchema, id: string): string { return `${this.prefix()}${schema.collection}:${id}`; } + private keyPrefix(collection: string): string { return `${this.prefix()}${collection}:`; } + private indexOf(collection: string): string { return `idx:${this.prefix()}${collection}`; } + private client(): RedisClient { + if (!this.db) throw new Error('Redis not connected. Call connect() first.'); + return this.db; + } + private genId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.floor(this.rnd() * 16); + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); + } + private _seed = 987654321; + private rnd(): number { this._seed = (this._seed * 1103515245 + 12345 + Date.now()) % 2147483648; return this._seed / 2147483648; } + + private withTimestamps(data: Record, schema: EntitySchema, isCreate: boolean): Record { + if (!schema.timestamps) return data; + const now = new Date().toISOString(); + return isCreate ? { createdAt: now, updatedAt: now, ...data } : { ...data, updatedAt: now }; + } + + private typesFor(schema: EntitySchema): Record { + let t = this.fieldTypes.get(schema.collection); + if (t) return t; + t = {}; + for (const [name, f] of Object.entries(schema.fields || {})) { + if (f.type === 'json' || f.type === 'array') continue; // non indexés + t[name] = ftTypeOf(f); + } + for (const [name, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') continue; + t[rel.joinColumn || name] = 'TAG'; + } + if (schema.timestamps) { t.createdAt = 'TAG'; t.updatedAt = 'TAG'; } + if (schema.softDelete) t._deleted = 'NUMERIC'; + this.fieldTypes.set(schema.collection, t); + return t; + } + + // --- Traduction filtre @mostajs → requête RediSearch --- + + private clauseFor(field: string, ftType: FtType, cond: unknown): string { + const tag = (v: unknown) => `@${field}:{${escTag(v)}}`; + const num = (lo: string, hi: string) => `@${field}:[${lo} ${hi}]`; + if (cond !== null && typeof cond === 'object' && !Array.isArray(cond)) { + const parts: string[] = []; + for (const [op, val] of Object.entries(cond as Record)) { + switch (op) { + case '$eq': parts.push(ftType === 'NUMERIC' ? num(String(val), String(val)) : tag(val)); break; + case '$ne': parts.push(`-(${ftType === 'NUMERIC' ? num(String(val), String(val)) : tag(val)})`); break; + case '$gt': parts.push(num(`(${Number(val)}`, '+inf')); break; + case '$gte': parts.push(num(`${Number(val)}`, '+inf')); break; + case '$lt': parts.push(num('-inf', `(${Number(val)}`)); break; + case '$lte': parts.push(num('-inf', `${Number(val)}`)); break; + case '$in': parts.push(`@${field}:{${(val as unknown[]).map(escTag).join('|')}}`); break; + case '$nin': parts.push(`-@${field}:{${(val as unknown[]).map(escTag).join('|')}}`); break; + case '$exists': parts.push(val ? `@${field}:*` : `-@${field}:*`); break; + case '$regex': parts.push(`@${field}:*${escTag(val)}*`); break; + default: throw new Error(`Redis: opérateur de filtre inconnu "${op}".`); + } + } + return parts.join(' '); + } + return ftType === 'NUMERIC' ? num(String(cond), String(cond)) : tag(cond); + } + + private buildQuery(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): string { + const types = this.typesFor(schema); + const parts: string[] = []; + for (const [field, cond] of Object.entries(filter as Record)) { + if (field === '$or') { + const cls = (cond as DALFilter[]).map(c => `(${this.buildQuery(schema, c)})`); + parts.push(`(${cls.join(' | ')})`); + continue; + } + const ft = types[field] ?? 'TAG'; + parts.push(this.clauseFor(field, ft, cond)); + } + // soft-delete : exclure les supprimés sauf includeDeleted + if (schema.softDelete && !(options as { includeDeleted?: boolean } | undefined)?.includeDeleted && !('_deleted' in (filter as object))) { + parts.push('@_deleted:[0 0]'); + } + return parts.length ? parts.join(' ') : '*'; + } + + /** Exécute FT.SEARCH et renvoie les documents JSON. */ + private async ftSearch(schema: EntitySchema, query: string, options?: QueryOptions, countOnly = false): Promise<{ total: number; rows: T[] }> { + const args: (string | number)[] = [this.indexOf(schema.collection), query, 'DIALECT', 2]; + if (countOnly) { args.push('LIMIT', 0, 0); } + else { + if (options?.sort) { + const [f, dir] = Object.entries(options.sort)[0]; + args.push('SORTBY', f, (String(dir) === 'desc' || String(dir) === '-1') ? 'DESC' : 'ASC'); + } + args.push('LIMIT', options?.skip ?? 0, options?.limit ?? 10000); + // RETURN : count = nombre TOTAL de tokens suivants ($, AS, __doc = 3). + args.push('RETURN', 3, '$', 'AS', '__doc'); + } + const reply = await this.client().call('FT.SEARCH', ...args) as unknown[]; + const total = Number(reply[0]); + const rows: T[] = []; + if (!countOnly) { + // reply = [total, key1, [ '__doc', '' ], key2, [...], ...] + for (let i = 1; i < reply.length; i += 2) { + const fields = reply[i + 1] as string[]; + const idx = fields.indexOf('__doc'); + if (idx !== -1) { + let doc = JSON.parse(fields[idx + 1]); + if (options?.select?.length) { + const sel = new Set(['id', ...options.select]); + doc = Object.fromEntries(Object.entries(doc).filter(([k]) => sel.has(k))); + } + rows.push(doc as T); + } + } + } + return { total, rows }; + } + + // --- Lifecycle --- + + async connect(config: ConnectionConfig): Promise { + this.config = config; + let Redis: new (uri: string) => RedisClient; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'ioredis' as string); + Redis = ((mod as { default?: unknown }).default ?? mod) as never; + } catch (e) { + throw new Error(`Redis driver not found. Install it: npm install ioredis\nOriginal error: ${e instanceof Error ? e.message : String(e)}`); + } + this.db = new Redis(config.uri); + } + + async disconnect(): Promise { + try { await this.db?.quit(); } catch { /* ignore */ } + this.db = null; + } + + async testConnection(): Promise { + try { return (await this.db?.ping()) === 'PONG'; } + catch { return false; } // scan-ignore: testConnection retourne explicitement boolean + } + + // --- Schema : crée un index RediSearch par entité --- + + async initSchema(schemas: EntitySchema[]): Promise { + const strategy = this.config?.schemaStrategy ?? 'none'; + for (const schema of schemas) { + if (strategy === 'create' || strategy === 'create-drop') { + try { await this.client().call('FT.DROPINDEX', this.indexOf(schema.collection)); } catch { /* pas d'index */ } + await this.truncateTable(schema.collection); + } + if (strategy === 'none' || strategy === 'validate') continue; + await this.ensureIndex(schema); + } + } + + private async ensureIndex(schema: EntitySchema): Promise { + const types = this.typesFor(schema); + const schemaArgs: string[] = []; + for (const [field, ft] of Object.entries(types)) { + schemaArgs.push(`$.${field}`, 'AS', field, ft); + if (ft !== 'TEXT') schemaArgs.push('SORTABLE'); + } + try { + await this.client().call('FT.CREATE', this.indexOf(schema.collection), + 'ON', 'JSON', 'PREFIX', 1, this.keyPrefix(schema.collection), 'SCHEMA', ...schemaArgs); + } catch (e) { + if (!/already exists/i.test((e as Error).message)) throw e; + } + } + + // --- CRUD --- + + async find(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): Promise { + const { rows } = await this.ftSearch(schema, this.buildQuery(schema, filter, options), options); + return rows; + } + async findOne(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): Promise { + const rows = await this.find(schema, filter, { ...options, limit: 1 }); + return rows[0] ?? null; + } + async findById(schema: EntitySchema, id: string, options?: QueryOptions): Promise { + const raw = await this.client().call('JSON.GET', this.keyOf(schema, id)) as string | null; + if (!raw) return null; + const doc = JSON.parse(raw); + if (schema.softDelete && !(options as { includeDeleted?: boolean } | undefined)?.includeDeleted && doc.deletedAt != null) return null; + return doc as T; + } + + async create(schema: EntitySchema, data: Record): Promise { + const id = (data.id as string) ?? this.genId(); + const payload = this.withTimestamps({ ...data, id }, schema, true); + if (schema.softDelete) { payload.deletedAt = payload.deletedAt ?? null; payload._deleted = 0; } + await this.client().call('JSON.SET', this.keyOf(schema, id), '$', JSON.stringify(payload)); + return payload as T; + } + + async update(schema: EntitySchema, id: string, data: Record): Promise { + const raw = await this.client().call('JSON.GET', this.keyOf(schema, id)) as string | null; + if (!raw) return null; + const cur = JSON.parse(raw); + const { id: _ig, ...rest } = data; void _ig; + const next = this.withTimestamps({ ...cur, ...rest }, schema, false); + await this.client().call('JSON.SET', this.keyOf(schema, id), '$', JSON.stringify(next)); + return next as T; + } + async updateMany(schema: EntitySchema, filter: DALFilter, data: Record): Promise { + const rows = await this.find<{ id: string }>(schema, filter); + let n = 0; for (const r of rows) if (await this.update(schema, r.id, data)) n++; return n; + } + + async delete(schema: EntitySchema, id: string): Promise { + if (schema.softDelete) { + return (await this.update(schema, id, { deletedAt: new Date().toISOString(), _deleted: 1 })) != null; + } + return (await this.client().del(this.keyOf(schema, id))) > 0; + } + async deleteMany(schema: EntitySchema, filter: DALFilter): Promise { + const rows = await this.find<{ id: string }>(schema, filter); + let n = 0; for (const r of rows) if (await this.delete(schema, r.id)) n++; return n; + } + + // --- Queries --- + + async count(schema: EntitySchema, filter: DALFilter, options?: QueryOptions): Promise { + const { total } = await this.ftSearch(schema, this.buildQuery(schema, filter, options), options, true); + return total; + } + async distinct(schema: EntitySchema, field: string, filter: DALFilter, options?: QueryOptions): Promise { + const rows = await this.find>(schema, filter, options); + return [...new Set(rows.map(r => r[field]))]; + } + async aggregate(_schema: EntitySchema, _stages: AggregateStage[], _options?: QueryOptions): Promise { + throw new Error('Redis: aggregate() non implémenté (utiliser FT.AGGREGATE — évolution).'); + } + + // --- Relations (lookup M2O) --- + + private async populate>(schema: EntitySchema, doc: T, relations: string[]): Promise { + for (const relName of relations) { + const rel = schema.relations?.[relName]; + if (!rel || rel.type === 'one-to-many' || rel.type === 'many-to-many') continue; + const fk = rel.joinColumn ?? relName; + const refId = doc[fk] ?? doc[relName]; + if (typeof refId === 'string') { + const raw = await this.client().call('JSON.GET', `${this.prefix()}${rel.target.toLowerCase()}s:${refId}`) as string | null; + if (raw) (doc as Record)[relName] = JSON.parse(raw); + } + } + return doc; + } + async findWithRelations(schema: EntitySchema, filter: DALFilter, relations: string[], options?: QueryOptions): Promise { + const rows = await this.find>(schema, filter, options); + return Promise.all(rows.map(r => this.populate(schema, r, relations))) as Promise; + } + async findByIdWithRelations(schema: EntitySchema, id: string, relations: string[], options?: QueryOptions): Promise { + const doc = await this.findById>(schema, id, options); + return doc ? (this.populate(schema, doc, relations) as Promise) : null; + } + + // --- Upsert --- + + async upsert(schema: EntitySchema, filter: DALFilter, data: Record): Promise { + const existing = await this.findOne<{ id: string }>(schema, filter); + if (existing) return (await this.update(schema, existing.id, data))!; + return this.create(schema, data); + } + + // --- Atomic / array ops (RedisJSON) --- + + async increment(schema: EntitySchema, id: string, field: string, amount: number): Promise> { + await this.client().call('JSON.NUMINCRBY', this.keyOf(schema, id), `$.${field}`, amount); + if (schema.timestamps) await this.client().call('JSON.SET', this.keyOf(schema, id), '$.updatedAt', JSON.stringify(new Date().toISOString())); + return (await this.findById>(schema, id))!; + } + async addToSet(schema: EntitySchema, id: string, field: string, value: unknown): Promise | null> { + const doc = await this.findById>(schema, id); + if (!doc) return null; + const arr = Array.isArray(doc[field]) ? (doc[field] as unknown[]) : []; + if (!arr.includes(value)) arr.push(value); + return this.update(schema, id, { [field]: arr }); + } + async pull(schema: EntitySchema, id: string, field: string, value: unknown): Promise | null> { + const doc = await this.findById>(schema, id); + if (!doc) return null; + const arr = Array.isArray(doc[field]) ? (doc[field] as unknown[]) : []; + return this.update(schema, id, { [field]: arr.filter(x => x !== value) }); + } + + // --- Text search : RediSearch full-text sur les champs TEXT --- + + async search(schema: EntitySchema, query: string, fields: string[], options?: QueryOptions): Promise { + const types = this.typesFor(schema); + const textFields = fields.filter(f => types[f]); // indexés + const q = textFields.length + ? textFields.map(f => `@${f}:*${escTag(query)}*`).join(' | ') + : `*${escTag(query)}*`; + const { rows } = await this.ftSearch(schema, q, options); + return rows; + } + + // --- Transactions : pass-through (multi-clés non atomique ici) --- + + async $transaction(cb: (tx: IDialect) => Promise): Promise { return cb(this); } + async beginTx(): Promise { + throw new Error('Redis: API tx manuelle non supportée — utiliser $transaction(cb).'); + } + + // --- Drops / truncate --- + + async dropTable(tableName: string): Promise { await this.truncateTable(tableName); } + async truncateTable(tableName: string): Promise { + const c = this.client(); + let cursor = '0'; + do { + const [next, batch] = await c.call('SCAN', cursor, 'MATCH', `${this.keyPrefix(tableName)}*`, 'COUNT', 200) as [string, string[]]; + if (batch.length) await c.del(...batch); + cursor = next; + } while (cursor !== '0'); + } + async dropSchema(schemas: EntitySchema[]): Promise { + const dropped: string[] = []; + for (const s of schemas) { + try { await this.client().call('FT.DROPINDEX', this.indexOf(s.collection)); } catch { /* pas d'index */ } + await this.truncateTable(s.collection); dropped.push(s.collection); + } + return dropped; + } + async truncateAll(schemas: EntitySchema[]): Promise { return this.dropSchema(schemas); } +} + +// ============================================================ +// Factory export +// ============================================================ + +export function createDialect(): IDialect { + return new RedisDialect(); +} From e968a4835092102aae84d94f5c4c6abe7b025084 Mon Sep 17 00:00:00 2001 From: MADANI Date: Fri, 12 Jun 2026 19:56:40 +0100 Subject: [PATCH 4/6] =?UTF-8?q?feat(dialect):=20ajoute=20Cassandra=20(19e?= =?UTF-8?q?=20dialecte,=20wide-column=20CQL=20=E2=80=94=20valid=C3=A9=20li?= =?UTF-8?q?ve=20amia=2020/20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialecte `cassandra` (AbstractSqlDialect + quirks CQL) pour Apache Cassandra. Driver officiel cassandra-driver. VALIDÉ LIVE sur amia : test-sgbd 20/20. Quirks CQL gérés : - CREATE TABLE (id PRIMARY KEY) sans NOT NULL/UNIQUE/FK/DEFAULT ; - ALLOW FILTERING auto sur les SELECT filtrés sur colonne non-clé ; - WHERE 1=1 retiré (tautologie des filtres vides, refusée par CQL) ; - LIMIT sans OFFSET ; pas d'ORDER BY arbitraire ; INSERT = upsert natif ; - placeholders ? (prepared) ; system_schema.tables/columns ; Long → number. Câblage : DialectType, DIALECT_LOADERS, DIALECT_CONFIGS, peerDependencies (optionnel). Release 2.10.0 : CHANGELOG, llms.txt, README (19 bases + section « Live validation » avec matrice 20/20 par dialecte). NB infra : Cassandra 4.1 exige Java 11 (option JVM CMS retirée en Java 14+ → ne démarre pas sous Java 17) ; libérer le port 9042 des zombies (fuser -k). Author: Dr Hamid MADANI --- CHANGELOG.md | 23 +++ README.md | 40 ++++-- dist/core/config.js | 4 + dist/core/factory.js | 1 + dist/core/types.d.ts | 2 +- dist/dialects/cassandra.dialect.d.ts | 48 +++++++ dist/dialects/cassandra.dialect.js | 186 ++++++++++++++++++++++++ llms.txt | 8 +- package.json | 8 +- src/core/config.ts | 4 + src/core/factory.ts | 1 + src/core/types.ts | 3 +- src/dialects/cassandra.dialect.ts | 206 +++++++++++++++++++++++++++ 13 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 dist/dialects/cassandra.dialect.d.ts create mode 100644 dist/dialects/cassandra.dialect.js create mode 100644 src/dialects/cassandra.dialect.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7f414..36b2ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to `@mostajs/orm` will be documented in this file. +## [2.10.0] — 2026-06-12 + +### Feat — Dialecte Cassandra (19e dialecte · wide-column CQL) + +Nouveau dialecte `cassandra` (`AbstractSqlDialect` + quirks CQL) pour **Apache Cassandra** +(NoSQL wide-column distribué). Driver officiel `cassandra-driver` (peer optionnel). +**Validé LIVE sur amia : harnais `test-sgbd` 20/20.** + +- Connexion : `cassandra://host:9042/keyspace[?dc=datacenter1]`. +- **Quirks CQL gérés** : `CREATE TABLE (… PRIMARY KEY)` sans NOT NULL/UNIQUE/FK/DEFAULT ; + `ALLOW FILTERING` ajouté automatiquement aux `SELECT` filtrés sur colonne non-clé ; + `LIMIT` sans `OFFSET` ; pas d'`ORDER BY` arbitraire ; `INSERT` = upsert natif ; + placeholders `?` (prepared) ; introspection `system_schema.tables/columns` ; types + `text/double/boolean/timestamp` ; `Long` → number à la lecture ; **`WHERE 1=1` retiré** + (tautologie SQL des filtres vides, refusée par CQL). + +Câblage : `DialectType`, `DIALECT_LOADERS`, `DIALECT_CONFIGS`, `peerDependencies` +(`cassandra-driver` optionnel). + +**Validé LIVE** sur un nœud `cassandra` 4.1 natif (sans Docker, amia) : harnais `test-sgbd` +**20/20**. NB infra : Cassandra 4.1 exige **Java 11** (option JVM CMS retirée en Java 14+ → +ne démarre pas sous Java 17). + ## [2.9.0] — 2026-06-12 ### Feat — Dialecte Redis (18e dialecte · NoSQL documentaire, Redis Stack) diff --git a/README.md b/README.md index ab9cf80..00c7c41 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ # @mostajs/orm -> **Plug & Play ORM to Drive 18 Databases at Once** +> **Plug & Play ORM to Drive 19 Databases at Once** [![npm version](https://img.shields.io/npm/v/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![npm downloads](https://img.shields.io/npm/dm/@mostajs/orm.svg)](https://www.npmjs.com/package/@mostajs/orm) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE) -[![dialects](https://img.shields.io/badge/dialects-18-success.svg)](#databases) +[![dialects](https://img.shields.io/badge/dialects-19-success.svg)](#databases) [![Types: TypeScript](https://img.shields.io/badge/types-TypeScript-blue.svg)](https://www.typescriptlang.org/) [![bundle size](https://img.shields.io/bundlephobia/minzip/@mostajs/orm)](https://bundlephobia.com/package/@mostajs/orm) -Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 18 databases, zero lock-in, bundler-friendly**. +Hibernate-inspired multi-dialect ORM for Node.js & TypeScript — **one API, 19 databases, zero lock-in, bundler-friendly**. 📦 **npm** · https://www.npmjs.com/package/@mostajs/orm 🐙 **GitHub** · https://github.com/apolocine/mosta-orm @@ -28,10 +28,10 @@ cd ~/my-app && ./01-quickstart-sqlite.sh # runnable in 30 seconds ## Why @mostajs/orm ? -- 🎯 **One API, 18 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. +- 🎯 **One API, 19 dialects.** Switch from PostgreSQL to MongoDB to Firestore to SQLite without rewriting a single repository call. - 🪶 **Zero lock-in.** Native drivers, no proprietary query DSL — your SQL/NoSQL stays portable. - 🧬 **Hibernate / JPA semantics.** `@OneToMany`, cascade types, `SAVEPOINT`, schema strategies (`validate`/`update`/`create`/`create-drop`) — concepts battle-tested for 25 years, ported to TypeScript. -- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 18 databases. +- 🌉 **Drop-in Prisma replacement.** [`@mostajs/orm-bridge`](https://www.npmjs.com/package/@mostajs/orm-bridge) lets you keep your Prisma code while running on any of 19 databases. - 🔁 **Cross-dialect replication built-in.** [`@mostajs/replicator`](https://www.npmjs.com/package/@mostajs/replicator) — CDC + master/slave + failover across SQL ↔ MongoDB. - 🧪 **Bundler-friendly.** Tree-shakable ESM, no `eval`, works with esbuild / Vite / Next.js / Bun out of the box. - 🏷️ **Multi-app DB cohabitation** *(v2.3.0+)*. `DB_TABLE_PREFIX` à la Hibernate `physical_naming_strategy` — let two apps share one Oracle/MSSQL/HANA DB user without colliding on `users`/`roles`/`permissions`. @@ -278,16 +278,40 @@ If `@mostajs/orm` saves you days of glue code, please : ## Databases -SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird · ClickHouse · Redis +SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · CockroachDB · DB2 · SAP HANA · HSQLDB · Spanner · Sybase · DuckDB · Firestore · Firebird · ClickHouse · Redis · Cassandra - **`duckdb`** — OLAP **in-process** engine (file or `:memory:`), SQL ≈ PostgreSQL. Analytics without a server. - **`firestore`** — Google Cloud **Firestore**, NoSQL document store (Mongo-style API). Remote (gRPC/TLS) or local **Java emulator** (no Docker, no key); production via a service-account key. Full-text search delegates to an external search module. - **`firebird`** — **Firebird 3.0+** OLTP relational engine (InterBase lineage). Pure-JS `node-firebird` driver; `ROWS` pagination, UUID ids. Validated live against a native server. - **`clickhouse`** — **ClickHouse** OLAP columnar engine (MergeTree, HTTP). Official `@clickhouse/client` driver. **Append/analytical scope**: `UPDATE`/`DELETE` are mutations (made synchronous), no PK/UNIQUE/FK. Validated live against a native server. - **`redis`** — **Redis Stack** as a real-time document store (`ioredis`). Not a plain key-value cache here: entities are stored as native JSON with **RedisJSON** (`JSON.SET/GET`, atomic `JSON.NUMINCRBY`) and queried **server-side** with **RediSearch** (`FT.CREATE` per entity, `FT.SEARCH` translating `@mostajs` filters to TAG/NUMERIC/TEXT — O(log n), no key scan). Same `@mostajs/orm` API as MongoDB/Firestore. +- **`cassandra`** — Apache **Cassandra** wide-column engine (CQL). Official `cassandra-driver`. Query-first: `PRIMARY KEY` lookups, `ALLOW FILTERING` for non-key filters, native upsert, no JOIN/UNIQUE/FK. Validated live against a native node (Cassandra 4.1 needs Java 11). > **Why Redis here?** Beyond MongoDB/Firestore, the `redis` dialect positions Redis as the **low-latency operational data layer** of the stack — live documents, sessions, queues, search and (via RedisTimeSeries) time-series — the substrate that **feeds the analytical / decision layers** of the ecosystem (e.g. operations-research workloads: assignment, queueing, forecasting). It stores, indexes and serves; it does not compute optima. Heavy aggregation stays delegated (`FT.AGGREGATE` / analytics module). +### Live validation + +Every newly added dialect is validated end-to-end against a **real native engine** (no Docker) +with the shared `test-sgbd` harness — **20 checks**: connection · schema (3 repos) · create +(with relations) · `findById`/`findOne`/`findAll` · `count` · filtered query · `update` · +`upsert` · `delete` · bulk create (10 rows) · filter+count consistency · update loop · relation +integrity · full cleanup. + +| Dialect | Engine (native) | Harness | Result | +|---|---|:---:|:---:| +| **Firebird** | `firebird3.0-server` | `test-sgbd` | **20/20** ✅ | +| **ClickHouse** | `clickhouse-server` | `test-sgbd` | **20/20** ✅ | +| **Redis** | `redis-stack-server` (RedisJSON+RediSearch) | `test-sgbd` | **20/20** ✅ | +| **Cassandra** | `cassandra` 4.1 (CQL) | `test-sgbd` | **20/20** ✅ | +| **Firestore** | Java emulator | `test-sgbd` + NoSQL smoke | **20/20** + **41/41** ✅ | +| **DuckDB** | in-process | `test-sgbd` | **20/20** ✅ | + +> Engine-specific quirks surfaced & fixed during live validation — Firebird: `boolean→SMALLINT`, +> `text/json→VARCHAR` (driver BLOB read hangs), Srp+wireCrypt auth; ClickHouse: `MergeTree` +> engine, typed params, `mutations_sync` for synchronous update/delete; Redis: `FT.SEARCH` +> filter translation, TAG escaping; Cassandra: `ALLOW FILTERING`, `WHERE 1=1` stripping, Java 11. +> An HTML report per run is produced by `test-scripts/sgbd-html-report.mjs`. + **+ WASM runtimes** — two zero-binary dialects run in WebAssembly, so the same ORM **boots in the browser / Bolt.new / Cloudflare Workers with no native binary**: - **`sqljs`** — SQLite in WASM (via `sql.js`). In-memory in the browser; file-backed on Node. @@ -352,7 +376,7 @@ Because the WASM build needs **no native binary and no server**, the same typed ```bash npm install @mostajs/orm # + the driver for your dialect : -npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird, @clickhouse/client, ioredis +npm install better-sqlite3 # or: pg, mysql2, mongoose, oracledb, mssql, ibm_db, mariadb, @sap/hana-client, @google-cloud/spanner, duckdb, node-firebird, @clickhouse/client, ioredis, cassandra-driver npm install @google-cloud/firestore # Firestore — NoSQL document store (dialect: 'firestore'); Java emulator in dev, GCP key in prod npm install sql.js # SQLite in the browser / Bolt.new / Workers — no native binary (dialect: 'sqljs') npm install @electric-sql/pglite # PostgreSQL in the browser — idb:// persistence (dialect: 'pglite') @@ -1068,7 +1092,7 @@ const conn = getNamedConnection('audit') | Package | Description | |---|---| -| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 18 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | +| [@mostajs/orm-bridge](https://www.npmjs.com/package/@mostajs/orm-bridge) | Keep your Prisma code, run it on any of the 19 databases (`createPrismaLikeDb()` is a drop-in replacement for `new PrismaClient()`). | | [@mostajs/orm-cli](https://www.npmjs.com/package/@mostajs/orm-cli) | `npx @mostajs/orm-cli` — interactive CLI : convert schemas, init databases, scaffold services, replicator + monitor, seeding, bootstrap Prisma migration. | | [@mostajs/orm-adapter](https://www.npmjs.com/package/@mostajs/orm-adapter) | Convert Prisma / JSON Schema / OpenAPI / native `.mjs` to `EntitySchema[]` (bidirectional). | | [@mostajs/replicator](https://www.npmjs.com/package/@mostajs/replicator) | Cross-dialect replication : CQRS master/slave, CDC rules (snapshot + incremental), wildcard `*`, failover (`promoteToMaster`). As of @mostajs/orm v1.13, Mongo FK columns accept UUID strings coming from SQL dialects (populate falls back to `{ id: uuid }` lookup). | diff --git a/dist/core/config.js b/dist/core/config.js index 0776d4d..b3b4e84 100644 --- a/dist/core/config.js +++ b/dist/core/config.js @@ -124,6 +124,10 @@ export const DIALECT_CONFIGS = { installHint: 'npm install ioredis', label: 'Redis Stack (NoSQL doc — RedisJSON + RediSearch)', }, + cassandra: { + installHint: 'npm install cassandra-driver', + label: 'Cassandra (NoSQL wide-column — CQL, R&D)', + }, }; /** * Get the list of supported dialect types diff --git a/dist/core/factory.js b/dist/core/factory.js index b33247f..bddc862 100644 --- a/dist/core/factory.js +++ b/dist/core/factory.js @@ -43,6 +43,7 @@ const DIALECT_LOADERS = { firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), clickhouse: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/clickhouse.dialect.js'), redis: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/redis.dialect.js'), + cassandra: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/cassandra.dialect.js'), }; /** * Dynamically load a dialect adapter module. diff --git a/dist/core/types.d.ts b/dist/core/types.d.ts index 75a87c6..152ef96 100644 --- a/dist/core/types.d.ts +++ b/dist/core/types.d.ts @@ -189,7 +189,7 @@ export interface AggregateLimitStage { $limit: number; } export type AggregateStage = AggregateMatchStage | AggregateGroupStage | AggregateSortStage | AggregateLimitStage; -export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird' | 'clickhouse' | 'redis'; +export type DialectType = 'mongodb' | 'sqlite' | 'sqljs' | 'postgres' | 'pglite' | 'mysql' | 'mariadb' | 'oracle' | 'mssql' | 'cockroachdb' | 'db2' | 'hana' | 'hsqldb' | 'spanner' | 'sybase' | 'duckdb' | 'firestore' | 'firebird' | 'clickhouse' | 'redis' | 'cassandra'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) * diff --git a/dist/dialects/cassandra.dialect.d.ts b/dist/dialects/cassandra.dialect.d.ts new file mode 100644 index 0000000..11615bd --- /dev/null +++ b/dist/dialects/cassandra.dialect.d.ts @@ -0,0 +1,48 @@ +import type { IDialect, DialectType, ConnectionConfig, EntitySchema, FieldDef, QueryOptions } from '../core/types.js'; +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; +interface CassClient { + connect(): Promise; + execute(query: string, params?: unknown[], options?: Record): Promise<{ + rows: Record[]; + }>; + shutdown(): Promise; +} +export declare class CassandraDialect extends AbstractSqlDialect { + readonly dialectType: DialectType; + db: CassClient | null; + private keyspace; + quoteIdentifier(name: string): string; + getPlaceholder(_index: number): string; + fieldToSqlType(field: FieldDef): string; + getIdColumnType(): string; + getTableListQuery(): string; + protected getExistingColumns(tableName: string): Promise>; + protected supportsIfNotExists(): boolean; + protected supportsReturning(): boolean; + protected supportsAlterTableAddForeignKey(): boolean; + protected supportsPartialIndex(): boolean; + protected serializeBoolean(v: boolean): unknown; + protected deserializeBoolean(v: unknown): boolean; + protected serializeDate(value: unknown): unknown; + /** CQL n'a pas d'ILIKE/regex serveur ; LIKE nécessite un index SASI. On reste sur LIKE. */ + protected buildRegexCondition(col: string, _flags?: string): string; + protected buildLimitOffset(options?: QueryOptions): string; + protected buildOrderBy(): string; + protected generateIndexes(): string[]; + protected generateCreateTable(schema: EntitySchema): string; + protected getDropTableSql(tableName: string): string; + private normalizeRow; + doConnect(config: ConnectionConfig): Promise; + doDisconnect(): Promise; + doTestConnection(): Promise; + /** CQL n'accepte pas la tautologie `WHERE 1=1` émise pour les filtres vides. */ + private stripTautology; + /** Ajoute ALLOW FILTERING aux SELECT filtrés sur colonne non-clé. */ + private withAllowFiltering; + doExecuteQuery(sql: string, params: unknown[]): Promise; + doExecuteRun(sql: string, params: unknown[]): Promise<{ + changes: number; + }>; +} +export declare function createDialect(): IDialect; +export {}; diff --git a/dist/dialects/cassandra.dialect.js b/dist/dialects/cassandra.dialect.js new file mode 100644 index 0000000..f308695 --- /dev/null +++ b/dist/dialects/cassandra.dialect.js @@ -0,0 +1,186 @@ +// Cassandra Dialect — extends AbstractSqlDialect (CQL : SQL-like, placeholders `?`). +// NoSQL wide-column distribué. R&D / périmètre borné (cf. roadmap §A6, doc §3). +// Driver : npm install cassandra-driver (DataStax, officiel). +// +// ⚠ PARADIGME CQL (query-first) : +// - pas de JOIN ; requêtes pilotées par la PARTITION KEY (ici `id`) ; +// - WHERE sur colonne non-clé ⇒ `ALLOW FILTERING` (coûteux — OK petit volume) ; +// - pas de UNIQUE/FK ; pas de DEFAULT/NOT NULL ; pas d'OFFSET ni d'ORDER BY arbitraire ; +// - upsert natif (INSERT = upsert) ; pas de compteur d'affected-rows. +// +// ✅ STATUT : VALIDÉ LIVE sur amia (test-sgbd 20/20, 2026-06-12). NB : Cassandra 4.1 exige +// Java 11 (option JVM CMS retirée en Java 14+ → ne démarre pas sous Java 17). CQL n'accepte +// pas la tautologie `WHERE 1=1` des filtres vides → on la retire (stripTautology). +// +// Author: Dr Hamid MADANI +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; +const CASSANDRA_TYPE_MAP = { + string: 'text', + text: 'text', + number: 'double', + boolean: 'boolean', + date: 'timestamp', + json: 'text', + array: 'text', +}; +export class CassandraDialect extends AbstractSqlDialect { + dialectType = 'cassandra'; + db = null; + keyspace = 'mostajs_dev'; + // --- Abstract implementations --- + quoteIdentifier(name) { return `"${name.replace(/"/g, '""')}"`; } + getPlaceholder(_index) { return '?'; } + fieldToSqlType(field) { return CASSANDRA_TYPE_MAP[field.type] || 'text'; } + getIdColumnType() { return 'text'; } + getTableListQuery() { + return `SELECT table_name AS name FROM system_schema.tables WHERE keyspace_name = '${this.keyspace}'`; + } + async getExistingColumns(tableName) { + try { + const rows = await this.executeQuery(`SELECT column_name AS name FROM system_schema.columns WHERE keyspace_name = '${this.keyspace}' AND table_name = ? ALLOW FILTERING`, [tableName]); + return new Set(rows.map(r => r.name).filter(Boolean)); + } + catch { + return new Set(); + } + } + // --- Hooks --- + supportsIfNotExists() { return true; } + supportsReturning() { return false; } + supportsAlterTableAddForeignKey() { return false; } + supportsPartialIndex() { return false; } + serializeBoolean(v) { return v; } // boolean natif CQL + deserializeBoolean(v) { return v === true || v === 1 || v === '1'; } + serializeDate(value) { + if (value === 'now' || value === '__MOSTA_NOW__') + return new Date(); + if (value instanceof Date) + return value; + const d = new Date(value); + return isNaN(d.getTime()) ? value : d; // timestamp CQL = Date JS + } + /** CQL n'a pas d'ILIKE/regex serveur ; LIKE nécessite un index SASI. On reste sur LIKE. */ + buildRegexCondition(col, _flags) { + return `${col} LIKE ${this.nextPlaceholder()}`; + } + // CQL : LIMIT seulement (pas d'OFFSET) ; ORDER BY arbitraire non supporté. + buildLimitOffset(options) { + return options?.limit ? ` LIMIT ${options.limit}` : ''; + } + buildOrderBy() { return ''; } + generateIndexes() { return []; } + // --- DDL : CREATE TABLE (id PRIMARY KEY ; ni NOT NULL/UNIQUE/FK/DEFAULT) --- + generateCreateTable(schema) { + const q = (n) => this.quoteIdentifier(n); + const cols = [` ${q('id')} ${this.getIdColumnType()} PRIMARY KEY`]; + const fkCols = new Set(); + for (const [rn, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') + continue; + fkCols.add(rel.joinColumn || rn); + } + for (const [name, field] of Object.entries(schema.fields || {})) { + if (name === 'id' || fkCols.has(name)) + continue; + cols.push(` ${q(name)} ${this.fieldToSqlType(field)}`); + } + for (const [name, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') + continue; + cols.push(` ${q(rel.joinColumn || name)} ${this.getIdColumnType()}`); + } + if (schema.timestamps) { + cols.push(` ${q('createdAt')} timestamp`); + cols.push(` ${q('updatedAt')} timestamp`); + } + if (schema.softDelete) + cols.push(` ${q('deletedAt')} timestamp`); + return `CREATE TABLE IF NOT EXISTS ${q(this.getPrefixedName(schema.collection))} (\n${cols.join(',\n')}\n)`; + } + getDropTableSql(tableName) { + return `DROP TABLE IF EXISTS ${this.quoteIdentifier(this.getPrefixedName(tableName))}`; + } + // --- Normalisation des valeurs renvoyées (Long → number) --- + normalizeRow(row) { + const out = {}; + for (const [k, v] of Object.entries(row)) { + if (v && typeof v.toNumber === 'function' + && v.constructor?.name === 'Long') { + out[k] = v.toNumber(); + } + else + out[k] = v; + } + return out; + } + // --- Connection lifecycle --- + async doConnect(config) { + let Client; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'cassandra-driver'); + Client = mod.Client; + } + catch (e) { + throw new Error(`Cassandra driver not found. Install it: npm install cassandra-driver\n` + + `Original error: ${e instanceof Error ? e.message : String(e)}`); + } + // URI : cassandra://host:port/keyspace[?dc=datacenter1] + const u = new URL(config.uri.replace(/^cassandra:\/\//, 'http://')); + this.keyspace = u.pathname.replace(/^\//, '') || 'mostajs_dev'; + const dc = u.searchParams.get('dc') || 'datacenter1'; + this.db = new Client({ + contactPoints: [u.hostname || '127.0.0.1'], + protocolOptions: { port: u.port ? Number(u.port) : 9042 }, + localDataCenter: dc, + keyspace: this.keyspace, + }); + await this.db.connect(); + } + async doDisconnect() { + const db = this.db; + this.db = null; + if (db) + await db.shutdown(); + } + async doTestConnection() { + if (!this.db) + return false; + try { + await this.db.execute('SELECT now() FROM system.local'); + return true; + } + catch (e) { + this.log('TEST_CONNECTION', `down: ${e.message}`); + return false; + } + } + // --- Query execution --- + /** CQL n'accepte pas la tautologie `WHERE 1=1` émise pour les filtres vides. */ + stripTautology(sql) { + return sql + .replace(/\bWHERE\s+1\s*=\s*1\s+AND\s+/i, 'WHERE ') + .replace(/\bWHERE\s+1\s*=\s*1\b/i, ''); + } + /** Ajoute ALLOW FILTERING aux SELECT filtrés sur colonne non-clé. */ + withAllowFiltering(sql) { + if (/^\s*SELECT/i.test(sql) && /\sWHERE\s/i.test(sql) && !/ALLOW\s+FILTERING/i.test(sql)) { + return `${sql} ALLOW FILTERING`; + } + return sql; + } + async doExecuteQuery(sql, params) { + if (!this.db) + throw new Error('Cassandra not connected. Call connect() first.'); + const res = await this.db.execute(this.withAllowFiltering(this.stripTautology(sql)), params, { prepare: true }); + return res.rows.map(r => this.normalizeRow(r)); + } + async doExecuteRun(sql, params) { + if (!this.db) + throw new Error('Cassandra not connected. Call connect() first.'); + await this.db.execute(this.stripTautology(sql), params, { prepare: true }); + return { changes: 1 }; // CQL n'expose pas d'affected-rows + } +} +export function createDialect() { + return new CassandraDialect(); +} diff --git a/llms.txt b/llms.txt index c7cb480..2c007f1 100644 --- a/llms.txt +++ b/llms.txt @@ -1,7 +1,7 @@ # @mostajs/orm — fiche LLM -> ORM multi-dialecte inspiré d'Hibernate — une seule API, 18 bases de données, zéro lock-in. +> ORM multi-dialecte inspiré d'Hibernate — une seule API, 19 bases de données, zéro lock-in. -- Version: 2.9.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI +- Version: 2.10.0 · Licence: AGPL-3.0-or-later · Auteur: Dr Hamid MADANI - Chemin: mostajs/mosta-orm · Statut audit: complet (dist/) · Anomalies #1-#14 + #16 + #17 corrigées (cf. docs/ANOMALIES-LOT3-2026-05-25.md) — derniers : **dialecte WASM `sqljs` (SQLite, 2.4.0)** + **dialecte WASM `pglite` (PostgreSQL, idb:// persistance navigateur, 2.5.0)** — bootent navigateur/WebContainer/edge sans binaire natif ; **fix #17 : `index.fields` en tableau produisait une colonne `"0"` (silent SQLite, crash Postgres/PGlite) → normalisé (2.5.0)** ; **dialecte `duckdb` (OLAP in-process, SQL ≈ Postgres, 2.6.0)** + **dialecte `firestore` (NoSQL documentaire managé, émulateur Java ou clé GCP, façon Mongo, 2.6.0)** ; **dialecte `firebird` (OLTP relationnel FB 3.0+, validé live, 2.7.0)** ; **dialecte `clickhouse` (OLAP colonnaire MergeTree, append/analytique, validé live, 2.8.0)** ; **dialecte `redis` (NoSQL doc Redis Stack : RedisJSON + RediSearch, validé live, 2.9.0)** ## RÔLE @@ -9,7 +9,7 @@ Couche d'accès aux données unifiée pour Node.js/TypeScript. Le développeur d ses entités sous forme d'objets `EntitySchema` (fields, relations, indexes) et obtient une API CRUD/query identique quel que soit le SGBD cible : MongoDB, SQLite, PostgreSQL, MySQL, MariaDB, Oracle, MSSQL, CockroachDB, DB2, SAP HANA, HSQLDB, Spanner, Sybase, -DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo), Firebird (OLTP), ClickHouse (OLAP) et Redis (NoSQL doc, RedisJSON+RediSearch). +DuckDB (OLAP in-process), Firestore (NoSQL documentaire, façon Mongo), Firebird (OLTP), ClickHouse (OLAP), Redis (NoSQL doc, RedisJSON+RediSearch) et Cassandra (wide-column CQL, validé live). Deux dialectes WASM (zéro binaire natif) bootent dans le navigateur, les WebContainers (StackBlitz / Bolt.new) et l'edge — même API/SQL que leur moteur respectif : `sqljs` (SQLite WASM, via sql.js) et `pglite` (PostgreSQL WASM, via @electric-sql/pglite ; @@ -23,7 +23,7 @@ ConnectionConfig = persistence.xml). C'est le module fondation de l'écosystème npm i @mostajs/orm (driver natif requis selon dialecte : better-sqlite3, pg, mysql2, mariadb, oracledb, mssql, ibm_db, @sap/hana-client, @google-cloud/spanner, @google-cloud/firestore, duckdb, -node-firebird, @clickhouse/client, ioredis, mongoose — peer/optional deps) +node-firebird, @clickhouse/client, ioredis, cassandra-driver, mongoose — peer/optional deps) Pour le navigateur / WebContainer / edge (WASM pur, aucun binaire natif — à utiliser dans Bolt.new / StackBlitz / Cloudflare Workers où better-sqlite3/pg ne chargent pas) : - SQLite : `npm i sql.js` + `{ dialect: 'sqljs', uri: ':memory:' }` ; persistance fichier diff --git a/package.json b/package.json index 162b7fd..dca5d9b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mostajs/orm", - "version": "2.9.0", - "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 18 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", + "version": "2.10.0", + "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 19 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", "author": "Dr Hamid MADANI ", "license": "AGPL-3.0-or-later", "type": "module", @@ -92,6 +92,7 @@ }, "peerDependencies": { "@clickhouse/client": ">=1.0.0", + "cassandra-driver": ">=4.0.0", "ioredis": ">=5.0.0", "@electric-sql/pglite": ">=0.2.0", "@google-cloud/firestore": ">=7.0.0", @@ -114,6 +115,9 @@ "@clickhouse/client": { "optional": true }, + "cassandra-driver": { + "optional": true + }, "ioredis": { "optional": true }, diff --git a/src/core/config.ts b/src/core/config.ts index 13c4e8d..3d0be68 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -136,6 +136,10 @@ export const DIALECT_CONFIGS: Record = { installHint: 'npm install ioredis', label: 'Redis Stack (NoSQL doc — RedisJSON + RediSearch)', }, + cassandra: { + installHint: 'npm install cassandra-driver', + label: 'Cassandra (NoSQL wide-column — CQL, R&D)', + }, }; /** diff --git a/src/core/factory.ts b/src/core/factory.ts index 7183fdf..4e49cf7 100644 --- a/src/core/factory.ts +++ b/src/core/factory.ts @@ -55,6 +55,7 @@ const DIALECT_LOADERS: Record Promise<{ createDialect: () => firebird: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/firebird.dialect.js'), clickhouse: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/clickhouse.dialect.js'), redis: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/redis.dialect.js'), + cassandra: () => import(/* webpackIgnore: true */ /* @vite-ignore */ '../dialects/cassandra.dialect.js'), }; /** diff --git a/src/core/types.ts b/src/core/types.ts index 88389b2..9522044 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -287,7 +287,8 @@ export type DialectType = | 'firestore' | 'firebird' | 'clickhouse' - | 'redis'; + | 'redis' + | 'cassandra'; /** * Schema generation strategy (inspired by hibernate.hbm2ddl.auto) diff --git a/src/dialects/cassandra.dialect.ts b/src/dialects/cassandra.dialect.ts new file mode 100644 index 0000000..3519a7c --- /dev/null +++ b/src/dialects/cassandra.dialect.ts @@ -0,0 +1,206 @@ +// Cassandra Dialect — extends AbstractSqlDialect (CQL : SQL-like, placeholders `?`). +// NoSQL wide-column distribué. R&D / périmètre borné (cf. roadmap §A6, doc §3). +// Driver : npm install cassandra-driver (DataStax, officiel). +// +// ⚠ PARADIGME CQL (query-first) : +// - pas de JOIN ; requêtes pilotées par la PARTITION KEY (ici `id`) ; +// - WHERE sur colonne non-clé ⇒ `ALLOW FILTERING` (coûteux — OK petit volume) ; +// - pas de UNIQUE/FK ; pas de DEFAULT/NOT NULL ; pas d'OFFSET ni d'ORDER BY arbitraire ; +// - upsert natif (INSERT = upsert) ; pas de compteur d'affected-rows. +// +// ✅ STATUT : VALIDÉ LIVE sur amia (test-sgbd 20/20, 2026-06-12). NB : Cassandra 4.1 exige +// Java 11 (option JVM CMS retirée en Java 14+ → ne démarre pas sous Java 17). CQL n'accepte +// pas la tautologie `WHERE 1=1` des filtres vides → on la retire (stripTautology). +// +// Author: Dr Hamid MADANI + +import type { + IDialect, + DialectType, + ConnectionConfig, + EntitySchema, + FieldDef, + QueryOptions, +} from '../core/types.js'; +import { AbstractSqlDialect } from './abstract-sql.dialect.js'; + +const CASSANDRA_TYPE_MAP: Record = { + string: 'text', + text: 'text', + number: 'double', + boolean: 'boolean', + date: 'timestamp', + json: 'text', + array: 'text', +}; + +interface CassClient { + connect(): Promise; + execute(query: string, params?: unknown[], options?: Record): Promise<{ rows: Record[] }>; + shutdown(): Promise; +} + +export class CassandraDialect extends AbstractSqlDialect { + readonly dialectType: DialectType = 'cassandra'; + db: CassClient | null = null; + private keyspace = 'mostajs_dev'; + + // --- Abstract implementations --- + + quoteIdentifier(name: string): string { return `"${name.replace(/"/g, '""')}"`; } + getPlaceholder(_index: number): string { return '?'; } + fieldToSqlType(field: FieldDef): string { return CASSANDRA_TYPE_MAP[field.type] || 'text'; } + getIdColumnType(): string { return 'text'; } + + getTableListQuery(): string { + return `SELECT table_name AS name FROM system_schema.tables WHERE keyspace_name = '${this.keyspace}'`; + } + protected async getExistingColumns(tableName: string): Promise> { + try { + const rows = await this.executeQuery<{ name: string }>( + `SELECT column_name AS name FROM system_schema.columns WHERE keyspace_name = '${this.keyspace}' AND table_name = ? ALLOW FILTERING`, + [tableName], + ); + return new Set(rows.map(r => r.name).filter(Boolean)); + } catch { return new Set(); } + } + + // --- Hooks --- + + protected supportsIfNotExists(): boolean { return true; } + protected supportsReturning(): boolean { return false; } + protected supportsAlterTableAddForeignKey(): boolean { return false; } + protected supportsPartialIndex(): boolean { return false; } + protected serializeBoolean(v: boolean): unknown { return v; } // boolean natif CQL + protected deserializeBoolean(v: unknown): boolean { return v === true || v === 1 || v === '1'; } + protected serializeDate(value: unknown): unknown { + if (value === 'now' || value === '__MOSTA_NOW__') return new Date(); + if (value instanceof Date) return value; + const d = new Date(value as string); + return isNaN(d.getTime()) ? value : d; // timestamp CQL = Date JS + } + + /** CQL n'a pas d'ILIKE/regex serveur ; LIKE nécessite un index SASI. On reste sur LIKE. */ + protected buildRegexCondition(col: string, _flags?: string): string { + return `${col} LIKE ${this.nextPlaceholder()}`; + } + + // CQL : LIMIT seulement (pas d'OFFSET) ; ORDER BY arbitraire non supporté. + protected buildLimitOffset(options?: QueryOptions): string { + return options?.limit ? ` LIMIT ${options.limit}` : ''; + } + protected buildOrderBy(): string { return ''; } + protected generateIndexes(): string[] { return []; } + + // --- DDL : CREATE TABLE (id PRIMARY KEY ; ni NOT NULL/UNIQUE/FK/DEFAULT) --- + + protected generateCreateTable(schema: EntitySchema): string { + const q = (n: string) => this.quoteIdentifier(n); + const cols: string[] = [` ${q('id')} ${this.getIdColumnType()} PRIMARY KEY`]; + const fkCols = new Set(); + for (const [rn, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') continue; + fkCols.add(rel.joinColumn || rn); + } + for (const [name, field] of Object.entries(schema.fields || {})) { + if (name === 'id' || fkCols.has(name)) continue; + cols.push(` ${q(name)} ${this.fieldToSqlType(field)}`); + } + for (const [name, rel] of Object.entries(schema.relations || {})) { + if (rel.type === 'many-to-many' || rel.type === 'one-to-many') continue; + cols.push(` ${q(rel.joinColumn || name)} ${this.getIdColumnType()}`); + } + if (schema.timestamps) { + cols.push(` ${q('createdAt')} timestamp`); + cols.push(` ${q('updatedAt')} timestamp`); + } + if (schema.softDelete) cols.push(` ${q('deletedAt')} timestamp`); + return `CREATE TABLE IF NOT EXISTS ${q(this.getPrefixedName(schema.collection))} (\n${cols.join(',\n')}\n)`; + } + + protected getDropTableSql(tableName: string): string { + return `DROP TABLE IF EXISTS ${this.quoteIdentifier(this.getPrefixedName(tableName))}`; + } + + // --- Normalisation des valeurs renvoyées (Long → number) --- + + private normalizeRow(row: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + if (v && typeof (v as { toNumber?: () => number }).toNumber === 'function' + && (v as { constructor?: { name?: string } }).constructor?.name === 'Long') { + out[k] = (v as { toNumber: () => number }).toNumber(); + } else out[k] = v; + } + return out; + } + + // --- Connection lifecycle --- + + async doConnect(config: ConnectionConfig): Promise { + let Client: new (opts: Record) => CassClient; + try { + const mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ 'cassandra-driver' as string); + Client = (mod as { Client: unknown }).Client as never; + } catch (e: unknown) { + throw new Error( + `Cassandra driver not found. Install it: npm install cassandra-driver\n` + + `Original error: ${e instanceof Error ? e.message : String(e)}` + ); + } + // URI : cassandra://host:port/keyspace[?dc=datacenter1] + const u = new URL(config.uri.replace(/^cassandra:\/\//, 'http://')); + this.keyspace = u.pathname.replace(/^\//, '') || 'mostajs_dev'; + const dc = u.searchParams.get('dc') || 'datacenter1'; + this.db = new Client({ + contactPoints: [u.hostname || '127.0.0.1'], + protocolOptions: { port: u.port ? Number(u.port) : 9042 }, + localDataCenter: dc, + keyspace: this.keyspace, + }); + await this.db.connect(); + } + + async doDisconnect(): Promise { + const db = this.db; this.db = null; + if (db) await db.shutdown(); + } + + async doTestConnection(): Promise { + if (!this.db) return false; + try { await this.db.execute('SELECT now() FROM system.local'); return true; } + catch (e) { this.log('TEST_CONNECTION', `down: ${(e as Error).message}`); return false; } + } + + // --- Query execution --- + + /** CQL n'accepte pas la tautologie `WHERE 1=1` émise pour les filtres vides. */ + private stripTautology(sql: string): string { + return sql + .replace(/\bWHERE\s+1\s*=\s*1\s+AND\s+/i, 'WHERE ') + .replace(/\bWHERE\s+1\s*=\s*1\b/i, ''); + } + /** Ajoute ALLOW FILTERING aux SELECT filtrés sur colonne non-clé. */ + private withAllowFiltering(sql: string): string { + if (/^\s*SELECT/i.test(sql) && /\sWHERE\s/i.test(sql) && !/ALLOW\s+FILTERING/i.test(sql)) { + return `${sql} ALLOW FILTERING`; + } + return sql; + } + + async doExecuteQuery(sql: string, params: unknown[]): Promise { + if (!this.db) throw new Error('Cassandra not connected. Call connect() first.'); + const res = await this.db.execute(this.withAllowFiltering(this.stripTautology(sql)), params, { prepare: true }); + return res.rows.map(r => this.normalizeRow(r)) as T[]; + } + + async doExecuteRun(sql: string, params: unknown[]): Promise<{ changes: number }> { + if (!this.db) throw new Error('Cassandra not connected. Call connect() first.'); + await this.db.execute(this.stripTautology(sql), params, { prepare: true }); + return { changes: 1 }; // CQL n'expose pas d'affected-rows + } +} + +export function createDialect(): IDialect { + return new CassandraDialect(); +} From d480ac624fb6d0a04166f8733f2ee7a24986869c Mon Sep 17 00:00:00 2001 From: MADANI Date: Sat, 13 Jun 2026 00:44:25 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20count()=20insensible=20=C3=A0=20la?= =?UTF-8?q?=20casse=20de=20l'alias=20+=20HSQLDB=20valid=C3=A9=20live=20(po?= =?UTF-8?q?nt=20JDBC)=20=E2=80=94=202.10.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - count() : lit `cnt` puis `CNT`, et à défaut la première (et seule) valeur de la ligne. HSQLDB/Oracle plient l'alias non-quoté en MAJUSCULES, et le pont JDBC renvoie la clé brute sans la normaliser comme les drivers natifs. Non-régression vérifiée 20/20 sur les 14 dialectes SQL héritant d'AbstractSqlDialect : 5 locaux (sqlite, duckdb, sqljs, pglite, hsqldb) + 9 amia (postgres, cockroachdb, firebird, clickhouse, mssql, oracle, cassandra, mariadb, mysql). - HSQLDB : 1er dialecte « pont JDBC » validé LIVE 20/20 (serveur HyperSQL 2.7 Java local, sans Docker ; le jar fait serveur ET driver JDBC, chargé depuis jar_files/ par le pont). - SQL Server : connexion par URL `mssql://…` parsée en objet de config (node-mssql l'exige) + `string`→`NVARCHAR(255)` (UNIQUE interdit sur NVARCHAR(MAX)). Validé LIVE 20/20 sur mssql-server 2022 natif (apt, sans Docker). Author: Dr Hamid MADANI --- CHANGELOG.md | 29 +++++++++++ README.md | 71 +++++++++++++++++++++------ dist/dialects/abstract-sql.dialect.js | 9 +++- dist/dialects/mssql.dialect.js | 29 +++++++++-- package.json | 2 +- src/dialects/abstract-sql.dialect.ts | 10 +++- src/dialects/mssql.dialect.ts | 29 +++++++++-- 7 files changed, 153 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36b2ff4..0e8c7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to `@mostajs/orm` will be documented in this file. +## [2.10.1] — 2026-06-13 + +### Validé — HSQLDB via le pont JDBC (1er dialecte « pont » validé live) + +Le dialecte `hsqldb` (déjà livré, sans driver npm) est désormais **validé LIVE 20/20** +via le **pont JDBC transparent** : un process Java `MostaJdbcBridge` charge `hsqldb*.jar` +depuis `jar_files/` et parle HTTP à l'ORM (`AbstractSqlDialect` détecte le JAR et route). +C'est le même chemin qui activera DB2 / SAP HANA / Sybase une fois leur driver JDBC déposé. + +- Serveur HSQLDB **local** (aucun Docker) : `org.hsqldb.server.Server`, port 9001, base + `mostadev`, user `SA`/sans mot de passe. Script de lancement : + `test-scripts/hjar/run-hsqldb-server.sh`. +- URI ORM : `hsqldb:hsql://localhost:9001/mostadev`. + +### Fix — `count()` insensible à la casse de l'alias (HSQLDB/Oracle) + +`SELECT COUNT(*) as cnt` : certains moteurs **plient l'alias non-quoté en MAJUSCULES** +(HSQLDB → `CNT`), et le pont JDBC renvoie la clé brute sans la normaliser comme le font +les drivers natifs. `count()` lit désormais `cnt` puis `CNT`, et à défaut la première (et +seule) valeur de la ligne — robuste quel que soit le pliage de casse du backend. +Non-régression vérifiée 20/20 sur les 14 dialectes SQL déjà validés (locaux + amia). + +### Fix — Dialecte SQL Server : connexion par URL + `NVARCHAR(255)` + +- `doConnect` parse une URL `mssql://user:pass@host:port/db?encrypt=…` en objet de config + (node-mssql exige un objet, pas une chaîne). +- `string` → `NVARCHAR(255)` (et non `NVARCHAR(MAX)`) : SQL Server interdit un index/contrainte + `UNIQUE` sur `NVARCHAR(MAX)`. Validé LIVE 20/20 sur SQL Server 2022 natif (apt, sans Docker). + ## [2.10.0] — 2026-06-12 ### Feat — Dialecte Cassandra (19e dialecte · wide-column CQL) diff --git a/README.md b/README.md index 00c7c41..7a93e2c 100644 --- a/README.md +++ b/README.md @@ -292,25 +292,68 @@ SQLite · PostgreSQL · MySQL · MariaDB · MongoDB · Oracle · SQL Server · C ### Live validation Every newly added dialect is validated end-to-end against a **real native engine** (no Docker) -with the shared `test-sgbd` harness — **20 checks**: connection · schema (3 repos) · create -(with relations) · `findById`/`findOne`/`findAll` · `count` · filtered query · `update` · -`upsert` · `delete` · bulk create (10 rows) · filter+count consistency · update loop · relation -integrity · full cleanup. - -| Dialect | Engine (native) | Harness | Result | -|---|---|:---:|:---:| -| **Firebird** | `firebird3.0-server` | `test-sgbd` | **20/20** ✅ | -| **ClickHouse** | `clickhouse-server` | `test-sgbd` | **20/20** ✅ | -| **Redis** | `redis-stack-server` (RedisJSON+RediSearch) | `test-sgbd` | **20/20** ✅ | -| **Cassandra** | `cassandra` 4.1 (CQL) | `test-sgbd` | **20/20** ✅ | -| **Firestore** | Java emulator | `test-sgbd` + NoSQL smoke | **20/20** + **41/41** ✅ | -| **DuckDB** | in-process | `test-sgbd` | **20/20** ✅ | +with the shared `test-sgbd` harness. The same **20 checks** run on each dialect, across 8 phases. +Detail of the **20/20** run on **HSQLDB** (HyperSQL 2.7, via the JDBC bridge) — each check `1/1 ✅`: + +| # | Phase | Test | HSQLDB | +|:--:|---|---|:--:| +| 1 | Connexion | `getDialect()` — connexion + singleton | 1/1 ✅ | +| 2 | Schéma | `BaseRepository(Category)` — schéma créé | 1/1 ✅ | +| 3 | Schéma | `BaseRepository(Product)` — schéma créé | 1/1 ✅ | +| 4 | Schéma | `BaseRepository(Order)` — schéma créé | 1/1 ✅ | +| 5 | Create | Category — create ×2 | 1/1 ✅ | +| 6 | Create | Product — create ×3 avec relation `category` | 1/1 ✅ | +| 7 | Create | Order — create ×2 avec relation `product` | 1/1 ✅ | +| 8 | Read | `findById()` | 1/1 ✅ | +| 9 | Read | `findOne({ slug })` | 1/1 ✅ | +| 10 | Read | `findAll()` | 1/1 ✅ | +| 11 | Read | `count()` | 1/1 ✅ | +| 12 | Read | `findAll({ status })` — filtré | 1/1 ✅ | +| 13 | Update | `update()` — modifier prix/stock/statut | 1/1 ✅ | +| 14 | Update | `upsert()` — créer si inexistant puis MAJ | 1/1 ✅ | +| 15 | Delete | `delete()` — supprimer un product | 1/1 ✅ | +| 16 | Avancé | create en masse (10 products) | 1/1 ✅ | +| 17 | Avancé | `findAll` filtré + `count` cohérent | 1/1 ✅ | +| 18 | Avancé | update en boucle + vérification | 1/1 ✅ | +| 19 | Avancé | Order avec relation product valide | 1/1 ✅ | +| 20 | Nettoyage | suppression de toutes les données de test | 1/1 ✅ | +| | | **Total** | **20/20 ✅** | + +**17 dialects validated 20/20** against real engines (native servers on a Linux box, or local): + +| Dialect | Engine | Result | +|---|---|:---:| +| **PostgreSQL** | `postgresql` (native) | **20/20** ✅ | +| **SQL Server** | `mssql-server` 2022 (native apt, no Docker) | **20/20** ✅ | +| **MySQL** | `mysqld` (native) | **20/20** ✅ | +| **MariaDB** | `mysqld` (native) | **20/20** ✅ | +| **Oracle XE** | `oracle-xe-21c` (native) | **20/20** ✅ | +| **CockroachDB** | `cockroachdb` (native) | **20/20** ✅ | +| **MongoDB** | `mongod` (native) | **20/20** ✅ | +| **Firebird** | `firebird3.0-server` (native) | **20/20** ✅ | +| **ClickHouse** | `clickhouse-server` (native) | **20/20** ✅ | +| **Redis** | `redis-stack-server` (RedisJSON+RediSearch) | **20/20** ✅ | +| **Cassandra** | `cassandra` 4.1 (CQL, native) | **20/20** ✅ | +| **Firestore** | Java emulator | **20/20** + **41/41** ✅ | +| **HSQLDB** | HyperSQL 2.7 (Java, JDBC bridge) | **20/20** ✅ | +| **DuckDB** | in-process | **20/20** ✅ | +| **SQLite** | `better-sqlite3` | **20/20** ✅ | +| **sqljs** | SQLite WASM (`sql.js`) | **20/20** ✅ | +| **pglite** | PostgreSQL WASM (`@electric-sql/pglite`) | **20/20** ✅ | + +> **HSQLDB** runs through the transparent JDBC bridge (a Java `MostaJdbcBridge` process that +> loads `hsqldb*.jar` from `jar_files/` and speaks HTTP to the ORM) — same path that powers +> DB2 / SAP HANA / Sybase once their JDBC drivers are dropped in. +> +> **Pending** (code shipped, live validation requires extra infra): DB2 · SAP HANA · Sybase +> (JDBC bridge — driver JAR needed), Spanner (16 GB RAM emulator). > Engine-specific quirks surfaced & fixed during live validation — Firebird: `boolean→SMALLINT`, > `text/json→VARCHAR` (driver BLOB read hangs), Srp+wireCrypt auth; ClickHouse: `MergeTree` > engine, typed params, `mutations_sync` for synchronous update/delete; Redis: `FT.SEARCH` > filter translation, TAG escaping; Cassandra: `ALLOW FILTERING`, `WHERE 1=1` stripping, Java 11. -> An HTML report per run is produced by `test-scripts/sgbd-html-report.mjs`. +> Each run produces an HTML report (`test-scripts/sgbd-html-report.mjs`). Validation is done +> **one engine at a time** (stop current → start target) to fit a memory-constrained host. **+ WASM runtimes** — two zero-binary dialects run in WebAssembly, so the same ORM **boots in the browser / Bolt.new / Cloudflare Workers with no native binary**: diff --git a/dist/dialects/abstract-sql.dialect.js b/dist/dialects/abstract-sql.dialect.js index b532fc2..29fd553 100644 --- a/dist/dialects/abstract-sql.dialect.js +++ b/dist/dialects/abstract-sql.dialect.js @@ -1699,7 +1699,14 @@ export class AbstractSqlDialect { const sql = `SELECT COUNT(*) as cnt FROM ${table} WHERE ${where.sql}`; this.log('COUNT', schema.collection, { sql, params: where.params }); const rows = await this.executeQuery(sql, where.params); - return rows.length > 0 ? Number(rows[0].cnt) : 0; + if (rows.length === 0) + return 0; + // Certains backends plient la casse de l'alias non-quoté (HSQLDB/Oracle → CNT) + // ou le renvoient via le pont JDBC sans normalisation. La requête ne sélectionne + // qu'UNE colonne : on lit cnt/CNT puis, à défaut, la première (et seule) valeur. + const row = rows[0]; + const raw = row.cnt ?? row.CNT ?? Object.values(row)[0]; + return Number(raw); } async distinct(schema, field, filter, options) { this.resetParams(); diff --git a/dist/dialects/mssql.dialect.js b/dist/dialects/mssql.dialect.js index 8f86fb8..d85cb94 100644 --- a/dist/dialects/mssql.dialect.js +++ b/dist/dialects/mssql.dialect.js @@ -7,7 +7,9 @@ import { AbstractSqlDialect } from './abstract-sql.dialect.js'; // Type Mapping — DAL FieldType → SQL Server column type // ============================================================ const MSSQL_TYPE_MAP = { - string: 'NVARCHAR(MAX)', + // string borné (NVARCHAR(255)) et NON MAX : SQL Server interdit un index/contrainte + // UNIQUE sur une colonne NVARCHAR(MAX) (cf. champs unique `name`/`slug`). + string: 'NVARCHAR(255)', text: 'NVARCHAR(MAX)', number: 'FLOAT', boolean: 'BIT', @@ -99,15 +101,34 @@ export class MSSQLDialect extends AbstractSqlDialect { } // --- Connection --- async doConnect(config) { + let mssql; try { - const mssql = await import(/* webpackIgnore: true */ 'mssql'); - const connect = mssql.default?.connect || mssql.connect; - this.pool = await connect(config.uri); + mssql = await import(/* webpackIgnore: true */ 'mssql'); } catch (e) { throw new Error(`SQL Server driver not found. Install it: npm install mssql\n` + `Original error: ${e instanceof Error ? e.message : String(e)}`); } + const connect = (mssql.default?.connect || mssql.connect); + // node-mssql attend un OBJET de config. Une URL `mssql://user:pass@host:port/db?opts` + // est parsée ici ; toute autre forme (chaîne ADO `Server=...`) est passée telle quelle. + if (/^mssql:\/\//i.test(config.uri)) { + const u = new URL(config.uri.replace(/^mssql:\/\//i, 'http://')); + this.pool = await connect({ + server: u.hostname || 'localhost', + port: u.port ? Number(u.port) : 1433, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: u.pathname.replace(/^\//, ''), + options: { + encrypt: u.searchParams.get('encrypt') !== 'false', + trustServerCertificate: u.searchParams.get('trustServerCertificate') === 'true', + }, + }); + } + else { + this.pool = await connect(config.uri); + } } async doDisconnect() { if (this.pool) { diff --git a/package.json b/package.json index dca5d9b..5151ab3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mostajs/orm", - "version": "2.10.0", + "version": "2.10.1", "description": "Hibernate-inspired multi-dialect ORM for Node.js/TypeScript — One API, 19 databases, zero lock-in, runs in the browser/WebContainer via WASM (SQLite & Postgres)", "author": "Dr Hamid MADANI ", "license": "AGPL-3.0-or-later", diff --git a/src/dialects/abstract-sql.dialect.ts b/src/dialects/abstract-sql.dialect.ts index 49f61bf..7c35dcf 100644 --- a/src/dialects/abstract-sql.dialect.ts +++ b/src/dialects/abstract-sql.dialect.ts @@ -1846,8 +1846,14 @@ export abstract class AbstractSqlDialect implements IDialect { const sql = `SELECT COUNT(*) as cnt FROM ${table} WHERE ${where.sql}`; this.log('COUNT', schema.collection, { sql, params: where.params }); - const rows = await this.executeQuery<{ cnt: number }>(sql, where.params); - return rows.length > 0 ? Number(rows[0].cnt) : 0; + const rows = await this.executeQuery>(sql, where.params); + if (rows.length === 0) return 0; + // Certains backends plient la casse de l'alias non-quoté (HSQLDB/Oracle → CNT) + // ou le renvoient via le pont JDBC sans normalisation. La requête ne sélectionne + // qu'UNE colonne : on lit cnt/CNT puis, à défaut, la première (et seule) valeur. + const row = rows[0]; + const raw = row.cnt ?? row.CNT ?? Object.values(row)[0]; + return Number(raw); } async distinct(schema: EntitySchema, field: string, filter: DALFilter, options?: QueryOptions): Promise { diff --git a/src/dialects/mssql.dialect.ts b/src/dialects/mssql.dialect.ts index b265e75..f187a6e 100644 --- a/src/dialects/mssql.dialect.ts +++ b/src/dialects/mssql.dialect.ts @@ -17,7 +17,9 @@ import { AbstractSqlDialect } from './abstract-sql.dialect.js'; // ============================================================ const MSSQL_TYPE_MAP: Record = { - string: 'NVARCHAR(MAX)', + // string borné (NVARCHAR(255)) et NON MAX : SQL Server interdit un index/contrainte + // UNIQUE sur une colonne NVARCHAR(MAX) (cf. champs unique `name`/`slug`). + string: 'NVARCHAR(255)', text: 'NVARCHAR(MAX)', number: 'FLOAT', boolean: 'BIT', @@ -128,16 +130,35 @@ export class MSSQLDialect extends AbstractSqlDialect { // --- Connection --- async doConnect(config: ConnectionConfig): Promise { + let mssql: { default?: { connect?: unknown }; connect?: unknown }; try { - const mssql = await import(/* webpackIgnore: true */ 'mssql' as string); - const connect = mssql.default?.connect || mssql.connect; - this.pool = await connect(config.uri); + mssql = await import(/* webpackIgnore: true */ 'mssql' as string) as never; } catch (e: unknown) { throw new Error( `SQL Server driver not found. Install it: npm install mssql\n` + `Original error: ${e instanceof Error ? e.message : String(e)}` ); } + const connect = ((mssql.default as { connect?: unknown })?.connect || mssql.connect) as + (cfg: unknown) => Promise; + // node-mssql attend un OBJET de config. Une URL `mssql://user:pass@host:port/db?opts` + // est parsée ici ; toute autre forme (chaîne ADO `Server=...`) est passée telle quelle. + if (/^mssql:\/\//i.test(config.uri)) { + const u = new URL(config.uri.replace(/^mssql:\/\//i, 'http://')); + this.pool = await connect({ + server: u.hostname || 'localhost', + port: u.port ? Number(u.port) : 1433, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: u.pathname.replace(/^\//, ''), + options: { + encrypt: u.searchParams.get('encrypt') !== 'false', + trustServerCertificate: u.searchParams.get('trustServerCertificate') === 'true', + }, + }); + } else { + this.pool = await connect(config.uri); + } } async doDisconnect(): Promise { From 383b98a00304585ec5448bb00999affd706969a0 Mon Sep 17 00:00:00 2001 From: MADANI Date: Sat, 13 Jun 2026 02:39:39 +0100 Subject: [PATCH 6/6] =?UTF-8?q?test:=20suite=20de=20tests=20automatis?= =?UTF-8?q?=C3=A9s=20in-process=20+=20CI=20(vitest)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Premier filet de non-régression rejouable en CI, SANS infrastructure : tout tourne en mémoire sur les dialectes in-process (sqlite, sql.js, pglite, duckdb). - vitest 4 + config (tests/**/*.test.ts, pool forks, timeouts). - Helpers : createIsolatedDialect (hors singleton/env global) + BaseRepository, paramétrés sur les 4 dialectes in-process. - tests/crud.test.ts : scénario complet ×4 dialectes (60 tests) — schéma, create, relations many-to-one, findById/findOne/findAll, count + count(filter) [couvre la régression de casse d'alias cnt/CNT], update, upsert (idempotent), pagination, delete/deleteMany. - tests/transactions.test.ts : commit (persiste) & rollback (annule) via $transaction(cb) ×4 dialectes (8 tests). - Scripts npm : test (vitest run), test:watch, typecheck (tsc --noEmit). - CI GitHub Actions (.github/workflows/test.yml) : Node 20/22, typecheck + build + test (npm install — package-lock.json non versionné). Total : 68 tests, ~6 s, zéro dépendance serveur. Author: Dr Hamid MADANI --- .github/workflows/test.yml | 37 +++++++++++ package.json | 10 ++- tests/crud.test.ts | 124 +++++++++++++++++++++++++++++++++++++ tests/fixtures/schemas.ts | 56 +++++++++++++++++ tests/helpers.ts | 42 +++++++++++++ tests/transactions.test.ts | 42 +++++++++++++ vitest.config.ts | 17 +++++ 7 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/crud.test.ts create mode 100644 tests/fixtures/schemas.ts create mode 100644 tests/helpers.ts create mode 100644 tests/transactions.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d612217 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +# CI — typecheck + suite de tests in-process (@mostajs/orm). +# Aucune infrastructure : tout tourne en mémoire (sqlite/sql.js/pglite/duckdb). +# Author: Dr Hamid MADANI +name: test + +on: + push: + branches: [main, 'feat/**', 'test/**'] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x, 22.x] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + # package-lock.json n'est pas versionné (choix du projet) → npm install (pas npm ci). + - name: Install dependencies + run: npm install + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Test (in-process dialects) + run: npm test diff --git a/package.json b/package.json index 5151ab3..a91ebe0 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,9 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "prepublishOnly": "npm run build" }, "dependencies": { @@ -92,16 +95,16 @@ }, "peerDependencies": { "@clickhouse/client": ">=1.0.0", - "cassandra-driver": ">=4.0.0", - "ioredis": ">=5.0.0", "@electric-sql/pglite": ">=0.2.0", "@google-cloud/firestore": ">=7.0.0", "@google-cloud/spanner": ">=7.0.0", "@mostajs/socle": ">=2.0.0", "@sap/hana-client": ">=2.0.0", "better-sqlite3": ">=9.0.0", + "cassandra-driver": ">=4.0.0", "duckdb": ">=1.0.0", "ibm_db": ">=3.0.0", + "ioredis": ">=5.0.0", "mariadb": ">=3.0.0", "mongoose": ">=7.0.0", "mssql": ">=10.0.0", @@ -176,7 +179,8 @@ "mongoose": "^8.0.0", "pg": "^8.21.0", "sql.js": "^1.14.1", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.8" }, "funding": { "type": "github", diff --git a/tests/crud.test.ts b/tests/crud.test.ts new file mode 100644 index 0000000..c6c64da --- /dev/null +++ b/tests/crud.test.ts @@ -0,0 +1,124 @@ +// Suite CRUD paramétrée — rejoue le scénario de validation (création, lecture, count, +// filtres, update, upsert, delete, relations, pagination) sur CHAQUE dialecte in-process. +// Aucune infra : sqlite / sql.js / pglite / duckdb, tout en mémoire. +// Author: Dr Hamid MADANI +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { IN_PROCESS_DIALECTS, setupRepos, type TestRepos } from './helpers.js'; + +describe.each(IN_PROCESS_DIALECTS)('CRUD — $label', (cfg) => { + let repos: TestRepos; + const id: Record = {}; + + beforeAll(async () => { + repos = await setupRepos(cfg); + }); + + afterAll(async () => { + await repos?.dialect.disconnect(); + }); + + it('schéma vide : count() = 0', async () => { + expect(await repos.cat.count()).toBe(0); + expect(await repos.prod.count()).toBe(0); + expect(await repos.order.count()).toBe(0); + }); + + it('create — catégories ×2', async () => { + const c1 = await repos.cat.create({ name: 'Electronique', description: 'Appareils', order: 1 }); + const c2 = await repos.cat.create({ name: 'Vetements', description: 'Mode', order: 2 }); + expect(c1.id).toBeTruthy(); + expect(c2.id).toBeTruthy(); + id.cat1 = c1.id as string; + id.cat2 = c2.id as string; + }); + + it('create — produits ×3 avec relation many-to-one', async () => { + const p1 = await repos.prod.create({ name: 'Laptop Pro', slug: 'laptop-pro', price: 120000, stock: 15, status: 'active', category: id.cat1, tags: ['laptop', 'pro'], metadata: { brand: 'MostaTech', year: 2025 } }); + const p2 = await repos.prod.create({ name: 'T-Shirt', slug: 'tshirt', price: 2500, stock: 100, status: 'active', category: id.cat2, tags: ['coton'] }); + const p3 = await repos.prod.create({ name: 'Ecouteurs', slug: 'ecouteurs', price: 5000, stock: 0, status: 'draft', category: id.cat1 }); + expect([p1.id, p2.id, p3.id].every(Boolean)).toBe(true); + id.prod1 = p1.id as string; + id.prod3 = p3.id as string; + }); + + it('create — commandes ×2 avec relation required', async () => { + const o1 = await repos.order.create({ orderNumber: 'CMD-001', total: 120000, status: 'paid', product: id.prod1 }); + const o2 = await repos.order.create({ orderNumber: 'CMD-002', total: 5000, status: 'pending', product: id.prod1 }); + expect([o1.id, o2.id].every(Boolean)).toBe(true); + }); + + it('findById — retrouve par id', async () => { + const cat = await repos.cat.findById(id.cat1); + expect(cat).not.toBeNull(); + expect((cat as Record).name).toBe('Electronique'); + }); + + it('findOne — filtre sur champ unique', async () => { + const prod = await repos.prod.findOne({ slug: 'laptop-pro' }); + expect(prod).not.toBeNull(); + expect((prod as Record).price).toBe(120000); + }); + + it('findAll — récupère tout', async () => { + expect((await repos.prod.findAll()).length).toBe(3); + }); + + it('count() — total exact (régression casse alias cnt/CNT)', async () => { + expect(await repos.cat.count()).toBe(2); + expect(await repos.prod.count()).toBe(3); + expect(await repos.order.count()).toBe(2); + }); + + it('count(filter) — comptage filtré', async () => { + expect(await repos.prod.count({ status: 'active' })).toBe(2); + expect(await repos.prod.count({ status: 'draft' })).toBe(1); + }); + + it('findAll(filter) — lecture filtrée', async () => { + const active = await repos.prod.findAll({ status: 'active' }); + expect(active.length).toBe(2); + }); + + it('update — modifie prix/stock/statut', async () => { + await repos.prod.update(id.prod3, { price: 4500, stock: 25, status: 'active' }); + const u = await repos.prod.findById(id.prod3) as Record; + expect(u.price).toBe(4500); + expect(u.stock).toBe(25); + expect(u.status).toBe('active'); + }); + + it('upsert — crée si absent puis met à jour', async () => { + const created = await repos.cat.upsert({ name: 'Sport' }, { name: 'Sport', description: 'Articles', order: 3 }); + expect(created.id).toBeTruthy(); + expect(await repos.cat.count()).toBe(3); + const updated = await repos.cat.upsert({ name: 'Sport' }, { name: 'Sport', description: 'Sport & fitness', order: 3 }); + expect(updated.id).toBe(created.id); + expect((await repos.cat.findById(created.id as string) as Record).description).toBe('Sport & fitness'); + expect(await repos.cat.count()).toBe(3); // pas de doublon + }); + + it('pagination — limit/skip/sort cohérents', async () => { + const page1 = await repos.prod.findAll({}, { sort: { price: 'asc' }, limit: 2, skip: 0 }); + const page2 = await repos.prod.findAll({}, { sort: { price: 'asc' }, limit: 2, skip: 2 }); + expect(page1.length).toBe(2); + expect(page2.length).toBe(1); + const prices = page1.map((p) => (p as Record).price); + expect(prices[0]).toBeLessThanOrEqual(prices[1]); + }); + + it('delete — supprime une ligne', async () => { + const ok = await repos.prod.delete(id.prod3); + expect(ok).toBe(true); + expect(await repos.prod.findById(id.prod3)).toBeNull(); + expect(await repos.prod.count()).toBe(2); + }); + + it('deleteMany — vide les collections', async () => { + await repos.order.deleteMany({}); + await repos.prod.deleteMany({}); + await repos.cat.deleteMany({}); + expect(await repos.order.count()).toBe(0); + expect(await repos.prod.count()).toBe(0); + expect(await repos.cat.count()).toBe(0); + }); +}); diff --git a/tests/fixtures/schemas.ts b/tests/fixtures/schemas.ts new file mode 100644 index 0000000..b41e00e --- /dev/null +++ b/tests/fixtures/schemas.ts @@ -0,0 +1,56 @@ +// Schémas d'entités de test — autonomes, zéro dépendance externe. +// Repris du harnais de validation live (test-sgbd) pour cohérence des scénarios. +// Author: Dr Hamid MADANI +import type { EntitySchema } from '../../src/index.js'; + +export const CategorySchema = { + name: 'Category', + collection: 'test_categories', + timestamps: true, + fields: { + name: { type: 'string', required: true, unique: true }, + description: { type: 'string', default: '' }, + order: { type: 'number', default: 0 }, + active: { type: 'boolean', default: true }, + }, + relations: {}, + indexes: [{ fields: { order: 'asc' } }], +} satisfies EntitySchema; + +export const ProductSchema = { + name: 'Product', + collection: 'test_products', + timestamps: true, + fields: { + name: { type: 'string', required: true }, + slug: { type: 'string', required: true, unique: true }, + price: { type: 'number', required: true }, + stock: { type: 'number', default: 0 }, + status: { type: 'string', enum: ['active', 'archived', 'draft'], default: 'draft' }, + tags: { type: 'array', arrayOf: 'string' }, + metadata: { type: 'json' }, + }, + relations: { + category: { target: 'Category', type: 'many-to-one' }, + }, + indexes: [{ fields: { status: 'asc', price: 'desc' } }], +} satisfies EntitySchema; + +export const OrderSchema = { + name: 'Order', + collection: 'test_orders', + timestamps: true, + fields: { + orderNumber: { type: 'string', required: true, unique: true }, + total: { type: 'number', required: true }, + status: { type: 'string', enum: ['pending', 'paid', 'shipped', 'cancelled'], default: 'pending' }, + notes: { type: 'string' }, + orderDate: { type: 'date', default: 'now' }, + }, + relations: { + product: { target: 'Product', type: 'many-to-one', required: true }, + }, + indexes: [{ fields: { status: 'asc' } }], +} satisfies EntitySchema; + +export const ALL_SCHEMAS = [CategorySchema, ProductSchema, OrderSchema]; diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..bea34cb --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,42 @@ +// Helpers de test — instancie un dialecte IN-PROCESS isolé (hors singleton/env global) +// et les repositories associés, à partir des schémas de fixtures. +// Author: Dr Hamid MADANI +import { createIsolatedDialect, BaseRepository } from '../src/index.js'; +import type { IDialect } from '../src/index.js'; +import { CategorySchema, ProductSchema, OrderSchema, ALL_SCHEMAS } from './fixtures/schemas.js'; + +export interface InProcessDialect { + dialect: string; + uri: string; + label: string; +} + +/** Dialectes testables sans aucune infra (mémoire / WASM / in-process). */ +export const IN_PROCESS_DIALECTS: InProcessDialect[] = [ + { dialect: 'sqlite', uri: ':memory:', label: 'SQLite (better-sqlite3)' }, + { dialect: 'sqljs', uri: ':memory:', label: 'sql.js (WASM)' }, + { dialect: 'pglite', uri: ':memory:', label: 'pglite (WASM)' }, + { dialect: 'duckdb', uri: ':memory:', label: 'DuckDB (in-process)' }, +]; + +export interface TestRepos { + dialect: IDialect; + cat: BaseRepository>; + prod: BaseRepository>; + order: BaseRepository>; +} + +/** Crée un dialecte isolé + les 3 repositories de test, schéma déjà initialisé. */ +export async function setupRepos(cfg: InProcessDialect): Promise { + const dialect = await createIsolatedDialect( + // schemaStrategy: 'create' → initSchema émet le DDL (CREATE TABLE/INDEX). + { dialect: cfg.dialect as never, uri: cfg.uri, schemaStrategy: 'create' }, + ALL_SCHEMAS, + ); + return { + dialect, + cat: new BaseRepository(CategorySchema, dialect), + prod: new BaseRepository(ProductSchema, dialect), + order: new BaseRepository(OrderSchema, dialect), + }; +} diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts new file mode 100644 index 0000000..66faf49 --- /dev/null +++ b/tests/transactions.test.ts @@ -0,0 +1,42 @@ +// Transactions ACID — commit (persiste) et rollback (annule) via $transaction(cb), +// sur chaque dialecte in-process supportant les transactions. +// Author: Dr Hamid MADANI +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { BaseRepository } from '../src/index.js'; +import { IN_PROCESS_DIALECTS, setupRepos, type TestRepos } from './helpers.js'; +import { CategorySchema } from './fixtures/schemas.js'; + +describe.each(IN_PROCESS_DIALECTS)('Transactions — $label', (cfg) => { + let repos: TestRepos; + + beforeAll(async () => { + repos = await setupRepos(cfg); + }); + + afterAll(async () => { + await repos?.dialect.disconnect(); + }); + + it('commit — les écritures du callback persistent', async () => { + const before = await repos.cat.count(); + await repos.dialect.$transaction(async (tx) => { + const catTx = new BaseRepository(CategorySchema, tx); + await catTx.create({ name: 'Tx-Commit', order: 10 }); + }); + expect(await repos.cat.count()).toBe(before + 1); + expect(await repos.cat.findOne({ name: 'Tx-Commit' })).not.toBeNull(); + }); + + it('rollback — une exception annule toutes les écritures', async () => { + const before = await repos.cat.count(); + await expect( + repos.dialect.$transaction(async (tx) => { + const catTx = new BaseRepository(CategorySchema, tx); + await catTx.create({ name: 'Tx-Rollback', order: 11 }); + throw new Error('boom — déclenche le rollback'); + }), + ).rejects.toThrow('boom'); + expect(await repos.cat.count()).toBe(before); + expect(await repos.cat.findOne({ name: 'Tx-Rollback' })).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..381bdfb --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +// Configuration Vitest — suite de tests automatisés @mostajs/orm +// Tests rejouables en CI sur les dialectes IN-PROCESS (sqlite/sqljs/pglite/duckdb), +// sans aucune infrastructure (ni Docker, ni serveur distant). +// Author: Dr Hamid MADANI +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + testTimeout: 20_000, + hookTimeout: 20_000, + // Les dialectes in-process gardent un état fichier/mémoire : on isole par fichier. + pool: 'forks', + reporters: 'default', + }, +});