sqlite-zod-orm 3.0.0 → 3.2.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.
@@ -1,49 +1,47 @@
1
- import { z } from 'zod';
2
-
3
- // ---------- SQL Identifier Quoting ----------
4
-
5
- /** Quote an identifier (table name, alias, column) to handle reserved words. */
6
- function q(name: string): string {
7
- return `"${name}"`;
8
- }
1
+ /**
2
+ * proxy-query.ts — Proxy-based SQL query builder
3
+ *
4
+ * Enables db.query(c => { const { users: u, posts: p } = c; ... }) syntax.
5
+ * Table/column access through the proxy creates ColumnNode AST nodes,
6
+ * which are then compiled into parameterized SQL.
7
+ */
9
8
 
10
- /** Quote a fully qualified alias.column reference. */
11
- function qRef(alias: string, column: string): string {
12
- return `${q(alias)}.${q(column)}`;
13
- }
9
+ import { z } from 'zod';
14
10
 
15
- // ---------- AST Node Types ----------
11
+ // ---------- Column Node ----------
16
12
 
17
- /** Represents a column reference in the query AST. */
13
+ /**
14
+ * Represents a reference to a specific table column with an alias.
15
+ * Used as a building block for SQL query construction.
16
+ */
18
17
  export class ColumnNode {
19
18
  readonly _type = 'COL' as const;
20
- readonly table: string;
21
- readonly column: string;
22
- readonly alias: string;
23
-
24
- constructor(table: string, column: string, alias: string) {
25
- this.table = table;
26
- this.column = column;
27
- this.alias = alias;
28
- }
19
+ constructor(
20
+ readonly table: string,
21
+ readonly column: string,
22
+ readonly alias: string,
23
+ ) { }
29
24
 
30
- /**
31
- * When used as object key via `[t.id]`, JS calls toString().
32
- * This returns the qualified column name for the ORM to parse.
33
- */
25
+ /** Quoted alias.column for use as computed property key */
34
26
  toString(): string {
35
- return `${q(this.alias)}.${q(this.column)}`;
27
+ return `"${this.alias}"."${this.column}"`;
36
28
  }
37
29
 
38
- /** Also override valueOf for numeric contexts. */
39
- valueOf(): string {
30
+ [Symbol.toPrimitive](): string {
40
31
  return this.toString();
41
32
  }
33
+ }
42
34
 
43
- /** Convenience for Symbol.toPrimitive */
44
- [Symbol.toPrimitive](hint: string): string {
45
- return this.toString();
46
- }
35
+ // ---------- SQL Quoting Helpers ----------
36
+
37
+ /** Quote an identifier with double quotes */
38
+ function q(name: string): string {
39
+ return `"${name}"`;
40
+ }
41
+
42
+ /** Quote a fully qualified reference: alias.column */
43
+ function qRef(alias: string, column: string): string {
44
+ return `"${alias}"."${column}"`;
47
45
  }
48
46
 
49
47
  // ---------- Table Proxy ----------
@@ -62,7 +60,6 @@ function createTableProxy(
62
60
  if (prop === Symbol.toPrimitive as any || prop === 'toString' || prop === 'valueOf') {
63
61
  return undefined;
64
62
  }
65
- // Allow any property access — the column may be inferred or wildcard
66
63
  return new ColumnNode(tableName, prop, alias);
67
64
  },
68
65
  ownKeys() {
@@ -100,9 +97,10 @@ export function createContextProxy(
100
97
  if (typeof tableName !== 'string') return undefined;
101
98
 
102
99
  const schema = schemas[tableName];
103
- const columns = schema
104
- ? new Set(Object.keys((schema as unknown as z.ZodObject<any>).shape))
105
- : new Set<string>();
100
+ const shape = schema
101
+ ? (schema as unknown as z.ZodObject<any>).shape
102
+ : {};
103
+ const columns = new Set(Object.keys(shape));
106
104
 
107
105
  aliasCounter++;
108
106
  const alias = `t${aliasCounter}`;
@@ -122,14 +120,16 @@ export function createContextProxy(
122
120
 
123
121
  // ---------- Query Result Shape ----------
124
122
 
123
+ type AnyColumn = ColumnNode | (ColumnNode & string);
124
+
125
125
  export interface ProxyQueryResult {
126
- select: Record<string, ColumnNode>;
127
- join?: [ColumnNode, ColumnNode] | [ColumnNode, ColumnNode][];
126
+ select: Record<string, AnyColumn | undefined>;
127
+ join?: [AnyColumn | undefined, AnyColumn | undefined] | [AnyColumn | undefined, AnyColumn | undefined][];
128
128
  where?: Record<string, any>;
129
129
  orderBy?: Record<string, 'asc' | 'desc'>;
130
130
  limit?: number;
131
131
  offset?: number;
132
- groupBy?: ColumnNode[];
132
+ groupBy?: (AnyColumn | undefined)[];
133
133
  }
134
134
 
135
135
  // ---------- Query Compiler ----------
@@ -173,7 +173,6 @@ export function compileProxyQuery(
173
173
  }
174
174
 
175
175
  // ---------- FROM / JOIN ----------
176
- // First table in aliases is the primary; rest are joined
177
176
  const allAliases = [...tablesUsed.values()];
178
177
  if (allAliases.length === 0) throw new Error('No tables referenced in query.');
179
178
 
@@ -187,13 +186,11 @@ export function compileProxyQuery(
187
186
  : [queryResult.join as [ColumnNode, ColumnNode]];
188
187
 
189
188
  for (const [left, right] of joins) {
190
- // Determine which side is the joined table (not the primary)
191
189
  const leftTable = tablesUsed.get(left.alias);
192
190
  const rightTable = tablesUsed.get(right.alias);
193
191
 
194
192
  if (!leftTable || !rightTable) throw new Error('Join references unknown table alias.');
195
193
 
196
- // The non-primary side needs a JOIN clause
197
194
  const joinAlias = leftTable.alias === primaryAlias.alias ? rightTable : leftTable;
198
195
 
199
196
  sql += ` JOIN ${q(joinAlias.tableName)} ${q(joinAlias.alias)} ON ${qRef(left.alias, left.column)} = ${qRef(right.alias, right.column)}`;
@@ -205,21 +202,17 @@ export function compileProxyQuery(
205
202
  const whereParts: string[] = [];
206
203
 
207
204
  for (const [key, value] of Object.entries(queryResult.where)) {
208
- // The key could be '"t1"."column"' (from toString trick) or a plain string
209
205
  let fieldRef: string;
210
206
 
211
207
  // Match quoted alias.column pattern: "alias"."column"
212
208
  const quotedMatch = key.match(/^"([^"]+)"\."([^"]+)"$/);
213
209
  if (quotedMatch && tablesUsed.has(quotedMatch[1]!)) {
214
- // Already fully quoted
215
210
  fieldRef = key;
216
211
  } else {
217
- // Plain field name — use the first table
218
212
  fieldRef = qRef(primaryAlias.alias, key);
219
213
  }
220
214
 
221
215
  if (isColumnNode(value)) {
222
- // Column-to-column comparison
223
216
  whereParts.push(`${fieldRef} = ${qRef(value.alias, value.column)}`);
224
217
  } else if (Array.isArray(value)) {
225
218
  if (value.length === 0) {
@@ -230,8 +223,18 @@ export function compileProxyQuery(
230
223
  params.push(...value);
231
224
  }
232
225
  } else if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
233
- // Operator object like { $gt: 5 }
234
226
  for (const [op, operand] of Object.entries(value)) {
227
+ if (op === '$in') {
228
+ const arr = operand as any[];
229
+ if (arr.length === 0) {
230
+ whereParts.push('1 = 0');
231
+ } else {
232
+ const placeholders = arr.map(() => '?').join(', ');
233
+ whereParts.push(`${fieldRef} IN (${placeholders})`);
234
+ params.push(...arr);
235
+ }
236
+ continue;
237
+ }
235
238
  const opMap: Record<string, string> = {
236
239
  $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=',
237
240
  };
@@ -271,7 +274,7 @@ export function compileProxyQuery(
271
274
 
272
275
  // ---------- GROUP BY ----------
273
276
  if (queryResult.groupBy && queryResult.groupBy.length > 0) {
274
- const parts = queryResult.groupBy.map(col => qRef(col.alias, col.column));
277
+ const parts = queryResult.groupBy.filter(Boolean).map(col => qRef(col!.alias, col!.column));
275
278
  sql += ` GROUP BY ${parts.join(', ')}`;
276
279
  }
277
280
 
@@ -290,7 +293,7 @@ export function compileProxyQuery(
290
293
 
291
294
  /**
292
295
  * The main `db.query(c => {...})` entry point.
293
- *
296
+ *
294
297
  * @param schemas The schema map for all registered tables.
295
298
  * @param callback The user's query callback that receives the context proxy.
296
299
  * @param executor A function that runs the compiled SQL and returns rows.
@@ -306,3 +309,4 @@ export function executeProxyQuery<T>(
306
309
  const { sql, params } = compileProxyQuery(queryResult, aliasMap);
307
310
  return executor(sql, params);
308
311
  }
312
+
@@ -15,10 +15,19 @@ interface WhereCondition {
15
15
  value: any;
16
16
  }
17
17
 
18
+ interface JoinClause {
19
+ table: string;
20
+ fromCol: string;
21
+ toCol: string;
22
+ columns: string[]; // columns to SELECT from the joined table
23
+ }
24
+
18
25
  interface IQO {
19
26
  selects: string[];
20
27
  wheres: WhereCondition[];
28
+ whereOrs: WhereCondition[][]; // Each sub-array is an OR group
21
29
  whereAST: ASTNode | null;
30
+ joins: JoinClause[];
22
31
  limit: number | null;
23
32
  offset: number | null;
24
33
  orderBy: { field: string; direction: OrderDirection }[];
@@ -49,11 +58,27 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
49
58
  const params: any[] = [];
50
59
 
51
60
  // SELECT clause
52
- const selectClause = iqo.selects.length > 0
53
- ? iqo.selects.map(s => `${tableName}.${s}`).join(', ')
54
- : `${tableName}.*`;
61
+ const selectParts: string[] = [];
62
+ if (iqo.selects.length > 0) {
63
+ selectParts.push(...iqo.selects.map(s => `${tableName}.${s}`));
64
+ } else {
65
+ selectParts.push(`${tableName}.*`);
66
+ }
67
+ // Add columns from joins
68
+ for (const j of iqo.joins) {
69
+ if (j.columns.length > 0) {
70
+ selectParts.push(...j.columns.map(c => `${j.table}.${c} AS ${j.table}_${c}`));
71
+ } else {
72
+ selectParts.push(`${j.table}.*`);
73
+ }
74
+ }
55
75
 
56
- let sql = `SELECT ${selectClause} FROM ${tableName}`;
76
+ let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
77
+
78
+ // JOIN clauses
79
+ for (const j of iqo.joins) {
80
+ sql += ` JOIN ${j.table} ON ${tableName}.${j.fromCol} = ${j.table}.${j.toCol}`;
81
+ }
57
82
 
58
83
  // WHERE clause — AST-based takes precedence if set
59
84
  if (iqo.whereAST) {
@@ -80,6 +105,31 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
80
105
  sql += ` WHERE ${whereParts.join(' AND ')}`;
81
106
  }
82
107
 
108
+ // Append OR groups (from $or)
109
+ if (iqo.whereOrs.length > 0) {
110
+ for (const orGroup of iqo.whereOrs) {
111
+ const orParts: string[] = [];
112
+ for (const w of orGroup) {
113
+ if (w.operator === 'IN') {
114
+ const arr = w.value as any[];
115
+ if (arr.length === 0) {
116
+ orParts.push('1 = 0');
117
+ } else {
118
+ orParts.push(`${w.field} IN (${arr.map(() => '?').join(', ')})`);
119
+ params.push(...arr.map(transformValueForStorage));
120
+ }
121
+ } else {
122
+ orParts.push(`${w.field} ${w.operator} ?`);
123
+ params.push(transformValueForStorage(w.value));
124
+ }
125
+ }
126
+ if (orParts.length > 0) {
127
+ const orClause = `(${orParts.join(' OR ')})`;
128
+ sql += sql.includes(' WHERE ') ? ` AND ${orClause}` : ` WHERE ${orClause}`;
129
+ }
130
+ }
131
+ }
132
+
83
133
  // ORDER BY clause
84
134
  if (iqo.orderBy.length > 0) {
85
135
  const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
@@ -115,19 +165,27 @@ export class QueryBuilder<T extends Record<string, any>> {
115
165
  private tableName: string;
116
166
  private executor: (sql: string, params: any[], raw: boolean) => any[];
117
167
  private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
168
+ private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
169
+ private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
118
170
 
119
171
  constructor(
120
172
  tableName: string,
121
173
  executor: (sql: string, params: any[], raw: boolean) => any[],
122
174
  singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
175
+ joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
176
+ conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
123
177
  ) {
124
178
  this.tableName = tableName;
125
179
  this.executor = executor;
126
180
  this.singleExecutor = singleExecutor;
181
+ this.joinResolver = joinResolver ?? null;
182
+ this.conditionResolver = conditionResolver ?? null;
127
183
  this.iqo = {
128
184
  selects: [],
129
185
  wheres: [],
186
+ whereOrs: [],
130
187
  whereAST: null,
188
+ joins: [],
131
189
  limit: null,
132
190
  offset: null,
133
191
  orderBy: [],
@@ -162,7 +220,7 @@ export class QueryBuilder<T extends Record<string, any>> {
162
220
  * ))
163
221
  * ```
164
222
  */
165
- where(criteriaOrCallback: Partial<Record<keyof T & string, any>> | WhereCallback<T>): this {
223
+ where(criteriaOrCallback: (Partial<Record<keyof T & string, any>> & { $or?: Partial<Record<keyof T & string, any>>[] }) | WhereCallback<T>): this {
166
224
  if (typeof criteriaOrCallback === 'function') {
167
225
  // Callback-style: evaluate with proxies to produce AST
168
226
  const ast = (criteriaOrCallback as WhereCallback<T>)(
@@ -177,8 +235,35 @@ export class QueryBuilder<T extends Record<string, any>> {
177
235
  this.iqo.whereAST = ast;
178
236
  }
179
237
  } else {
238
+ // Resolve entity references: { author: tolstoy } → { authorId: tolstoy.id }
239
+ const resolved = this.conditionResolver
240
+ ? this.conditionResolver(criteriaOrCallback as Record<string, any>)
241
+ : criteriaOrCallback;
242
+
180
243
  // Object-style: parse into IQO conditions
181
- for (const [key, value] of Object.entries(criteriaOrCallback)) {
244
+ for (const [key, value] of Object.entries(resolved)) {
245
+ // Handle $or: [{ field1: val1 }, { field2: val2 }]
246
+ if (key === '$or' && Array.isArray(value)) {
247
+ const orConditions: WhereCondition[] = [];
248
+ for (const branch of value as Record<string, any>[]) {
249
+ const resolvedBranch = this.conditionResolver
250
+ ? this.conditionResolver(branch)
251
+ : branch;
252
+ for (const [bKey, bValue] of Object.entries(resolvedBranch)) {
253
+ if (typeof bValue === 'object' && bValue !== null && !Array.isArray(bValue) && !(bValue instanceof Date)) {
254
+ for (const [opKey, operand] of Object.entries(bValue)) {
255
+ const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
256
+ if (sqlOp) orConditions.push({ field: bKey, operator: sqlOp as WhereCondition['operator'], value: operand });
257
+ }
258
+ } else {
259
+ orConditions.push({ field: bKey, operator: '=', value: bValue });
260
+ }
261
+ }
262
+ }
263
+ if (orConditions.length > 0) this.iqo.whereOrs.push(orConditions);
264
+ continue;
265
+ }
266
+
182
267
  if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
183
268
  for (const [opKey, operand] of Object.entries(value)) {
184
269
  const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
@@ -221,6 +306,60 @@ export class QueryBuilder<T extends Record<string, any>> {
221
306
  return this;
222
307
  }
223
308
 
309
+ /**
310
+ * Join another table. Two calling styles:
311
+ *
312
+ * **Accessor-based** (auto-infers FK from relationships, type-safe columns):
313
+ * ```ts
314
+ * db.trees.select('name').join(db.forests, ['name']).all()
315
+ * // → [{ name: 'Oak', forests_name: 'Sherwood' }]
316
+ * ```
317
+ *
318
+ * **String-based** (manual FK):
319
+ * ```ts
320
+ * db.trees.select('name').join('forests', 'forestId', ['name']).all()
321
+ * ```
322
+ */
323
+ join(
324
+ accessor: { _tableName: string },
325
+ columns?: string[],
326
+ ): this;
327
+ join(
328
+ table: string,
329
+ fk: string,
330
+ columns?: string[],
331
+ pk?: string,
332
+ ): this;
333
+ join(tableOrAccessor: string | { _tableName: string }, fkOrCols?: string | string[], colsOrPk?: string[] | string, pk?: string): this {
334
+ let table: string;
335
+ let fromCol: string;
336
+ let toCol: string;
337
+ let columns: string[];
338
+
339
+ if (typeof tableOrAccessor === 'object' && '_tableName' in tableOrAccessor) {
340
+ // Accessor-based: .join(db.forests, ['name', 'address'])
341
+ table = tableOrAccessor._tableName;
342
+ columns = Array.isArray(fkOrCols) ? fkOrCols : [];
343
+
344
+ // Auto-resolve FK from relationships
345
+ if (!this.joinResolver) throw new Error(`Cannot auto-resolve join: no relationship data available`);
346
+ const resolved = this.joinResolver(this.tableName, table);
347
+ if (!resolved) throw new Error(`No relationship found between '${this.tableName}' and '${table}'`);
348
+ fromCol = resolved.fk;
349
+ toCol = resolved.pk;
350
+ } else {
351
+ // String-based: .join('forests', 'forestId', ['name'], 'id')
352
+ table = tableOrAccessor;
353
+ fromCol = fkOrCols as string;
354
+ columns = Array.isArray(colsOrPk) ? colsOrPk : [];
355
+ toCol = (typeof colsOrPk === 'string' ? colsOrPk : pk) ?? 'id';
356
+ }
357
+
358
+ this.iqo.joins.push({ table, fromCol, toCol, columns });
359
+ this.iqo.raw = true;
360
+ return this;
361
+ }
362
+
224
363
  /** Skip Zod parsing and return raw SQLite row objects. */
225
364
  raw(): this {
226
365
  this.iqo.raw = true;
package/src/schema.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * schema.ts — Schema parsing, relationship detection, and DDL helpers
3
+ */
4
+ import { z } from 'zod';
5
+ import type { SchemaMap, ZodType, Relationship } from './types';
6
+ import { asZodObject } from './types';
7
+
8
+ /**
9
+ * Parse declarative `relations` config into Relationship[] objects.
10
+ *
11
+ * Config format: `{ childTable: { fkColumn: 'parentTable' } }`
12
+ * Example: `{ books: { author_id: 'authors' } }` produces:
13
+ * - books → authors (belongs-to, FK = author_id, nav = author)
14
+ * - authors → books (one-to-many, nav = books)
15
+ */
16
+ export function parseRelationsConfig(
17
+ relations: Record<string, Record<string, string>>,
18
+ schemas: SchemaMap,
19
+ ): Relationship[] {
20
+ const relationships: Relationship[] = [];
21
+ const added = new Set<string>();
22
+
23
+ for (const [fromTable, rels] of Object.entries(relations)) {
24
+ if (!schemas[fromTable]) {
25
+ throw new Error(`relations: unknown table '${fromTable}'`);
26
+ }
27
+ for (const [fkColumn, toTable] of Object.entries(rels)) {
28
+ if (!schemas[toTable]) {
29
+ throw new Error(`relations: unknown target table '${toTable}' in ${fromTable}.${fkColumn}`);
30
+ }
31
+
32
+ // Derive navigation name: author_id → author
33
+ const navField = fkColumn.replace(/_id$/, '');
34
+
35
+ // belongs-to: books.author_id → authors
36
+ const btKey = `${fromTable}.${fkColumn}:belongs-to`;
37
+ if (!added.has(btKey)) {
38
+ relationships.push({
39
+ type: 'belongs-to',
40
+ from: fromTable,
41
+ to: toTable,
42
+ relationshipField: navField,
43
+ foreignKey: fkColumn,
44
+ });
45
+ added.add(btKey);
46
+ }
47
+
48
+ // auto-infer one-to-many inverse: authors.books → books[]
49
+ const otmKey = `${toTable}.${fromTable}:one-to-many`;
50
+ if (!added.has(otmKey)) {
51
+ relationships.push({
52
+ type: 'one-to-many',
53
+ from: toTable,
54
+ to: fromTable,
55
+ relationshipField: fromTable, // e.g. 'books'
56
+ foreignKey: '',
57
+ });
58
+ added.add(otmKey);
59
+ }
60
+ }
61
+ }
62
+
63
+ return relationships;
64
+ }
65
+
66
+ /** Get storable (non-id) fields from a schema */
67
+ export function getStorableFields(schema: z.ZodType<any>): { name: string; type: ZodType }[] {
68
+ return Object.entries(asZodObject(schema).shape)
69
+ .filter(([key]) => key !== 'id')
70
+ .map(([name, type]) => ({ name, type: type as ZodType }));
71
+ }
72
+
73
+ /** Map a Zod type to its SQLite column type */
74
+ export function zodTypeToSqlType(zodType: ZodType): string {
75
+ if (zodType instanceof z.ZodOptional) {
76
+ zodType = zodType._def.innerType;
77
+ }
78
+ if (zodType instanceof z.ZodDefault) {
79
+ zodType = zodType._def.innerType;
80
+ }
81
+ if (zodType instanceof z.ZodString || zodType instanceof z.ZodDate) return 'TEXT';
82
+ if (zodType instanceof z.ZodNumber || zodType instanceof z.ZodBoolean) return 'INTEGER';
83
+ if ((zodType as any)._def.typeName === 'ZodInstanceOf' && (zodType as any)._def.type === Buffer) return 'BLOB';
84
+ return 'TEXT';
85
+ }
86
+
87
+ /** Transform JS values to SQLite storage format */
88
+ export function transformForStorage(data: Record<string, any>): Record<string, any> {
89
+ const transformed: Record<string, any> = {};
90
+ for (const [key, value] of Object.entries(data)) {
91
+ if (value instanceof Date) {
92
+ transformed[key] = value.toISOString();
93
+ } else if (typeof value === 'boolean') {
94
+ transformed[key] = value ? 1 : 0;
95
+ } else {
96
+ transformed[key] = value;
97
+ }
98
+ }
99
+ return transformed;
100
+ }
101
+
102
+ /** Transform SQLite row back to JS types based on schema */
103
+ export function transformFromStorage(row: Record<string, any>, schema: z.ZodType<any>): Record<string, any> {
104
+ const transformed: Record<string, any> = {};
105
+ for (const [key, value] of Object.entries(row)) {
106
+ let fieldSchema = asZodObject(schema).shape[key];
107
+ if (fieldSchema instanceof z.ZodOptional) {
108
+ fieldSchema = fieldSchema._def.innerType;
109
+ }
110
+ if (fieldSchema instanceof z.ZodDefault) {
111
+ fieldSchema = fieldSchema._def.innerType;
112
+ }
113
+ if (fieldSchema instanceof z.ZodDate && typeof value === 'string') {
114
+ transformed[key] = new Date(value);
115
+ } else if (fieldSchema instanceof z.ZodBoolean && typeof value === 'number') {
116
+ transformed[key] = value === 1;
117
+ } else {
118
+ transformed[key] = value;
119
+ }
120
+ }
121
+ return transformed;
122
+ }