@avytrix/nestjs-drizzle-crud 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT.md CHANGED
@@ -60,11 +60,16 @@ pnpm add @avytrix/nestjs-drizzle-crud
60
60
  pnpm add @nestjs/common @nestjs/core drizzle-orm reflect-metadata # peers, always
61
61
  pnpm add postgres # PostgreSQL
62
62
  pnpm add mysql2 # MySQL
63
+ pnpm add @libsql/client # SQLite / libSQL
63
64
  ```
64
65
 
65
- `postgres` is an optional peer. You only need it when you let the module build
66
- the connection from a `connectionString` on PostgreSQL. If you pass your own
67
- `db`, no driver peer is required.
66
+ `postgres` and `mysql2` are optional peers. You only need them when you let the
67
+ module build the connection from a `connectionString` (PostgreSQL or MySQL;
68
+ `connection: { poolSize, idleTimeout, ssl }` tunes it). If you pass your own
69
+ `db`, no driver peer is required. SQLite (`dialect: 'sqlite'`, also libSQL and
70
+ Cloudflare D1) always takes your own `db`. On D1 and synchronous SQLite drivers
71
+ the mass writes, nested creates and attach / detach / sync throw a
72
+ `TransactionException`; use libSQL when you need them.
68
73
 
69
74
  Requires Node >= 20 and `emitDecoratorMetadata` / `experimentalDecorators` in
70
75
  `tsconfig.json`, which the Nest CLI already sets.
@@ -87,7 +92,8 @@ DrizzleCrudModule.forRootAsync({
87
92
  ```
88
93
 
89
94
  Use `forRoot` for a static config and `forRootAsync` when the value comes from
90
- `ConfigService` or the environment. Provide exactly one of `connectionString`
95
+ `ConfigService` or the environment (`useFactory`, or `useClass` / `useExisting`
96
+ with a class that implements `DrizzleCrudOptionsFactory`). Provide exactly one of `connectionString`
91
97
  or `db`. The module is global, so feature modules do not import it again for
92
98
  the connection.
93
99
 
@@ -138,7 +144,7 @@ Those are legacy manual patterns and they break registration.
138
144
  | OpenAPI | `@ApiCrudList(source)`, `@ApiCrudQuery(source)` (needs `@nestjs/swagger`) |
139
145
  | Bulk | `massCreate`, `massUpdate`, `massUpsert`, `massSoftDelete`, `massRestore`, `massDelete`, `massForceDelete` |
140
146
  | Query string | `parseQuery(query, options)`, or the standalone `parseCrudQuery(query, table, options)` |
141
- | Search | `fullTextSearch(term, columns, pagination)`, PostgreSQL only |
147
+ | Search | `fullTextSearch(term, columns, pagination, { filters, relations, select, language, queryMode })`, PostgreSQL only |
142
148
  | Transaction | `executeSqlTransaction(cb)` |
143
149
 
144
150
  `findAll` returns `{ data, total, page, limit }`, not a bare array; with
@@ -427,7 +433,8 @@ Driver error codes are translated at the service boundary, so bad client input
427
433
  never reaches the caller as a 500: a unique violation is a 409, a value that is
428
434
  too long, fails a check, has the wrong type or is not a valid date is a 400 (on
429
435
  reads too), a foreign key pointing at a row that does not exist is a 400, and a
430
- delete blocked by dependent rows is a 409.
436
+ delete blocked by dependent rows is a 409. The same holds on MySQL (strict mode
437
+ errors included) and SQLite (constraint errors).
431
438
 
432
439
  ---
433
440
 
@@ -452,9 +459,10 @@ To insert or update by a unique key, use
452
459
  than a find followed by a create.
453
460
 
454
461
  Take a row lock with `options.lock: 'update' | 'share'` only inside a
