• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rogerpadilla / uql / 29711874318

20 Jul 2026 02:03AM UTC coverage: 95.079% (+0.02%) from 95.058%
29711874318

push

github

rogerpadilla
chore(release): publish

 - uql-orm@0.17.0

3310 of 3657 branches covered (90.51%)

Branch coverage included in aggregate %.

5712 of 5832 relevant lines covered (97.94%)

531.72 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.83
/packages/uql-orm/src/util/sql.util.ts
1
import type { InsertIdSource, Key, QueryUpdateResult, RawRow } from '../type/index.js';
2
import type { PrimaryKey } from '../type/utility.js';
3
import { getKeys, hasKeys } from './object.util.js';
4

5
/** Pre-computed regex for each SQL identifier escape character to avoid per-call allocation. */
6
const escapeIdRegexCache = { '`': /`/g, '"': /"/g } as const satisfies Record<string, RegExp>;
92✔
7

8
export function flatObject<E extends object>(obj: E, pre?: string): E {
9
  return getKeys(obj).reduce(
139✔
10
    (acc, key) => flatObjectEntry(acc, key, obj[key as Key<E>], typeof obj[key as Key<E>] === 'object' ? '' : pre),
173✔
11
    {} as E,
12
  );
13
}
14

15
function flatObjectEntry<E>(map: E, key: string, val: unknown, pre?: string): E {
16
  const prefix = pre ? `${pre}.${key}` : key;
184✔
17
  if (typeof val === 'object' && val !== null) {
184✔
18
    return getKeys(val).reduce(
10✔
19
      (acc, prop) => flatObjectEntry(acc, prop, (val as Record<string, unknown>)[prop], prefix),
11✔
20
      map,
21
    );
22
  }
23
  (map as Record<string, unknown>)[prefix] = val;
174✔
24
  return map;
174✔
25
}
26

27
export function unflatObjects<T extends object>(objects: RawRow[]): T[] {
28
  if (!Array.isArray(objects) || !objects.length) {
401✔
29
    return objects as T[];
67✔
30
  }
31

32
  const attrsPaths = obtainAttrsPaths(objects[0]);
334✔
33

34
  if (!hasKeys(attrsPaths)) {
334✔
35
    return objects as T[];
296✔
36
  }
37

38
  return objects.map((row) => unflatObject<T>(row, attrsPaths));
57✔
39
}
40

41
/**
42
 * Unflattens a single raw row using pre-computed attribute paths.
43
 * Use this for streaming to avoid per-row array allocations.
44
 */
45
export function unflatObject<T extends object>(row: RawRow, attrsPaths: Record<string, string[]>): T {
46
  const dto = {} as T;
87✔
47

48
  for (const col in row) {
87✔
49
    if (row[col] === null) {
704✔
50
      continue;
221✔
51
    }
52
    const attrPath = attrsPaths[col];
483✔
53
    if (attrPath) {
483✔
54
      let target = dto as Record<string, unknown>;
223✔
55
      for (let i = 0; i < attrPath.length - 1; i++) {
223✔
56
        const seg = attrPath[i];
243✔
57
        if (typeof target[seg] !== 'object') {
243✔
58
          target[seg] = {};
68✔
59
        }
60
        target = target[seg] as Record<string, unknown>;
243✔
61
      }
62
      target[attrPath[attrPath.length - 1]] = row[col];
223✔
63
    } else {
64
      (dto as RawRow)[col] = row[col];
260✔
65
    }
66
  }
67

68
  return dto;
87✔
69
}
70

71
export function obtainAttrsPaths<T extends object>(row: T) {
72
  const paths: { [k: string]: string[] } = {};
356✔
73
  for (const col in row) {
356✔
74
    if (col.includes('.')) {
1,688✔
75
      paths[col] = col.split('.');
236✔
76
    }
77
  }
78
  return paths;
355✔
79
}
80

81
/**
82
 * Escape a SQL identifier (table name, column name, etc.)
83
 * @param val the identifier to escape
84
 * @param escapeIdChar the escape character to use (e.g. ` or ")
85
 * @param forbidQualified whether to forbid qualified identifiers (containing dots)
86
 * @param addDot whether to add a dot suffix
87
 */
88
export function escapeSqlId(
89
  val: string,
90
  escapeIdChar: '`' | '"' = '`',
38,296✔
91
  forbidQualified?: boolean,
92
  addDot?: boolean,
93
): string {
94
  if (!val) {
38,296✔
95
    return '';
9,299✔
96
  }
97

98
  if (!forbidQualified && val.includes('.')) {
28,997✔
99
    const result = val
13✔
100
      .split('.')
101
      .map((it) => escapeSqlId(it, escapeIdChar, true))
26✔
102
      .join('.');
103
    return addDot ? result + '.' : result;
13✔
104
  }
105

106
  const escaped =
107
    escapeIdChar + val.replace(escapeIdRegexCache[escapeIdChar], escapeIdChar + escapeIdChar) + escapeIdChar;
28,984✔
108

109
  const suffix = addDot ? '.' : '';
28,984✔
110

111
  return escaped + suffix;
38,296✔
112
}
113

114
/**
115
 * Payload for building a QueryUpdateResult.
116
 */
117
export interface BuildUpdateResultPayload {
118
  /** The count of rows affected by the statement. */
119
  changes?: number;
120
  /** The raw rows returned by the query (for RETURNING clauses). */
121
  rows?: RawRow[];
122
  /** The first auto-generated ID from the driver header (MySQL `insertId`; no `RETURNING`). */
123
  id?: PrimaryKey;
124
  /** How the dialect surfaces inserted IDs (see {@link InsertIdSource}). */
125
  insertIdSource?: InsertIdSource;
126
  /**
127
   * Auto-increment stride for header-derived id inference. Defaults to 1; a clustered MySQL
128
   * server (e.g. Galera, group replication) may set `auto_increment_increment` higher.
129
   */
130
  insertIdIncrement?: number;
131
  /**
132
   * Driver-specific upsert detection from the result header.
133
   * MySQL/MariaDB `ON DUPLICATE KEY UPDATE` convention: 1 = insert, 2 = update, 0 = no-op.
134
   */
135
  upsertStatus?: number;
136
}
137

138
/**
139
 * Unified utility to build a QueryUpdateResult from driver-specific results.
140
 *
141
 * UQL's SQL dialects always alias the entity's ID column to `id` in RETURNING clauses,
142
 * so the result rows always contain an `id` property regardless of the entity's @Id() key name.
143
 *
144
 * The header-derived ID path assumes the database allocated consecutive values for the
145
 * statement, which holds for a single multi-row `INSERT ... VALUES` on auto-increment keys
146
 * (with the standard `auto_increment_increment = 1`); the querier only maps these IDs onto
147
 * payloads when that assumption is safe.
148
 *
149
 * Caveat (MySQL/MariaDB-compatible engines with no `RETURNING`, i.e. `insertIdSource: 'firstId'`):
150
 * contiguous allocation across a statement's rows is only guaranteed under
151
 * `innodb_autoinc_lock_mode` 0 (`traditional`) or 1 (`consecutive`). Under mode 2 (`interleaved`,
152
 * MySQL 8.0's default), other connections inserting into the same table concurrently with this
153
 * statement can interleave with its auto-increment allocation, so the inferred IDs may not be
154
 * contiguous. There is no code-level fix for this (MySQL has no `RETURNING`); avoid relying on
155
 * inferred multi-row IDs for a table under heavy concurrent insert load, or set
156
 * `innodb_autoinc_lock_mode` to 0 or 1.
157
 */
158
export function buildUpdateResult(payload: BuildUpdateResultPayload): QueryUpdateResult {
159
  const { rows, id, insertIdSource, upsertStatus } = payload;
3,612✔
160
  const changes = payload.changes ?? rows?.length ?? 0;
3,612✔
161
  const stride = payload.insertIdIncrement && payload.insertIdIncrement > 0 ? payload.insertIdIncrement : 1;
3,612✔
162

163
  // ID mapping. RETURNING rows are exact. Otherwise the sequence is derived from the single id in
164
  // the driver header: `firstId` dialects (MySQL) report the FIRST generated id, and the rest are
165
  // inferred by incrementing it. A header id of `0`/`0n` means no id was generated (e.g. a
166
  // non-auto-increment key), so we infer none.
167
  //
168
  // This arithmetic assumes `changes` equals the batch's row count, which always holds for a plain
169
  // `insertMany` - but not for `upsertMany` on a `firstId` dialect (MySQL): its `ON DUPLICATE KEY
170
  // UPDATE` convention makes `changes` a per-row weighted sum (1=insert, 2=update, 0=no-op), so a
171
  // batch mixing an insert and an update would fabricate ids for rows that were never touched. This
172
  // function has no way to tell the two call sites apart (`internalRun` reports the same header
173
  // shape either way), so `AbstractSqlQuerier.upsertMany` strips `ids`/`firstId`/`created` back down
174
  // to just `changes` for a multi-row `firstId`-dialect upsert after calling this.
175
  let ids: PrimaryKey[] = [];
3,612✔
176
  if (rows?.length) {
3,612✔
177
    ids = rows.map((r) => r['id'] as PrimaryKey);
812✔
178
  } else if (insertIdSource !== 'returning' && isPrimaryKey(id) && id) {
3,021✔
179
    if (typeof id === 'string') {
72!
180
      if (changes === 1) ids = [id];
×
181
    } else {
182
      ids = sequentialIds(id, changes, stride);
72✔
183
    }
184
  }
185

186
  // 2. Creation Status
187
  // PostgreSQL: `(xmax = 0) AS "_created"` in the RETURNING clause provides a boolean per row.
188
  // MySQL: `affectedRows` convention - 1 = insert, 2 = update, 0 = no-op. Gated on `!== 'returning'`
189
  // since that convention is unreliable once RETURNING is in play (verified: MariaDB's affectedRows
190
  // for an `ON DUPLICATE KEY UPDATE ... RETURNING` statement differs by driver and doesn't follow
191
  // the 1/2/0 convention at all) - `insertIdSource === 'returning'` dialects without a `_created`
192
  // column (MariaDB, SQLite, CockroachDB) correctly get `undefined` instead of a misleading guess.
193
  const created =
3,612✔
194
    (rows?.length === 1 ? (rows[0]?.['_created'] as boolean | undefined) : undefined) ??
3,612✔
195
    (insertIdSource !== 'returning' && typeof upsertStatus === 'number' && upsertStatus >= 0 && upsertStatus <= 2
8,196✔
196
      ? upsertStatus === 1
197
      : undefined);
198

199
  return { changes, ids, firstId: ids?.[0], created };
3,612✔
200
}
201

202
/** Build `count` ids starting at `first`, incrementing by `step` (bigint- and number-safe). */
203
function sequentialIds(first: number | bigint, count: number, step: number): PrimaryKey[] {
204
  return typeof first === 'bigint'
72✔
205
    ? Array.from({ length: count }, (_, i) => first + BigInt(i) * BigInt(step))
3✔
206
    : Array.from({ length: count }, (_, i) => first + i * step);
108✔
207
}
208

209
/**
210
 * Checks if a value is of a primary key type (string, number, or bigint).
211
 */
212
export function isPrimaryKey(val: unknown): val is PrimaryKey {
213
  return typeof val === 'string' || typeof val === 'number' || typeof val === 'bigint';
340✔
214
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc