metal-orm 1.1.2 → 1.1.4

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.
Files changed (42) hide show
  1. package/README.md +728 -707
  2. package/dist/index.cjs +813 -75
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +277 -8
  5. package/dist/index.d.ts +277 -8
  6. package/dist/index.js +812 -75
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -2
  9. package/scripts/naming-strategy.mjs +16 -1
  10. package/src/cache/adapters/keyv-cache-adapter.ts +5 -0
  11. package/src/cache/adapters/memory-cache-adapter.ts +5 -0
  12. package/src/cache/adapters/redis-cache-adapter.ts +233 -0
  13. package/src/cache/cache-interfaces.ts +11 -0
  14. package/src/cache/index.ts +2 -0
  15. package/src/core/ast/procedure.ts +21 -0
  16. package/src/core/ast/query.ts +47 -19
  17. package/src/core/ddl/introspect/utils.ts +56 -56
  18. package/src/core/dialect/abstract.ts +560 -547
  19. package/src/core/dialect/base/sql-dialect.ts +43 -29
  20. package/src/core/dialect/mssql/index.ts +369 -232
  21. package/src/core/dialect/mysql/index.ts +99 -7
  22. package/src/core/dialect/postgres/index.ts +121 -60
  23. package/src/core/dialect/sqlite/index.ts +97 -64
  24. package/src/core/execution/db-executor.ts +108 -90
  25. package/src/core/execution/executors/mssql-executor.ts +28 -24
  26. package/src/core/execution/executors/mysql-executor.ts +62 -27
  27. package/src/core/execution/executors/sqlite-executor.ts +10 -9
  28. package/src/index.ts +9 -6
  29. package/src/orm/execute-procedure.ts +77 -0
  30. package/src/orm/execute.ts +74 -73
  31. package/src/orm/interceptor-pipeline.ts +21 -17
  32. package/src/orm/pooled-executor-factory.ts +41 -20
  33. package/src/orm/unit-of-work.ts +6 -4
  34. package/src/query/index.ts +8 -5
  35. package/src/query-builder/delete.ts +3 -2
  36. package/src/query-builder/insert-query-state.ts +47 -19
  37. package/src/query-builder/insert.ts +142 -28
  38. package/src/query-builder/procedure-call.ts +122 -0
  39. package/src/query-builder/select/select-operations.ts +5 -2
  40. package/src/query-builder/select.ts +1146 -1105
  41. package/src/query-builder/update.ts +3 -2
  42. package/src/tree/tree-manager.ts +754 -754
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "engines": {
@@ -44,7 +44,8 @@
44
44
  "pg": "^8.0.0",
45
45
  "sqlite3": "^5.1.6",
46
46
  "tedious": "^19.0.0",
47
- "keyv": "^5.6.0"
47
+ "keyv": "^5.6.0",
48
+ "ioredis": "^5.0.0"
48
49
  },
49
50
  "peerDependenciesMeta": {
50
51
  "mysql2": {
@@ -61,6 +62,9 @@
61
62
  },
62
63
  "keyv": {
63
64
  "optional": true
65
+ },
66
+ "ioredis": {
67
+ "optional": true
64
68
  }
65
69
  },