455
- transaction, and prefer `forNoKeyUpdate` when the key is not changing. The lock
456
- covers the service's own table only (`FOR UPDATE OF <table>`), so it works with
457
- an eager belongs-to.
462
+ transaction, and prefer `forNoKeyUpdate` when the key is not changing. On
463
+ PostgreSQL the lock covers the service's own table only (`FOR UPDATE OF
464
+ <table>`), so it works with an eager belongs-to. MySQL gets `FOR UPDATE` or
465
+ `FOR SHARE`; on SQLite a lock is a 400.
458
466
 
459
467
  ---
460
468
 
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to `@avytrix/nestjs-drizzle-crud` (published as `nestjs-drizzle-crud` up to 3.5.0) are documented here.
3
+ All notable changes to `@avytrix/nestjs-drizzle-crud` (published as `nestjs-drizzle-crud` up to 3.3.0) are documented here.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -8,6 +8,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  Scope note: the 3.0.x line is PostgreSQL-first. MySQL-specific paths (non-`RETURNING`
9
9
  restore, `ER_DUP_ENTRY` mapping, dialect locking) are tracked separately and unchanged here.
10
10
 
11
+ 3.3.1, 3.4.0, 3.5.0 and 3.5.2 were tagged but never published to npm. Their changes reached npm
12
+ in 3.5.1 (3.3.1 to 3.5.0) and 3.6.0 (3.5.2). The unscoped `nestjs-drizzle-crud` stops at 3.3.0.
13
+
14
+ ## [3.7.0] - 2026-11-02
15
+
16
+ SQLite support, MySQL connections built for you, and full-text search that works like `findAll`.
17
+
18
+ ### Added
19
+
20
+ - `dialect: "sqlite"` for SQLite, libSQL (Turso) and Cloudflare D1. Pass the Drizzle instance as
21
+ `db`. Writes use `RETURNING`, `upsert` uses `ON CONFLICT DO UPDATE` with the tenant guard, and
22
+ keyset pages follow SQLite's NULL order. `LIKE` gets `ESCAPE '\'`, so `%`, `_` and `\` in a
23
+ search term match literally. Constraint errors map like the other dialects: `UNIQUE` 409,
24
+ `FOREIGN KEY` 400 on a write and 409 on a delete, `NOT NULL` and `CHECK` 400. Full-text search,
25
+ row locks and the array and JSON operators are a 400.
26
+ - On Cloudflare D1 and synchronous SQLite drivers (better-sqlite3, bun:sqlite, sql.js), the calls
27
+ that need an interactive transaction (mass writes, nested creates, `attach`, `detach`, `sync`)
28
+ throw a `TransactionException` that says why, before any statement runs. `TransactionException`
29
+ is no longer deprecated.
30
+ - `connectionString` works with `dialect: "mysql"`: the module builds a `mysql2` pool, wraps it
31
+ with `drizzle-orm/mysql2` and ends it on shutdown. `mysql2` is a new optional peer, loaded only
32
+ on that path.
33
+ - `connection: { poolSize, idleTimeout, ssl }` for the connection the module builds from
34
+ `connectionString`, on PostgreSQL (postgres-js) and MySQL (mysql2).
35
+ - `forRootAsync` takes `useClass` and `useExisting`, with a class that implements
36
+ `DrizzleCrudOptionsFactory` (`createDrizzleCrudOptions()`), next to `useFactory`.
37
+ - `fullTextSearch()` takes `options.filters`, and honours `options.relations`, `options.select`
38
+ and `options.counts`, the same as `findAll`. Before, they were ignored.
39
+ - `language` (the text search configuration, default `"english"`) and `queryMode` (`"plain"`, the
40
+ default, or `"websearch"` for `websearch_to_tsquery`) on `fullTextSearch()` options and on
41
+ `search` with `mode: "fullText"`. A language that is not a plain identifier is a 400.
42
+ - New types: `DrizzleConnectionOptions`, `DrizzleCrudAsyncOptions`, `DrizzleCrudOptionsFactory`,
43
+ `FullTextOptions`, `FullTextQueryMode` and `FullTextSearchOptions`.
44
+
45
+ ### Fixed
46
+
47
+ - MySQL: `lock` and `forNoKeyUpdate` add `FOR UPDATE` or `FOR SHARE` (`forNoKeyUpdate` becomes
48
+ `FOR UPDATE`). They used to be accepted and silently ignored.
49
+ - MySQL strict mode data errors (a missing required value, a value too long, out of range, of
50
+ the wrong type, a bad date, a failed check) are a 400 that names the column, instead of a 500.
51
+
52
+ ### Changed
53
+
54
+ - The default `sql.useReturning` is `true` for every dialect except MySQL, so SQLite gets it.
55
+ PostgreSQL and MySQL keep the values they had.
56
+
11
57
  ## [3.6.0] - 2026-11-02
12
58
 
