metal-orm 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,8 +110,10 @@ Full docs live in the `docs/` folder:
110
110
  - **Table-valued functions**: use the new `tvf(key, …)` helper when you want portable intents such as `ARRAY_UNNEST`, letting the dialects’ `TableFunctionStrategy` renderers emit dialect-specific syntax (`LATERAL`/`WITH ORDINALITY`, alias validation, quoting, etc.). `fnTable()` remains available as the raw escape hatch when you need to emit a specific SQL function directly.
111
111
  - **String helpers**: `lower`, `upper`, `trim`, `ltrim/rtrim`, `concat/concatWs`, `substr/left/right`, `position/instr/locate`, `replace`, `repeat`, `lpad/rpad`, `space`, and more with dialect-aware rendering.
112
112
  - **Set operations**: `union`, `unionAll`, `intersect`, `except` across all dialects (ORDER/LIMIT apply to the combined result; hydration is disabled for compound queries so rows are returned as-is without collapsing duplicates).
113
- - **Expression builders**: `eq`, `and`, `or`, `between`, `inList`, `exists`, `jsonPath`, `caseWhen`, window functions like `rowNumber`, `rank`, `lag`, `lead`, etc., all backed by typed AST nodes.
114
- - **Relation-aware hydration**: turn flat rows into nested objects (`user.posts`, `user.roles`, etc.) using a hydration plan derived from the AST metadata.
113
+ - **Expression builders**: `eq`, `and`, `or`, `between`, `inList`, `exists`, `jsonPath`, `caseWhen`, window functions like `rowNumber`, `rank`, `lag`, `lead`, etc., all backed by typed AST nodes.
114
+ - **Operator safety**: scalar operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`) are for single values; for arrays, use `inList`/`notInList`.
115
+ - Migration example: `where(eq(tipoAcao.columns.codigo, codigos))` -> `where(inList(tipoAcao.columns.codigo, codigos))`.
116
+ - **Relation-aware hydration**: turn flat rows into nested objects (`user.posts`, `user.roles`, etc.) using a hydration plan derived from the AST metadata.
115
117
  - **Multi-dialect**: compile once, run on MySQL/MariaDB, PostgreSQL, SQLite, or SQL Server via pluggable dialects.
116
118
  - **DML**: type-safe INSERT / UPDATE / DELETE with `RETURNING` where supported.
117
119
 
@@ -129,7 +131,7 @@ On top of the query builder, MetalORM ships a focused runtime managed by `Orm` a
129
131
  - **Lazy, batched relations**: `user.posts.load()`, `user.roles.syncByIds([...])`, etc.
130
132
  - **Scoped transactions**: `session.transaction(async s => { ... })` wraps `begin/commit/rollback` on the existing executor; `Orm.transaction` remains available when you want a fresh transactional executor per call.
131
133
  - **Identity map**: the same row becomes the same entity instance within a session (see the [Identity map pattern](https://en.wikipedia.org/wiki/Identity_map_pattern)).
132
- - **Caching**: Flexible caching with `MemoryCacheAdapter` (dev) or `KeyvCacheAdapter` (Redis, etc.). Features human-readable TTL (`'30m'`, `'2h'`), tag-based invalidation, and multi-tenant cache isolation.
134
+ - **Caching**: Flexible caching with `MemoryCacheAdapter` (dev), `KeyvCacheAdapter` (simple production), or `RedisCacheAdapter` (full-featured with tag support). Features human-readable TTL (`'30m'`, `'2h'`), tag-based invalidation, and multi-tenant cache isolation.
133
135
  - **Tree Behavior (Nested Set/MPTT)**: hierarchical data with `TreeManager`, `treeQuery()`, and `@Tree` decorators. Efficient O(log n) operations for moves, inserts, and deletes. Supports multi-tree scoping, recovery, and validation.
134
136
  - **DTO/OpenAPI helpers**: the `metal-orm/dto` module generates DTOs and OpenAPI schemas, including tree schemas (`TreeNode`, `TreeNodeResult`, threaded trees).
135
137
  - **Unit of Work (`OrmSession`)** tracking New/Dirty/Removed entities and relation changes, inspired by the classic [Unit of Work pattern](https://en.wikipedia.org/wiki/wiki/Unit_of_work).
@@ -193,15 +195,24 @@ Pick the matching dialect (`MySqlDialect`, `SQLiteDialect`, `PostgresDialect`, `
193
195
 