66
70
  "devDependencies": {
@@ -70,6 +74,8 @@
70
74
  "@vitest/ui": "^4.0.18",
71
75
  "eslint": "^9.39.2",
72
76
  "express": "^5.2.1",
77
+ "ioredis": "^5.6.1",
78
+ "ioredis-mock": "^8.9.0",
73
79
  "keyv": "^5.6.0",
74
80
  "mysql-memory-server": "^1.14.0",
75
81
  "mysql2": "^3.16.2",
@@ -95,10 +95,25 @@ export class BaseNamingStrategy {
95
95
 
96
96
  belongsToProperty(foreignKeyName, targetTable) {
97
97
  const trimmed = foreignKeyName.replace(/_?id$/i, '');
98
- const base = trimmed && trimmed !== foreignKeyName ? trimmed : this.singularize(targetTable);
98
+ const targetBase = this.singularize(this.normalizeTableName(targetTable));
99
+ // If FK name ends with _id, use the trimmed version
100
+ // If FK name doesn't end with _id but is different from target table name, use the FK name (e.g., "criador", "responsavel_judicial")
101
+ // Otherwise fallback to target table name
102
+ const base = trimmed && trimmed !== foreignKeyName
103
+ ? trimmed
104
+ : trimmed && this.toCamelCase(trimmed) !== this.toCamelCase(targetBase)
105
+ ? trimmed
106
+ : targetBase;
99
107
  return this.toCamelCase(base);
100
108
  }
101
109
 
110
+ normalizeTableName(tableName) {
111
+ // Strip schema prefix if present (e.g., "dbo.usuario" -> "usuario")
112
+ return typeof tableName === 'string' && tableName.includes('.')
113
+ ? tableName.split('.').pop()
114
+ : tableName;
115
+ }
116
+
102
117
  hasManyProperty(targetTable) {
103
118
  const base = this.singularize(targetTable);
104
119
  const plural = this.inflector.pluralizeRelationProperty
@@ -19,6 +19,11 @@ interface KeyvInstance {
19
19
  */
20
20
  export class KeyvCacheAdapter implements CacheProvider {
21
21
  readonly name = 'keyv';
22
+ readonly capabilities = {
23
+ tags: false,
24
+ prefix: true,
25
+ ttl: true,
26
+ };
22
27
 
23
28
  constructor(private keyv: KeyvInstance) {}
24
29
 
@@ -11,6 +11,11 @@ interface CacheEntry<T> {
11
11
  */
12
12
  export class MemoryCacheAdapter implements CacheProvider {
13
13
  readonly name = 'memory';
14
+ readonly capabilities = {
15
+ tags: true,
16
+ prefix: true,
17
+ ttl: true,
18
+ };
14
19
  private storage: Map<string, CacheEntry<unknown>> = new Map();
15
20
  private tagIndex: Map<string, Set<string>> = new Map();
16
21
 
@@ -0,0 +1,233 @@
1
+ import type { CacheProvider } from '../cache-interfaces.js';
2
+
3
+ // Tipos mínimos para ioredis (para evitar dependência obrigatória)
4
+ interface RedisLike {
5
+ get(key: string): Promise<string | null>;
6
+ set(key: string, value: string, ...args: (string | number)[]): Promise<string | null>;
7
+ del(...keys: string[]): Promise<number>;
8
+ sadd(key: string, ...members: string[]): Promise<number>;
9
+ smembers(key: string): Promise<string[]>;
10
+ srem(key: string, ...members: string[]): Promise<number>;
11
+ scan(cursor: string | number, ...args: (string | number)[]): Promise<[string, string[]]>;
12
+ quit(): Promise<string>;
13
+ disconnect(): void;
14
+ isReady?: boolean;
15
+ status?: string;
16
+ }
17
+
18
+ interface RedisOptions {
19
+ host?: string;
20
+ port?: number;
21
+ password?: string;
22
+ db?: number;
23
+ keyPrefix?: string;
24
+ lazyConnect?: boolean;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /**
29
+ * Adapter para Redis usando ioredis
30
+ *
31
+ * Suporta:
32
+ * - Tags via Redis Sets (SADD, SMEMBERS, SREM)
33
+ * - Prefix invalidation via SCAN
34
+ * - TTL nativo do Redis
35
+ *
36
+ * Instalação:
37
+ * npm install ioredis
38
+ *
39
+ * Para testes (dev):
40
+ * npm install --save-dev ioredis-mock
41
+ */
42
+ export class RedisCacheAdapter implements CacheProvider {
43
+ readonly name = 'redis';
44
+ readonly capabilities = {
45
+ tags: true,
46
+ prefix: true,
47
+ ttl: true,
48
+ };
49
+
50
+ private redis: RedisLike;
51
+ private ownsConnection: boolean;
52
+ private tagPrefix: string;
53
+
54
+ /**
55
+ * Cria um adapter Redis
56
+ *
57
+ * @param redis - Instância do ioredis OU opções de conexão
58
+ * @param options - Opções adicionais
59
+ * @param options.tagPrefix - Prefixo para chaves de tag (default: 'tag:')
60
+ *
61
+ * Exemplos:
62
+ *
63
+ * // Com instância existente (recomendado para connection pooling):
64
+ * const redis = new Redis({ host: 'localhost', port: 6379 });
65
+ * const adapter = new RedisCacheAdapter(redis);
66
+ *
67
+ * // Com opções (adapter gerencia conexão):
68
+ * const adapter = new RedisCacheAdapter({ host: 'localhost', port: 6379 });
69
+ *
70
+ * // Para testes com ioredis-mock:
71
+ * import Redis from 'ioredis-mock';
72
+ * const adapter = new RedisCacheAdapter(new Redis());
73
+ */
74
+ constructor(
75
+ redis: RedisLike | RedisOptions,
76
+ options?: { tagPrefix?: string }
77
+ ) {
78
+ this.tagPrefix = options?.tagPrefix ?? 'tag:';
79
+
80
+ if (this.isRedisInstance(redis)) {
81
+ // Recebeu uma instância existente
82
+ this.redis = redis;
83
+ this.ownsConnection = false;
84
+ } else {
85
+ // Recebeu opções, precisa criar a conexão
86
+ this.redis = this.createRedis(redis);
87
+ this.ownsConnection = true;
88
+ }
89
+ }
90
+
91
+ private isRedisInstance(obj: unknown): obj is RedisLike {
92
+ return (
93
+ typeof obj === 'object' &&
94
+ obj !== null &&
95
+ 'get' in obj &&
96
+ 'set' in obj &&
97
+ 'del' in obj &&
98
+ typeof (obj as RedisLike).get === 'function'
99
+ );
100
+ }
101
+
102
+ private createRedis(options: RedisOptions): RedisLike {
103
+ // Dynamic import para evitar dependência obrigatória
104
+ try {
105
+ const Redis = require('ioredis');
106
+ return new Redis(options) as RedisLike;
107
+ } catch {
108
+ throw new Error(
109
+ 'ioredis is required for RedisCacheAdapter. ' +
110
+ 'Install it with: npm install ioredis'
111
+ );
112
+ }
113
+ }
114
+
115
+ async get<T>(key: string): Promise<T | undefined> {
116
+ const value = await this.redis.get(key);
117
+ if (value === null) {
118
+ return undefined;
119
+ }
120
+ try {
121
+ return JSON.parse(value) as T;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ async has(key: string): Promise<boolean> {
128
+ const value = await this.redis.get(key);
129
+ return value !== null;
130
+ }
131
+
132
+ async set<T>(
133
+ key: string,
134
+ value: T,
135
+ ttlMs?: number,
136
+ tags?: string[]
137
+ ): Promise<void> {
138
+ const serialized = JSON.stringify(value);
139
+
140
+ if (ttlMs) {
141
+ // EX = seconds, PX = milliseconds
142
+ await this.redis.set(key, serialized, 'PX', ttlMs);
143
+ } else {
144
+ await this.redis.set(key, serialized);
145
+ }
146
+
147
+ // Registra tags se fornecidas
148
+ if (tags && tags.length > 0) {
149
+ await this.registerTags(key, tags);
150
+ }
151
+ }
152
+
153
+ async delete(key: string): Promise<void> {
154
+ await this.redis.del(key);
155
+ }
156
+
157
+ async invalidate(key: string): Promise<void> {
158
+ await this.delete(key);
159
+ }
160
+
161
+ async invalidateTags(tags: string[]): Promise<void> {
162
+ const keysToDelete = new Set<string>();
163
+
164
+ for (const tag of tags) {
165
+ const tagKey = `${this.tagPrefix}${tag}`;
166
+ const keys = await this.redis.smembers(tagKey);
167
+
168
+ for (const key of keys) {
169
+ keysToDelete.add(key);
170
+ }
171
+
172
+ // Deleta o set da tag
173
+ await this.redis.del(tagKey);
174
+ }
175
+
176
+ // Deleta todas as chaves associadas
177
+ if (keysToDelete.size > 0) {
178
+ await this.redis.del(...Array.from(keysToDelete));
179
+ }
180
+ }
181
+
182
+ async invalidatePrefix(prefix: string): Promise<void> {
183
+ const keysToDelete: string[] = [];
184
+ let cursor = '0';
185
+
186
+ do {
187
+ const [nextCursor, keys] = await this.redis.scan(
188
+ cursor,
189
+ 'MATCH',
190
+ `${prefix}*`,
191
+ 'COUNT',
192
+ 100
193
+ );
194
+ cursor = nextCursor;
195
+ keysToDelete.push(...keys);
196
+ } while (cursor !== '0');
197
+
198
+ if (keysToDelete.length > 0) {
199
+ // Deleta em batches de 1000 para evitar bloqueio
200
+ const batchSize = 1000;
201
+ for (let i = 0; i < keysToDelete.length; i += batchSize) {
202
+ const batch = keysToDelete.slice(i, i + batchSize);
203
+ await this.redis.del(...batch);
204
+ }
205
+ }
206
+ }
207
+
208
+ private async registerTags(key: string, tags: string[]): Promise<void> {
209
+ for (const tag of tags) {
210
+ const tagKey = `${this.tagPrefix}${tag}`;
211
+ await this.redis.sadd(tagKey, key);
212
+ }
213
+ }
214
+
215
+ async dispose(): Promise<void> {
216
+ if (this.ownsConnection) {
217
+ try {
218
+ await this.redis.quit();
219
+ } catch {
220
+ // Se quit falhar, tenta disconnect
221
+ this.redis.disconnect?.();
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Retorna a instância Redis subjacente
228
+ * Útil para operações avançadas ou health checks
229
+ */
230
+ getRedis(): RedisLike {
231
+ return this.redis;
232
+ }
233
+ }
@@ -27,11 +27,22 @@ export interface CacheInvalidator {
27
27
  invalidatePrefix(prefix: string): Promise<void>;
28
28
  }
29
29
 
30
+ /**
31
+ * Capabilities de um cache provider
32
+ * Permite detectar funcionalidades suportadas em runtime
33
+ */
34
+ export interface CacheCapabilities {
35
+ tags: boolean;
36
+ prefix: boolean;
37
+ ttl: boolean;
38
+ }
39
+
30
40
  /**
31
41
  * Interface completa para implementações full-featured
32
42
  */
33
43
  export interface CacheProvider extends CacheReader, CacheWriter, CacheInvalidator {
34
44
  readonly name: string;
45
+ readonly capabilities: CacheCapabilities;
35
46
  dispose?(): Promise<void>;
36
47
  }
37
48
 
@@ -4,6 +4,7 @@ export type {
4
4
  CacheWriter,
5
5
  CacheInvalidator,
6
6
  CacheProvider,
7
+ CacheCapabilities,
7
8
  Duration,
8
9
  CacheOptions,
9
10
  InvalidationStrategy,
@@ -20,6 +21,7 @@ export { DefaultCacheStrategy } from './strategies/default-cache-strategy.js';
20
21
  // Adapters
21
22
  export { MemoryCacheAdapter } from './adapters/memory-cache-adapter.js';
22
23
  export { KeyvCacheAdapter } from './adapters/keyv-cache-adapter.js';
24
+ export { RedisCacheAdapter } from './adapters/redis-cache-adapter.js';
23
25
 
24
26
  // Manager
25
27
  export { QueryCacheManager } from './query-cache-manager.js';
@@ -0,0 +1,21 @@
1
+ import type { OperandNode } from './expression.js';
2
+
3
+ export type ProcedureDirection = 'in' | 'out' | 'inout';
4
+
5
+ export interface ProcedureRefNode {
6
+ name: string;
7
+ schema?: string;
8
+ }
9
+
10
+ export interface ProcedureParamNode {
11
+ name: string;
12
+ direction: ProcedureDirection;
13
+ value?: OperandNode;
14
+ dbType?: string;
15
+ }
16
+
17
+ export interface ProcedureCallNode {
18
+ type: 'ProcedureCall';
19
+ ref: ProcedureRefNode;
20
+ params: ProcedureParamNode[];
21
+ }
@@ -161,25 +161,53 @@ export interface InsertValuesSourceNode {
161
161
  rows: OperandNode[][];
162
162
  }
163
163
 
164
- export interface InsertSelectSourceNode {
165
- type: 'InsertSelect';
166
- /** SELECT query providing rows */
167
- query: SelectQueryNode;
168
- }
169
-
170
- export type InsertSourceNode = InsertValuesSourceNode | InsertSelectSourceNode;
171
-
172
- export interface InsertQueryNode {
173
- type: 'InsertQuery';
174
- /** Target table */
175
- into: TableNode;
176
- /** Column order for inserted values */
177
- columns: ColumnNode[];
178
- /** Source of inserted rows (either literal values or a SELECT query) */
179
- source: InsertSourceNode;
180
- /** Optional RETURNING clause */
181
- returning?: ColumnNode[];
182
- }
164
+ export interface InsertSelectSourceNode {
165
+ type: 'InsertSelect';
166
+ /** SELECT query providing rows */
167
+ query: SelectQueryNode;
168
+ }
169
+
170
+ export type InsertSourceNode = InsertValuesSourceNode | InsertSelectSourceNode;
171
+
172
+ export interface UpsertConflictTarget {
173
+ /** Conflict columns (primary key or unique columns) */
174
+ columns: ColumnNode[];
175
+ /** Named constraint (PostgreSQL only) */
176
+ constraint?: string;
177
+ }
178
+
179
+ export interface UpsertUpdateAction {
180
+ type: 'DoUpdate';
181
+ /** Assignments to apply on conflict */
182
+ set: UpdateAssignmentNode[];
183
+ /** Optional condition for the update branch */
184
+ where?: ExpressionNode;
185
+ }
186
+
187
+ export interface UpsertDoNothingAction {
188
+ type: 'DoNothing';
189
+ }
190
+
191
+ export type UpsertAction = UpsertUpdateAction | UpsertDoNothingAction;
192
+
193
+ export interface UpsertClause {
194
+ target: UpsertConflictTarget;
195
+ action: UpsertAction;
196
+ }
197
+
198
+ export interface InsertQueryNode {
199
+ type: 'InsertQuery';
200
+ /** Target table */
201
+ into: TableNode;
202
+ /** Column order for inserted values */
203
+ columns: ColumnNode[];
204
+ /** Source of inserted rows (either literal values or a SELECT query) */
205
+ source: InsertSourceNode;
206
+ /** Optional dialect-specific UPSERT clause */
207
+ onConflict?: UpsertClause;
208
+ /** Optional RETURNING clause */
209
+ returning?: ColumnNode[];
210
+ }
183
211
 
184
212
  export interface UpdateAssignmentNode {
185
213
  /** Column to update */
@@ -1,56 +1,56 @@
1
- import { DbExecutor, QueryResult } from '../../execution/db-executor.js';
2
- import { IntrospectOptions } from './types.js';
3
-
4
- /**
5
- * Converts a query result to an array of row objects.
6
- * @param result - The query result.
7
- * @returns The array of rows.
8
- */
9
- export const toRows = (result: QueryResult | undefined): Record<string, unknown>[] => {
10
- if (!result) return [];
11
- return result.values.map(row =>
12
- result.columns.reduce<Record<string, unknown>>((acc, col, idx) => {
13
- acc[col] = row[idx];
14
- return acc;
15
- }, {})
16
- );
17
- };
18
-
19
- /**
20
- * Executes a SQL query and returns the rows.
21
- * @param executor - The database executor.
22
- * @param sql - The SQL query.
23
- * @param params - The query parameters.
24
- * @returns The array of rows.
25
- */
26
- export const queryRows = async (
27
- executor: DbExecutor,
28
- sql: string,
29
- params: unknown[] = []
30
- ): Promise<Record<string, unknown>[]> => {
31
- const [first] = await executor.executeSql(sql, params);
32
- return toRows(first);
33
- };
34
-
35
- /**
36
- * Checks if a table should be included in introspection based on options.
37
- * @param name - The table name.
38
- * @param options - The introspection options.
39
- * @returns True if the table should be included.
40
- */
41
- export const shouldIncludeTable = (name: string, options: IntrospectOptions): boolean => {
42
- if (options.includeTables && !options.includeTables.includes(name)) return false;
43
- if (options.excludeTables && options.excludeTables.includes(name)) return false;
44
- return true;
45
- };
46
-
47
- /**
48
- * Checks if a view should be included in introspection based on options.
49
- * @param name - The view name.
50
- * @param options - The introspection options.
51
- * @returns True if the view should be included.
52
- */
53
- export const shouldIncludeView = (name: string, options: IntrospectOptions): boolean => {
54
- if (options.excludeViews && options.excludeViews.includes(name)) return false;
55
- return true;
56
- };
1
+ import { DbExecutor, QueryResult } from '../../execution/db-executor.js';
2
+ import { IntrospectOptions } from './types.js';
3
+
4
+ /**
5
+ * Converts a query result to an array of row objects.
6
+ * @param result - The query result.
7
+ * @returns The array of rows.
8
+ */
9
+ export const toRows = (result: QueryResult | undefined): Record<string, unknown>[] => {
10
+ if (!result) return [];
11
+ return result.values.map(row =>
12
+ result.columns.reduce<Record<string, unknown>>((acc, col, idx) => {
13
+ acc[col] = row[idx];
14
+ return acc;
15
+ }, {})
16
+ );
17
+ };
18
+
19
+ /**
20
+ * Executes a SQL query and returns the rows.
21
+ * @param executor - The database executor.
22
+ * @param sql - The SQL query.
23
+ * @param params - The query parameters.
24
+ * @returns The array of rows.
25
+ */
26
+ export const queryRows = async (
27
+ executor: DbExecutor,
28
+ sql: string,
29
+ params: unknown[] = []
30
+ ): Promise<Record<string, unknown>[]> => {
31
+ const [first] = await executor.executeSql(sql, params);
32
+ return toRows(first);
33
+ };
34
+
35
+ /**
36
+ * Checks if a table should be included in introspection based on options.
37
+ * @param name - The table name.
38
+ * @param options - The introspection options.
39
+ * @returns True if the table should be included.
40
+ */
41
+ export const shouldIncludeTable = (name: string, options: IntrospectOptions): boolean => {
42
+ if (options.includeTables && !options.includeTables.includes(name)) return false;
43
+ if (options.excludeTables && options.excludeTables.includes(name)) return false;
44
+ return true;
45
+ };
46
+
47
+ /**
48
+ * Checks if a view should be included in introspection based on options.
49
+ * @param name - The view name.
50
+ * @param options - The introspection options.
51
+ * @returns True if the view should be included.
52
+ */
53
+ export const shouldIncludeView = (name: string, options: IntrospectOptions): boolean => {
54
+ if (options.excludeViews && options.excludeViews.includes(name)) return false;
55
+ return true;
56
+ };