13
59
  Fixes from the second audit. The first one is a security fix: upgrade if you use `scope`.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![license](https://img.shields.io/npm/l/@avytrix/nestjs-drizzle-crud)](./LICENSE)
6
6
  [![Sponsor @avytrix](https://img.shields.io/badge/Sponsor-%40avytrix-db61a2?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/avytrix)
7
7
 
8
- **nestjs-drizzle-crud is a NestJS + Drizzle ORM CRUD library: extend one base class and every table gets type-safe find, create, update, delete, filtering, pagination, relations and soft delete, on PostgreSQL or MySQL.**
8
+ **nestjs-drizzle-crud is a NestJS + Drizzle ORM CRUD library: extend one base class and every table gets type-safe find, create, update, delete, filtering, pagination, relations and soft delete, on PostgreSQL, MySQL or SQLite.**
9
9
 
10
10
  ```bash
11
11
  pnpm add @avytrix/nestjs-drizzle-crud drizzle-orm
@@ -94,7 +94,7 @@ sponsors are what make keeping it free sustainable.
94
94
  - 🚀 **CRUD**: `find`, `findOne`, `findAll`, `create`, `update`, `delete`, and more
95
95
  - 🧩 **Configure once**: `forRoot()` owns the connection; services only declare a table
96
96
  - ⚡ **Type-safe**: generics over your entity, create/update DTOs and filter types
97
- - 🗃️ **PostgreSQL & MySQL**: dialect-aware (`RETURNING` vs `insertId`)
97
+ - 🗃️ **PostgreSQL, MySQL & SQLite**: dialect-aware (`RETURNING` vs `insertId`), libSQL and Cloudflare D1 included
98
98
  - 🔄 **Soft delete**: opt-in, with `restore`, a trash view (`onlyDeleted`) and `forceDelete`
99
99
  - 📦 **Bulk operations**: one statement per mass create/update/delete, inside a transaction, plus `upsert`
100
100
  - 🔍 **Filtering**: comparison, `in`/`notIn`, `between`, escaped `contains`/`startsWith`, array and JSON operators, `or`/`not` groups
@@ -136,23 +136,23 @@ pnpm add @nestjs/common @nestjs/core drizzle-orm reflect-metadata
136
136
  # PostgreSQL (also required if you use `connectionString` with dialect 'postgresql')
137
137
  pnpm add postgres
138
138
 
139
- # MySQL
139
+ # MySQL (also required if you use `connectionString` with dialect 'mysql')
140
140
  pnpm add mysql2
141
141
 
142
+ # SQLite / libSQL (you build the Drizzle instance and pass it as `db`)
143
+ pnpm add @libsql/client
144
+
142
145
  # Only for ApiCrudQuery / ApiCrudList (OpenAPI docs)
143
146
  pnpm add @nestjs/swagger
144
147
  ```
145
148
 
146
149
  > Using npm or yarn? Swap `pnpm add` for `npm install` / `yarn add`.
147
150
 
148
- > **pnpm note:** `postgres` is an optional peer dependency and `mysql2` is not a peer
149
- > at all. pnpm enforces peer deps strictly, so if you build the connection from a
150
- > `connectionString` make sure `postgres` is installed in your app; for MySQL you
151
- > install `mysql2` yourself and pass the `db` you build with it.
152
-
153
- > `postgres` is an **optional** peer dependency. It's only needed when you let the
154
- > module build the connection from a `connectionString` for the `postgresql` dialect.
155
- > If you pass a pre-built `db` instead, you don't need it.
151
+ > **pnpm note:** `postgres` and `mysql2` are optional peer dependencies. pnpm
152
+ > enforces peer deps strictly, so if you build the connection from a
153
+ > `connectionString` make sure the driver for your dialect is installed in your app.
154
+ > If you pass a pre-built `db` instead, you don't need either. SQLite always takes a
155
+ > `db` you build.
156
156
 
157
157
  ---
158
158
 
@@ -285,15 +285,16 @@ export class UsersController {
285
285
 
286
286
  | Field | Type | Description |
287
287
  |---|---|---|
288
- | `dialect` | `'postgresql' \| 'mysql'` | **Required.** Database dialect. |
289
- | `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL only). |
288
+ | `dialect` | `'postgresql' \| 'mysql' \| 'sqlite'` | **Required.** Database dialect. `'sqlite'` covers SQLite, libSQL and Cloudflare D1. |
289
+ | `connectionString` | `string` | Connection string. The module builds the connection (PostgreSQL with `postgres`, MySQL with `mysql2`). |
290
+ | `connection` | `{ poolSize?, idleTimeout?, ssl? }` | Pool size, idle timeout (seconds) and TLS for the connection built from `connectionString`. |
290
291
  | `db` | `Drizzle instance` | Alternatively, pass a Drizzle instance you built yourself (any dialect). |
291
292
  | `schema` | `Record<string, unknown>` | Drizzle schema, used when building from `connectionString`. |
292
293
  | `defaults.softDelete` | `boolean` | Enable soft delete for all entities (default `true`). |
293
294
  | `defaults.timestamps` | `boolean` | Auto-manage `created_at`/`updated_at` for all entities (default `true`). |
294
295
  | `defaults.pagination` | `{ defaultLimit?, maxLimit? }` | Pagination defaults (default `{ 20, 100 }`). Either key alone keeps the other's default. |
295
296
  | `defaults.sortOrder` | `'asc' \| 'desc'` | Opt-in default sort: entities **without** an explicit `defaultSort` fall back to ordering by their `created_at` column in this direction. Omit it and `findAll` orders by the primary key only. |
296
- | `sql` | `{ caseSensitive?, useReturning? }` | Dialect tuning. `useReturning` defaults to `true` for PostgreSQL, `false` for MySQL. `jsonSupport` and `enableFullTextSearch` are deprecated and do nothing. |
297
+ | `sql` | `{ caseSensitive?, useReturning? }` | Dialect tuning. `useReturning` defaults to `true` for PostgreSQL and SQLite, `false` for MySQL. `jsonSupport` and `enableFullTextSearch` are deprecated and do nothing. |
297
298
  | `hooks` | `{ enableGlobalHooks?, subscribers? }` | Module-level write events on the `CrudEventBus`. See [Events](#events-and-global-hooks). |
298
299
 
299
300
  > **Provide exactly one of `connectionString` or `db`.** If your tables have no
@@ -306,7 +307,7 @@ export class UsersController {
306
307
  > table does not have stops the app at startup, and the message names the key
307
308
  > to use.
308
309
 
309
- **Build the connection yourself (any dialect, recommended for MySQL):**
310
+ **Build the connection yourself (any dialect, required for SQLite):**
310
311
 
311
312
  ```typescript
312
313
  import { drizzle } from 'drizzle-orm/postgres-js';
@@ -332,6 +333,27 @@ DrizzleCrudModule.forRootAsync({
332
333
  });
333
334
  ```
334
335
 
336
+ `forRootAsync` also takes `useClass` or `useExisting` with a class that
337
+ implements `DrizzleCrudOptionsFactory`:
338
+
339
+ ```typescript
340
+ @Injectable()
341
+ class CrudConfig implements DrizzleCrudOptionsFactory {
342
+ constructor(private readonly cfg: ConfigService) {}
343
+ createDrizzleCrudOptions(): DrizzleCrudConfig {
344
+ return { dialect: 'mysql', connectionString: this.cfg.get('DATABASE_URL') };
345
+ }
346
+ }
347
+
348
+ DrizzleCrudModule.forRootAsync({ useClass: CrudConfig });
349
+ ```
350
+
351
+ On SQLite (`dialect: 'sqlite'`), mass writes, nested creates and attach / detach
352
+ / sync need an async transaction: they work on libSQL and throw a
353
+ `TransactionException` on Cloudflare D1 and synchronous drivers such as
354
+ better-sqlite3. Full-text search, row locks and the array and JSON operators
355
+ are a 400 there.
356
+
335
357
  ### `DrizzleCrudModule.forFeature(entities)`
336
358
 
337
359
  Registers one or more services and binds each to its table. Per-entity overrides
@@ -1111,7 +1133,19 @@ const { data, total } = await service.fullTextSearch(
1111
1133
  ```
1112
1134
 
1113
1135
  Builds `to_tsvector(...) @@ plainto_tsquery(...)` across the given columns and
1114
- orders by `ts_rank`. Throws if the dialect is not `postgresql`.
1136
+ orders by `ts_rank`. Throws a 400 if the dialect is not `postgresql`. The options
1137
+ take `filters`, `relations`, `select` and `counts` as on `findAll`, plus
1138
+ `language` (default `'english'`) and `queryMode: 'websearch'` for
1139
+ `websearch_to_tsquery`:
1140
+
1141
+ ```typescript
1142
+ await service.fullTextSearch('"zero downtime" -kubernetes', ['title', 'body'], {}, {
1143
+ filters: { status: 'published' },
1144
+ relations: ['author'],
1145
+ language: 'english',
1146
+ queryMode: 'websearch',
1147
+ });
1148
+ ```
1115
1149
 
1116
1150
  For paginated list endpoints, prefer `findAll(..., ..., { search })` so search is
1117
1151
  combined with filters and the count query. Use `fullTextSearch` directly when
@@ -1211,7 +1245,7 @@ read-only and never touches your database. Full setup:
1211
1245
 
1212
1246
  Use these facts instead of guessing.
1213
1247
 
1214
- **Package:** `nestjs-drizzle-crud` · **Peers:** `@nestjs/common`, `@nestjs/core`, `drizzle-orm`, `reflect-metadata`; optional `postgres` (PG) / `@nestjs/swagger` (for `ApiCrudList`); `mysql2` is installed by you for MySQL, not a peer.
1248
+ **Package:** `nestjs-drizzle-crud` · **Peers:** `@nestjs/common`, `@nestjs/core`, `drizzle-orm`, `reflect-metadata`; optional `postgres` (PG `connectionString`) / `mysql2` (MySQL `connectionString`) / `@nestjs/swagger` (for `ApiCrudList`). SQLite drivers are yours, passed as `db`.
1215
1249
 
1216
1250
  **Setup is two steps and no per-service connection wiring:**
1217
1251
  1. `DrizzleCrudModule.forRoot({ dialect, connectionString | db, schema, defaults })` once in `AppModule`.
@@ -1242,15 +1276,17 @@ export class XService extends SqlBaseCrudService<X, CreateXDto, UpdateXDto, XFil
1242
1276
  - Relations: declare in forFeature `config.relations` and eager-load via `options.relations: ['relName']`, where a dotted path nests (`['states', 'states.cities']`, `['state.country']`; the parent path is pulled in automatically). Belongs-to is `{ table, localKey, references? }`, LEFT JOINed into the main query, resolving to an object or `null`. Has-many is `{ table, type: 'hasMany', foreignKey, parentKey?, orderBy? }`. Many-to-many is `{ table, type: 'manyToMany', through, throughLocalKey, throughForeignKey, references?, parentKey?, orderBy? }`. Nested relations live in a `relations` map on the relation. Every collection and every nested level costs exactly one extra query for the whole result set, never one per row. Filter via `findAll({ relName: { col: value } })` with the same operators; collection filters compile to `parentKey IN (SELECT ... WHERE ...)`, so `total` stays correct. Filters go through relation paths at any depth (`{ state: { country: { code: 'IN' } } }`), and collections take `some` / `none` / `every` (`{ posts: { none: { status: 'draft' } } }`), compiled to subqueries.
1243
1277
  - `options.select` narrows the projection and takes dotted relation paths at any depth: `['id', 'name', 'region.code', 'states.cities.name']`. Unknown names are ignored; an all-dotted list leaves the base row complete; the parent key, the child foreign key and any nested key are re-added automatically.
1244
1278
  - Primary keys: `primaryKey` (default `'id'`) per entity in forFeature config (`primaryKeyType` is deprecated and does nothing). UUID works on Postgres via RETURNING; with uuid keys, route params are strings, so don't use `ParseIntPipe`.
1245
- - Full-text search is PostgreSQL-only.
1279
+ - Full-text search is PostgreSQL-only; it takes `filters`, `relations`, `select`, `language` and `queryMode` in its options.
1280
+ - SQLite (`dialect: 'sqlite'`): pass `db`. Bulk writes need libSQL; D1 and sync drivers throw `TransactionException`. Locks, full-text and array/JSON operators are a 400.
1281
+ - Exports (3.7): `DrizzleConnectionOptions`, `DrizzleCrudAsyncOptions`, `DrizzleCrudOptionsFactory`, `FullTextOptions`, `FullTextQueryMode`, `FullTextSearchOptions`.
1246
1282
  - Exports (3.5): `createCrudController`, `CrudContext`, `CrudEventBus`, `ApiCrudQuery`, `ApiCrudList`, `MissingScopeException`, `StaleVersionException`, and the types `CrudControllerOptions`, `CrudEvent`, `CrudSubscriber`, `CrudScope`, `CollectionFilter`, `CountByRow`, `CrudPage`, `UncountedCrudPage`, `RelationCounts`.
1247
- - Exports: `SqlBaseCrudService`, `DrizzleCrudModule`, `DRIZZLE_DB`, `DRIZZLE_CRUD_CONFIG`, `TestCrudFactory`, exceptions (`EntityNotFoundException`, `DuplicateEntityException`, `ForeignKeyViolationException`, `EntityInUseException`, `ValidationFailedException`, `BulkOperationException`, plus the deprecated `DatabaseConnectionException` and `TransactionException`), and types (`SqlCrudConfig`, `SqlOperationOptions`, `DrizzleCrudConfig`, `CrudFeature`, `CrudFeatureConfig`, `PaginationOptions`, `OperationOptions`, `SqlSearchOptions`, `SqlSearchMode`, `BulkOperationResult`, `BulkOperationItemError`, `SqlDialect`, `PrimaryKeyType`, `CrudFilter`, `FilterOperators`, `SortInput`, `CursorPaginationOptions`, `CursorPage`, `CrudAllowlists`, `UpsertOptions`, `BulkMode`, `ParsedCrudQuery`), and `parseCrudQuery`.
1283
+ - Exports: `SqlBaseCrudService`, `DrizzleCrudModule`, `DRIZZLE_DB`, `DRIZZLE_CRUD_CONFIG`, `TestCrudFactory`, exceptions (`EntityNotFoundException`, `DuplicateEntityException`, `ForeignKeyViolationException`, `EntityInUseException`, `ValidationFailedException`, `BulkOperationException`, `TransactionException`, plus the deprecated `DatabaseConnectionException`), and types (`SqlCrudConfig`, `SqlOperationOptions`, `DrizzleCrudConfig`, `CrudFeature`, `CrudFeatureConfig`, `PaginationOptions`, `OperationOptions`, `SqlSearchOptions`, `SqlSearchMode`, `BulkOperationResult`, `BulkOperationItemError`, `SqlDialect`, `PrimaryKeyType`, `CrudFilter`, `FilterOperators`, `SortInput`, `CursorPaginationOptions`, `CursorPage`, `CrudAllowlists`, `UpsertOptions`, `BulkMode`, `ParsedCrudQuery`), and `parseCrudQuery`.
1248
1284
 
1249
1285
  ### `SqlCrudConfig`
1250
1286
 
1251
1287
  ```typescript
1252
1288
  interface SqlCrudConfig {
1253
- dialect: 'postgresql' | 'mysql';
1289
+ dialect: 'postgresql' | 'mysql' | 'sqlite';
1254
1290
  db: any; // Drizzle instance (injected by forFeature)
1255
1291
  table: any; // Drizzle table (set by forFeature)
1256
1292
  primaryKey: string; // default 'id'
@@ -3,7 +3,7 @@ import type { ICrudService, PaginationOptions } from "../interfaces/crud-service
3
3
  import type { CrudWriteOperation } from "../interfaces/events.interface";
4
4
  import type { CountByRow, CrudAllowlists, CrudPage, CursorPage, CursorPaginationOptions, ParseCrudQueryOptions, ParsedCrudQuery, RelationCounts, SortInput, UncountedCrudPage, UpsertOptions } from "../interfaces/query.interface";
5
5
  import type { CrudScopeValues } from "../interfaces/scope.interface";
6
- import type { RelationConfig, RelationNode, RelationsConfig, RelationType, SelectTree, SqlCrudConfig, SqlOperationOptions } from "../interfaces/sql-crud-config.interface";
6
+ import type { FullTextOptions, FullTextSearchOptions, RelationConfig, RelationNode, RelationsConfig, RelationType, SelectTree, SqlCrudConfig, SqlOperationOptions } from "../interfaces/sql-crud-config.interface";
7
7
  import type { SortOrder } from "../types/sql.types";
8
8
  interface SortSpec {
9
9
  key: string;
@@ -85,8 +85,12 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
85
85
  offset: number;
86
86
  };
87
87
  protected isUniqueViolation(error: any): boolean;
88
+ protected sqliteConstraint(error: any): string | undefined;
88
89
  protected toDuplicateException(error: any): DuplicateEntityException;
89
90
  private static readonly DATA_ERRORS;
91
+ private static readonly MYSQL_DATA_ERRORS;
92
+ private static readonly SQLITE_DATA_ERRORS;
93
+ private dataErrorMessage;
90
94
  protected isDataException(error: any): boolean;
91
95
  protected toDataException(error: any): ValidationFailedException;
92
96
  protected toClientError(error: any, operation?: "read" | "write" | "delete"): unknown;
@@ -94,8 +98,14 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
94
98
  protected isForeignKeyViolation(error: any): boolean;
95
99
  protected toForeignKeyException(error: any): ForeignKeyViolationException;
96
100
  protected applyLock(query: any, options?: SqlOperationOptions): any;
101
+ protected textSearchConfig(language?: unknown): any;
102
+ protected fullTextParts(columns: any[], term: string, options?: FullTextOptions): {
103
+ vector: any;
104
+ query: any;
105
+ };
97
106
  protected buildSearchCondition(search?: SqlOperationOptions["search"]): any;
98
107
  protected caseInsensitiveLike(column: any, pattern: string): any;
108
+ protected likePattern(column: any, pattern: string): any;
99
109
  parseQuery(query: Record<string, unknown> | null | undefined, options?: ParseCrudQueryOptions): ParsedCrudQuery;
100
110
  idKind(): "int" | "bigint" | "uuid" | "string";
101
111
  parseId(raw: unknown): Promise<unknown>;
@@ -219,8 +229,9 @@ export declare abstract class SqlBaseCrudService<T extends Record<string, any>,
219
229
  private patternCondition;
220
230
  protected applyComplexFilter(conditions: any[], column: any, filterObj: any): void;
221
231
  protected executeSqlTransaction<R>(operation: (tx: any) => Promise<R>, existingTransaction?: any): Promise<R>;
232
+ protected assertTransactions(db: any): void;
222
233
  protected getEntityName(): string;
223
- fullTextSearch(searchTerm: string, searchColumns: string[], pagination?: PaginationOptions, options?: SqlOperationOptions): Promise<{
234
+ fullTextSearch(searchTerm: string, searchColumns: string[], pagination?: PaginationOptions, options?: FullTextSearchOptions<FilterDto>): Promise<{
224
235
  data: T[];
225
236
  total: number;
226
237
  page: number;
@@ -66,6 +66,10 @@ class SqlBaseCrudService {
66
66
  return result.rowsAffected > 0;
67
67
  if (typeof result.affectedRows === "number")
68
68
  return result.affectedRows > 0;
69
+ if (typeof result.changes === "number")
70
+ return result.changes > 0;
71
+ if (typeof result.meta?.changes === "number")
72
+ return result.meta.changes > 0;
69
73
  return false;
70
74
  }
71
75
  buildWhereConditionsFromPartial(where) {
@@ -806,13 +810,32 @@ class SqlBaseCrudService {
806
810
  isUniqueViolation(error) {
807
811
  const code = error?.code ?? error?.cause?.code;
808
812
  const errno = error?.errno ?? error?.cause?.errno;
809
- return code === "23505" || code === "ER_DUP_ENTRY" || errno === 1062;
813
+ const sqlite = this.sqliteConstraint(error);
814
+ return (code === "23505" ||
815
+ code === "ER_DUP_ENTRY" ||
816
+ errno === 1062 ||
817
+ sqlite === "UNIQUE" ||
818
+ sqlite === "PRIMARYKEY");
819
+ }
820
+ sqliteConstraint(error) {
821
+ if (this.config.dialect !== "sqlite")
822
+ return undefined;
823
+ for (const e of [error, error?.cause]) {
824
+ const code = e?.extendedCode ?? e?.code;
825
+ if (typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT_")) {
826
+ return code.slice("SQLITE_CONSTRAINT_".length);
827
+ }
828
+ }
829
+ const message = `${error?.cause?.message ?? ""} ${error?.message ?? ""}`;
830
+ const m = message.match(/(UNIQUE|PRIMARY KEY|FOREIGN KEY|NOT NULL|CHECK) constraint failed/);
831
+ return m?.[1]?.replace(" ", "");
810
832
  }
811
833
  toDuplicateException(error) {
812
834
  const detail = error?.detail ?? error?.cause?.detail ?? "";
813
835
  const message = error?.cause?.message ?? error?.message ?? "";
814
836
  const m = detail.match(/Key \((?<field>.+?)\)=\((?<value>.+?)\)/) ??
815
- message.match(/Duplicate entry '(?<value>.*)' for key '(?:[^']*\.)?(?<field>[^']+)'/);
837
+ message.match(/Duplicate entry '(?<value>.*)' for key '(?:[^']*\.)?(?<field>[^']+)'/) ??
838
+ message.match(/UNIQUE constraint failed: (?:[^.,\s]+\.)?(?<field>[^,\s]+)/);
816
839
  return new crud_exceptions_1.DuplicateEntityException(this.getEntityName(), m?.groups?.field ?? "unique field", m?.groups?.value ?? "");
817
840
  }
818
841
  static DATA_ERRORS = {
@@ -825,18 +848,53 @@ class SqlBaseCrudService {
825
848
  "22008": "a date or time value is out of range",
826
849
  "21000": "the same key appears more than once in one upsert",
827
850
  };
828
- isDataException(error) {
851
+ static MYSQL_DATA_ERRORS = {
852
+ 1048: "a required value is missing",
853
+ 1364: "a required value is missing",
854
+ 1406: "a value is too long for its column",
855
+ 3819: "a value violates a column check constraint",
856
+ 1264: "a numeric value is out of range",
857
+ 1265: "a value does not match its column type",
858
+ 1366: "a value does not match its column type",
859
+ 1292: "a date or time value has an invalid format",
860
+ };
861
+ static SQLITE_DATA_ERRORS = {
862
+ NOTNULL: "a required value is missing",
863
+ CHECK: "a value violates a column check constraint",
864
+ };
865
+ dataErrorMessage(error) {
829
866
  const code = error?.code ?? error?.cause?.code;
830
- return Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "");
867
+ if (Object.hasOwn(SqlBaseCrudService.DATA_ERRORS, code ?? "")) {
868
+ return SqlBaseCrudService.DATA_ERRORS[code];
869
+ }
870
+ if (this.config.dialect === "mysql") {
871
+ const errno = error?.errno ?? error?.cause?.errno;
872
+ return Object.hasOwn(SqlBaseCrudService.MYSQL_DATA_ERRORS, errno ?? "")
873
+ ? SqlBaseCrudService.MYSQL_DATA_ERRORS[errno]
874
+ : undefined;
875
+ }
876
+ const sqlite = this.sqliteConstraint(error) ?? "";
877
+ return Object.hasOwn(SqlBaseCrudService.SQLITE_DATA_ERRORS, sqlite)
878
+ ? SqlBaseCrudService.SQLITE_DATA_ERRORS[sqlite]
879
+ : undefined;
880
+ }
881
+ isDataException(error) {
882
+ return this.dataErrorMessage(error) !== undefined;
831
883
  }
832
884
  toDataException(error) {
833
- const code = error?.code ?? error?.cause?.code;
834
- const message = SqlBaseCrudService.DATA_ERRORS[code] ?? "invalid value";
885
+ const message = this.dataErrorMessage(error) ?? "invalid value";
835
886
  const cause = error?.cause ?? error;
887
+ const text = String(cause?.message ?? "");
836
888
  const column = error?.column ??
837
889
  cause?.column ??
838
890
  error?.column_name ??
839
- cause?.column_name;
891
+ cause?.column_name ??
892
+ (this.config.dialect === "mysql"
893
+ ? text.match(/column '([^']+)'/i)?.[1]
894
+ : undefined) ??
895
+ (this.config.dialect === "sqlite"
896
+ ? text.match(/NOT NULL constraint failed: (?:[^.\s]+\.)?(\S+)/)?.[1]
897
+ : undefined);
840
898
  return new crud_exceptions_1.ValidationFailedException(column ? `${message}: ${column}` : message);
841
899
  }
842
900
  toClientError(error, operation = "write") {
@@ -862,7 +920,10 @@ class SqlBaseCrudService {
862
920
  isForeignKeyViolation(error) {
863
921
  const code = error?.code ?? error?.cause?.code;
864
922
  const errno = error?.errno ?? error?.cause?.errno;
865
- return code === "23503" || errno === 1451 || errno === 1452;
923
+ return (code === "23503" ||
924
+ errno === 1451 ||
925
+ errno === 1452 ||
926
+ this.sqliteConstraint(error) === "FOREIGNKEY");
866
927
  }
867
928
  toForeignKeyException(error) {
868
929
  const detail = error?.detail ?? error?.cause?.detail ?? "";
@@ -870,14 +931,37 @@ class SqlBaseCrudService {
870
931
  return new crud_exceptions_1.ForeignKeyViolationException(this.getEntityName(), m?.groups?.field ?? "reference");
871
932
  }
872
933
  applyLock(query, options) {
873
- if (this.config.dialect !== "postgresql")
934
+ const strength = options?.forNoKeyUpdate
935
+ ? "no key update"
936
+ : options?.lock && options.lock !== "none"
937
+ ? options.lock
938
+ : undefined;
939
+ if (!strength)
874
940
  return query;
875
- const of = { of: this.config.table };
876
- if (options?.forNoKeyUpdate)
877
- return query.for("no key update", of);
878
- if (options?.lock && options.lock !== "none")
879
- return query.for(options.lock, of);
880
- return query;
941
+ if (this.config.dialect === "postgresql") {
942
+ return query.for(strength, { of: this.config.table });
943
+ }
944
+ if (this.config.dialect === "mysql") {
945
+ return query.for(strength === "share" ? "share" : "update");
946
+ }
947
+ throw new common_1.BadRequestException(`Row locks (lock, forNoKeyUpdate) are not supported on ${this.config.dialect}`);
948
+ }
949
+ textSearchConfig(language = "english") {
950
+ if (typeof language !== "string" ||
951
+ !/^[A-Za-z_][A-Za-z0-9_]{0,62}(\.[A-Za-z_][A-Za-z0-9_]{0,62})?$/.test(language)) {
952
+ throw new common_1.BadRequestException(`Invalid full-text search language: ${String(language)}`);
953
+ }
954
+ return drizzle_orm_1.sql.raw(`'${language}'`);
955
+ }
956
+ fullTextParts(columns, term, options = {}) {
957
+ const config = this.textSearchConfig(options.language);
958
+ const mode = options.queryMode ?? "plain";
959
+ if (mode !== "plain" && mode !== "websearch") {
960
+ throw new common_1.BadRequestException(`Invalid full-text query mode: ${String(mode)} (expected 'plain' or 'websearch')`);
961
+ }
962
+ const parse = drizzle_orm_1.sql.raw(mode === "websearch" ? "websearch_to_tsquery" : "plainto_tsquery");
963
+ const vector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector(${config}, ${column})`), (0, drizzle_orm_1.sql) ` || `);
964
+ return { vector, query: (0, drizzle_orm_1.sql) `${parse}(${config}, ${term})` };
881
965
  }
882
966
  buildSearchCondition(search) {
883
967
  if (!search?.term || !search.columns?.length)
@@ -894,18 +978,22 @@ class SqlBaseCrudService {
894
978
  if (this.config.dialect !== "postgresql") {
895
979
  throw new common_1.BadRequestException("Full-text search only supported in PostgreSQL");
896
980
  }
897
- const tsVector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector('english', ${column})`), (0, drizzle_orm_1.sql) ` || `);
898
- const tsQuery = (0, drizzle_orm_1.sql) `plainto_tsquery('english', ${term})`;
899
- return (0, drizzle_orm_1.sql) `${tsVector} @@ ${tsQuery}`;
981
+ const { vector, query } = this.fullTextParts(columns, term, search);
982
+ return (0, drizzle_orm_1.sql) `${vector} @@ ${query}`;
900
983
  }
901
984
  const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
902
985
  const conditions = columns.map((column) => this.caseInsensitiveLike(column, pattern));
903
986
  return conditions.length === 1 ? conditions[0] : (0, drizzle_orm_1.or)(...conditions);
904
987
  }
905
988
  caseInsensitiveLike(column, pattern) {
906
- return this.config.dialect === "mysql"
907
- ? (0, drizzle_orm_1.like)(column, pattern)
908
- : (0, drizzle_orm_1.ilike)(column, pattern);
989
+ if (this.config.dialect === "postgresql")
990
+ return (0, drizzle_orm_1.ilike)(column, pattern);
991
+ return this.likePattern(column, pattern);
992
+ }
993
+ likePattern(column, pattern) {
994
+ return this.config.dialect === "sqlite"
995
+ ? (0, drizzle_orm_1.sql) `${column} like ${pattern} escape '\\'`
996
+ : (0, drizzle_orm_1.like)(column, pattern);
909
997
  }
910
998
  parseQuery(query, options = {}) {
911
999
  const config = this.config;
@@ -1211,7 +1299,7 @@ class SqlBaseCrudService {
1211
1299
  return branches.length > 0 ? (0, drizzle_orm_1.or)(...branches) : (0, drizzle_orm_1.sql) `false`;
1212
1300
  }
1213
1301
  afterCursor(spec, value) {
1214
- const postgres = this.config.dialect !== "mysql";
1302
+ const postgres = this.config.dialect === "postgresql";
1215
1303
  const nullsFirst = postgres ? spec.order === "desc" : spec.order === "asc";
1216
1304
  if (value === null) {
1217
1305
  return nullsFirst ? (0, drizzle_orm_1.isNotNull)(spec.column) : undefined;
@@ -2751,7 +2839,7 @@ class SqlBaseCrudService {
2751
2839
  : `%${escaped}%`;
2752
2840
  return insensitive || this.config.sql?.caseSensitive === false
2753
2841
  ? this.caseInsensitiveLike(column, pattern)
2754
- : (0, drizzle_orm_1.like)(column, pattern);
2842
+ : this.likePattern(column, pattern);
2755
2843
  }
2756
2844
  applyComplexFilter(conditions, column, filterObj) {
2757
2845
  const coerce = (value) => this.coerceFilterValue(column, value);
@@ -2783,7 +2871,7 @@ class SqlBaseCrudService {
2783
2871
  conditions.push((0, drizzle_orm_1.ne)(column, coerce(raw)));
2784
2872
  break;
2785
2873
  case "like":
2786
- conditions.push((0, drizzle_orm_1.like)(column, raw));
2874
+ conditions.push(this.likePattern(column, raw));
2787
2875
  break;
2788
2876
  case "ilike":
2789
2877
  conditions.push(this.caseInsensitiveLike(column, raw));
@@ -2841,10 +2929,22 @@ class SqlBaseCrudService {
2841
2929
  }
2842
2930
  }
2843
2931
  async executeSqlTransaction(operation, existingTransaction) {
2932
+ this.assertTransactions(existingTransaction ?? this.config.db);
2844
2933
  if (existingTransaction)
2845
2934
  return operation(existingTransaction);
2846
2935
  return this.config.db.transaction(operation);
2847
2936
  }
2937
+ assertTransactions(db) {
2938
+ if (this.config.dialect !== "sqlite")
2939
+ return;
2940
+ const kind = db?.constructor?.[drizzle_orm_1.entityKind];
2941
+ if (kind === "D1Database" || kind === "D1Transaction") {
2942
+ throw new crud_exceptions_1.TransactionException("Cloudflare D1 has no interactive transactions, so mass writes, nested writes and attach/detach/sync are not available on it. Use the single-row methods, or db.batch() yourself.");
2943
+ }
2944
+ if (db?.resultKind === "sync") {
2945
+ throw new crud_exceptions_1.TransactionException("This SQLite driver is synchronous (better-sqlite3, bun:sqlite, sql.js), and its transactions cannot wait for async work, so mass writes, nested writes and attach/detach/sync are not available on it. Use an async driver such as libSQL (@libsql/client).");
2946
+ }
2947
+ }
2848
2948
  getEntityName() {
2849
2949
  try {
2850
2950
  return (0, drizzle_orm_1.getTableName)(this.config.table) || "Entity";
@@ -2868,31 +2968,37 @@ class SqlBaseCrudService {
2868
2968
  this.assertAllowed("searchableColumns", name, options);
2869
2969
  return column;
2870
2970
  });
2871
- const { transaction } = options || {};
2872
- const db = transaction || this.config.db;
2873
- const tsVector = drizzle_orm_1.sql.join(columns.map((column) => (0, drizzle_orm_1.sql) `to_tsvector('english', ${column})`), (0, drizzle_orm_1.sql) ` || `);
2874
- const tsQuery = (0, drizzle_orm_1.sql) `plainto_tsquery('english', ${searchTerm})`;
2875
- const matchExpr = (0, drizzle_orm_1.sql) `${tsVector} @@ ${tsQuery}`;
2876
- const whereExpr = (0, drizzle_orm_1.and)(matchExpr, ...this.rowScope(options));
2971
+ const { filters, language, queryMode, ...readOptions } = options ?? {};
2972
+ const { vector, query } = this.fullTextParts(columns, searchTerm, {
2973
+ language,
2974
+ queryMode,
2975
+ });
2877
2976
  const { page, limit, offset } = this.resolvePagination(pagination?.page, pagination?.limit);
2977
+ const plan = this.planList(filters, undefined, "asc", readOptions);
2978
+ const conditions = [(0, drizzle_orm_1.sql) `${vector} @@ ${query}`, ...plan.conditions];
2979
+ const joins = Array.from(new Set([
2980
+ ...plan.joined.map((node) => node.name),
2981
+ ...plan.filterRelations,
2982
+ ]));
2878
2983
  const pk = this.columnOf(this.config.table, this.config.primaryKey);
2879
- const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${tsVector}, ${tsQuery}) DESC`];
2984
+ const orderBy = [(0, drizzle_orm_1.sql) `ts_rank(${vector}, ${query}) DESC`];
2880
2985
  if (pk)
2881
2986
  orderBy.push((0, drizzle_orm_1.asc)(pk));
2882
- const dataQuery = db
2883
- .select()
2884
- .from(this.config.table)
2885
- .where(whereExpr)
2987
+ let dataQuery = this.startSelect(plan.db, plan.baseSelect, plan.joined, joins.length > 0, plan.selection);
2988
+ dataQuery = this.applyJoins(dataQuery, joins, readOptions)
2989
+ .where((0, drizzle_orm_1.and)(...conditions))
2886
2990
  .orderBy(...orderBy)
2887
2991
  .limit(limit)
2888
2992
  .offset(offset);
2889
- const countQuery = db
2890
- .select({ count: (0, drizzle_orm_1.sql) `count(*)` })
2891
- .from(this.config.table)
2892
- .where(whereExpr);
2893
- const [data, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
2993
+ const countQuery = this.buildCountQuery(plan.db, conditions, plan.filterRelations, readOptions);
2994
+ const [fetched, totalResult] = await this.runRead(Promise.all([dataQuery, countQuery]));
2894
2995
  const total = parseInt(totalResult[0]?.count?.toString() || "0", 10);
2895
- return { data, total, page, limit };
2996
+ const rows = this.normalizeRelations(fetched, plan.joined.map((node) => node.name));
2997
+ await Promise.all([
2998
+ this.loadRelations(plan.db, rows, plan.nodes, plan.selection, undefined, readOptions),
2999
+ this.loadCounts(plan.db, rows, readOptions.counts, readOptions),
3000
+ ]);
3001
+ return { data: rows, total, page, limit };
2896
3002
  }
2897
3003
  }
2898
3004
  exports.SqlBaseCrudService = SqlBaseCrudService;