194
196
  > Drivers are declared as optional peer dependencies. Install only the ones you actually use in your project.
195
197
 
196
- **Optional: Caching Backend**
198
+ **Optional: Caching Backends**
197
199
 
198
- For production caching with Redis or other stores:
200
+ For production caching, choose based on your needs:
201
+
202
+ | Adapter | Tags | Install | Use Case |
203
+ |---------|------|---------|----------|
204
+ | `RedisCacheAdapter` | ✅ Full support | `npm install ioredis` | Production with tag invalidation |
205
+ | `KeyvCacheAdapter` | ❌ Not supported | `npm install keyv @keyv/redis` | Simple production setups |
199
206
 
200
207
  ```bash
208
+ # For full-featured Redis (recommended)
209
+ npm install ioredis
210
+
211
+ # For simple Keyv-based caching
201
212
  npm install keyv @keyv/redis
202
213
  ```
203
214
 
204
- > The `keyv` package is optional. MetalORM includes `MemoryCacheAdapter` for development without external dependencies.
215
+ > Caching packages are optional peer dependencies. MetalORM includes `MemoryCacheAdapter` for development without external dependencies.
205
216
 
206
217
  ### Playground (optional) 🧪
207
218
 
package/dist/index.cjs CHANGED
@@ -89,6 +89,7 @@ __export(index_exports, {
89
89
  PrimaryKey: () => PrimaryKey,
90
90
  PrototypeMaterializationStrategy: () => PrototypeMaterializationStrategy,
91
91
  QueryCacheManager: () => QueryCacheManager,
92
+ RedisCacheAdapter: () => RedisCacheAdapter,
92
93
  RelationKinds: () => RelationKinds,
93
94
  STANDARD_COLUMN_TYPES: () => STANDARD_COLUMN_TYPES,
94
95
  SelectQueryBuilder: () => SelectQueryBuilder,
@@ -202,6 +203,7 @@ __export(index_exports, {
202
203
  eq: () => eq,
203
204
  esel: () => esel,
204
205
  exclude: () => exclude,
206
+ executeFilteredPaged: () => executeFilteredPaged,
205
207
  executeHydrated: () => executeHydrated,
206
208
  executeHydratedPlain: () => executeHydratedPlain,
207
209
  executeHydratedPlainWithContexts: () => executeHydratedPlainWithContexts,
@@ -802,6 +804,11 @@ var toOperandNode = (value) => {
802
804
  if (isLiteralValue(value)) {
803
805
  return toLiteralNode(value);
804
806
  }
807
+ if (Array.isArray(value)) {
808
+ throw new Error(
809
+ "Array operands are not supported in scalar comparisons. Use inList/notInList for array matching."
810
+ );
811
+ }
805
812
  return columnRefToNode(value);
806
813
  };
807
814
  var valueToOperand = (value) => {
@@ -14003,6 +14010,11 @@ function isValidDuration(value) {
14003
14010
  // src/cache/adapters/memory-cache-adapter.ts
14004
14011
  var MemoryCacheAdapter = class {
14005
14012
  name = "memory";
14013
+ capabilities = {
14014
+ tags: true,
14015
+ prefix: true,
14016
+ ttl: true
14017
+ };
14006
14018
  storage = /* @__PURE__ */ new Map();
14007
14019
  tagIndex = /* @__PURE__ */ new Map();
14008
14020
  async get(key) {
@@ -16208,6 +16220,129 @@ function computePaginationMetadata(totalItems, page, pageSize) {
16208
16220
  };
16209
16221
  }
16210
16222
 
16223
+ // src/dto/execute-filtered-paged.ts
16224
+ var normalizeSortBy = (sortBy) => {
16225
+ if (!sortBy) {
16226
+ return void 0;
16227
+ }
16228
+ const normalized = sortBy.trim();
16229
+ return normalized.length > 0 ? normalized : void 0;
16230
+ };
16231
+ var detectPrimaryKeyName = (table) => {
16232
+ const pk = Object.values(table.columns).find((column) => column.primary);
16233
+ return pk?.name;
16234
+ };
16235
+ var toColumnNode = (table, name) => ({
16236
+ type: "Column",
16237
+ table: table.name,
16238
+ name
16239
+ });
16240
+ var getColumnByName = (table, name) => {
16241
+ return Object.values(table.columns).find((column) => column.name === name);
16242
+ };
16243
+ var resolveSortTermFromAllowed = (sortBy, allowedSortColumns) => {
16244
+ if (!allowedSortColumns) {
16245
+ throw new Error("allowedSortColumns is required when sortBy/defaultSortBy is provided.");
16246
+ }
16247
+ const term = allowedSortColumns[sortBy];
16248
+ if (term) {
16249
+ return term;
16250
+ }
16251
+ const allowedKeys = Object.keys(allowedSortColumns);
16252
+ throw new Error(
16253
+ `Invalid sortBy "${sortBy}". Allowed values: ${allowedKeys.length > 0 ? allowedKeys.join(", ") : "(none)"}.`
16254
+ );
16255
+ };
16256
+ var resolvePrimarySort = (table, sortBy, sortDirection, allowedSortColumns, defaultSortBy, defaultSortDirection) => {
16257
+ const requestedSortBy = normalizeSortBy(sortBy);
16258
+ if (requestedSortBy) {
16259
+ return {
16260
+ term: resolveSortTermFromAllowed(requestedSortBy, allowedSortColumns),
16261
+ direction: sortDirection ?? ORDER_DIRECTIONS.ASC
16262
+ };
16263
+ }
16264
+ const configuredDefaultSortBy = normalizeSortBy(defaultSortBy);
16265
+ if (configuredDefaultSortBy) {
16266
+ return {
16267
+ term: resolveSortTermFromAllowed(configuredDefaultSortBy, allowedSortColumns),
16268
+ direction: defaultSortDirection ?? ORDER_DIRECTIONS.ASC
16269
+ };
16270
+ }
16271
+ const detectedPkName = detectPrimaryKeyName(table);
16272
+ if (detectedPkName) {
16273
+ const detectedPkColumn = getColumnByName(table, detectedPkName);
16274
+ if (detectedPkColumn) {
16275
+ return {
16276
+ term: detectedPkColumn,
16277
+ direction: ORDER_DIRECTIONS.ASC
16278
+ };
16279
+ }
16280
+ }
16281
+ const idColumn = getColumnByName(table, "id");
16282
+ return {
16283
+ term: idColumn ?? toColumnNode(table, "id"),
16284
+ direction: ORDER_DIRECTIONS.ASC
16285
+ };
16286
+ };
16287
+ var resolveTieBreaker = (table, tieBreakerColumn) => {
16288
+ const configuredName = normalizeSortBy(tieBreakerColumn);
16289
+ if (configuredName) {
16290
+ const configuredColumn = getColumnByName(table, configuredName);
16291
+ if (!configuredColumn) {
16292
+ throw new Error(
16293
+ `Invalid tieBreakerColumn "${configuredName}" for table "${table.name}".`
16294
+ );
16295
+ }
16296
+ return configuredColumn;
16297
+ }
16298
+ const idColumn = getColumnByName(table, "id");
16299
+ if (idColumn) {
16300
+ return idColumn;
16301
+ }
16302
+ const detectedPkName = detectPrimaryKeyName(table);
16303
+ if (detectedPkName) {
16304
+ const detectedPkColumn = getColumnByName(table, detectedPkName);
16305
+ if (detectedPkColumn) {
16306
+ return detectedPkColumn;
16307
+ }
16308
+ }
16309
+ return toColumnNode(table, "id");
16310
+ };
16311
+ var extractSortColumnName = (term) => {
16312
+ if (typeof term === "object" && term !== null && "name" in term && typeof term.name === "string") {
16313
+ return term.name;
16314
+ }
16315
+ return void 0;
16316
+ };
16317
+ var executeFilteredPaged = async (options) => {
16318
+ const table = options.qb.getTable();
16319
+ const primarySort = resolvePrimarySort(
16320
+ table,
16321
+ options.sortBy,
16322
+ options.sortDirection,
16323
+ options.allowedSortColumns,
16324
+ options.defaultSortBy,
16325
+ options.defaultSortDirection
16326
+ );
16327
+ const tieBreaker = resolveTieBreaker(table, options.tieBreakerColumn);
16328
+ let qb = applyFilter(
16329
+ options.qb,
16330
+ options.tableOrEntity,
16331
+ options.filters
16332
+ );
16333
+ qb = qb.orderBy(primarySort.term, primarySort.direction);
16334
+ const primarySortColumnName = extractSortColumnName(primarySort.term);
16335
+ const tieBreakerColumnName = extractSortColumnName(tieBreaker);
16336
+ if (primarySortColumnName !== tieBreakerColumnName) {
16337
+ qb = qb.orderBy(tieBreaker, ORDER_DIRECTIONS.ASC);
16338
+ }
16339
+ const result = await qb.executePaged(options.session, {
16340
+ page: options.page,
16341
+ pageSize: options.pageSize
16342
+ });
16343
+ return toPagedResponse(result);
16344
+ };
16345
+
16211
16346
  // src/dto/openapi/type-mappings.ts
16212
16347
  var IntegerTypeStrategy = class {
16213
16348
  types = ["INT", "INTEGER"];
@@ -18733,6 +18868,11 @@ var KeyvCacheAdapter = class {
18733
18868
  this.keyv = keyv;
18734
18869
  }
18735
18870
  name = "keyv";
18871
+ capabilities = {
18872
+ tags: false,
18873
+ prefix: true,
18874
+ ttl: true
18875
+ };
18736
18876
  async get(key) {
18737
18877
  return this.keyv.get(key);
18738
18878
  }
@@ -18776,6 +18916,152 @@ var KeyvCacheAdapter = class {
18776
18916
  }
18777
18917
  };
18778
18918
 
18919
+ // src/cache/adapters/redis-cache-adapter.ts
18920
+ var RedisCacheAdapter = class {
18921
+ name = "redis";
18922
+ capabilities = {
18923
+ tags: true,
18924
+ prefix: true,
18925
+ ttl: true
18926
+ };
18927
+ redis;
18928
+ ownsConnection;
18929
+ tagPrefix;
18930
+ /**
18931
+ * Cria um adapter Redis
18932
+ *
18933
+ * @param redis - Instância do ioredis OU opções de conexão
18934
+ * @param options - Opções adicionais
18935
+ * @param options.tagPrefix - Prefixo para chaves de tag (default: 'tag:')
18936
+ *
18937
+ * Exemplos:
18938
+ *
18939
+ * // Com instância existente (recomendado para connection pooling):
18940
+ * const redis = new Redis({ host: 'localhost', port: 6379 });
18941
+ * const adapter = new RedisCacheAdapter(redis);
18942
+ *
18943
+ * // Com opções (adapter gerencia conexão):
18944
+ * const adapter = new RedisCacheAdapter({ host: 'localhost', port: 6379 });
18945
+ *
18946
+ * // Para testes com ioredis-mock:
18947
+ * import Redis from 'ioredis-mock';
18948
+ * const adapter = new RedisCacheAdapter(new Redis());
18949
+ */
18950
+ constructor(redis, options) {
18951
+ this.tagPrefix = options?.tagPrefix ?? "tag:";
18952
+ if (this.isRedisInstance(redis)) {
18953
+ this.redis = redis;
18954
+ this.ownsConnection = false;
18955
+ } else {
18956
+ this.redis = this.createRedis(redis);
18957
+ this.ownsConnection = true;
18958
+ }
18959
+ }
18960
+ isRedisInstance(obj) {
18961
+ return typeof obj === "object" && obj !== null && "get" in obj && "set" in obj && "del" in obj && typeof obj.get === "function";
18962
+ }
18963
+ createRedis(options) {
18964
+ try {
18965
+ const Redis = require("ioredis");
18966
+ return new Redis(options);
18967
+ } catch {
18968
+ throw new Error(
18969
+ "ioredis is required for RedisCacheAdapter. Install it with: npm install ioredis"
18970
+ );
18971
+ }
18972
+ }
18973
+ async get(key) {
18974
+ const value = await this.redis.get(key);
18975
+ if (value === null) {
18976
+ return void 0;
18977
+ }
18978
+ try {
18979
+ return JSON.parse(value);
18980
+ } catch {
18981
+ return void 0;
18982
+ }
18983
+ }
18984
+ async has(key) {
18985
+ const value = await this.redis.get(key);
18986
+ return value !== null;
18987
+ }
18988
+ async set(key, value, ttlMs, tags) {
18989
+ const serialized = JSON.stringify(value);
18990
+ if (ttlMs) {
18991
+ await this.redis.set(key, serialized, "PX", ttlMs);
18992
+ } else {
18993
+ await this.redis.set(key, serialized);
18994
+ }
18995
+ if (tags && tags.length > 0) {
18996
+ await this.registerTags(key, tags);
18997
+ }
18998
+ }
18999
+ async delete(key) {
19000
+ await this.redis.del(key);
19001
+ }
19002
+ async invalidate(key) {
19003
+ await this.delete(key);
19004
+ }
19005
+ async invalidateTags(tags) {
19006
+ const keysToDelete = /* @__PURE__ */ new Set();
19007
+ for (const tag of tags) {
19008
+ const tagKey = `${this.tagPrefix}${tag}`;
19009
+ const keys = await this.redis.smembers(tagKey);
19010
+ for (const key of keys) {
19011
+ keysToDelete.add(key);
19012
+ }
19013
+ await this.redis.del(tagKey);
19014
+ }
19015
+ if (keysToDelete.size > 0) {
19016
+ await this.redis.del(...Array.from(keysToDelete));
19017
+ }
19018
+ }
19019
+ async invalidatePrefix(prefix) {
19020
+ const keysToDelete = [];
19021
+ let cursor = "0";
19022
+ do {
19023
+ const [nextCursor, keys] = await this.redis.scan(
19024
+ cursor,
19025
+ "MATCH",
19026
+ `${prefix}*`,
19027
+ "COUNT",
19028
+ 100
19029
+ );
19030
+ cursor = nextCursor;
19031
+ keysToDelete.push(...keys);
19032
+ } while (cursor !== "0");
19033
+ if (keysToDelete.length > 0) {
19034
+ const batchSize = 1e3;
19035
+ for (let i = 0; i < keysToDelete.length; i += batchSize) {
19036
+ const batch = keysToDelete.slice(i, i + batchSize);
19037
+ await this.redis.del(...batch);
19038
+ }
19039
+ }
19040
+ }
19041
+ async registerTags(key, tags) {
19042
+ for (const tag of tags) {
19043
+ const tagKey = `${this.tagPrefix}${tag}`;
19044
+ await this.redis.sadd(tagKey, key);
19045
+ }
19046
+ }
19047
+ async dispose() {
19048
+ if (this.ownsConnection) {
19049
+ try {
19050
+ await this.redis.quit();
19051
+ } catch {
19052
+ this.redis.disconnect?.();
19053
+ }
19054
+ }
19055
+ }
19056
+ /**
19057
+ * Retorna a instância Redis subjacente
19058
+ * Útil para operações avançadas ou health checks
19059
+ */
19060
+ getRedis() {
19061
+ return this.redis;
19062
+ }
19063
+ };
19064
+
18779
19065
  // src/cache/tag-index.ts
18780
19066
  var TagIndex = class {
18781
19067
  tagToKeys = /* @__PURE__ */ new Map();
@@ -18930,6 +19216,7 @@ var TagIndex = class {
18930
19216
  PrimaryKey,
18931
19217
  PrototypeMaterializationStrategy,
18932
19218
  QueryCacheManager,
19219
+ RedisCacheAdapter,
18933
19220
  RelationKinds,
18934
19221
  STANDARD_COLUMN_TYPES,
18935
19222
  SelectQueryBuilder,
@@ -19043,6 +19330,7 @@ var TagIndex = class {
19043
19330
  eq,
19044
19331
  esel,
19045
19332
  exclude,
19333
+ executeFilteredPaged,
19046
19334
  executeHydrated,
19047
19335
  executeHydratedPlain,
19048
19336
  executeHydratedPlainWithContexts,