Repository navigation
refactor(db): extrai column-mapping (snake↔camel) de db/core.ts #4947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,80 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||
| * db/caseMapping.ts — pure snake_case ↔ camelCase column mapping. | ||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||
| * Extracted from db/core.ts (god-file decomposition): the column-name conversion | ||||||||||||||||||||||||||||||||||||||||||||
| * helpers that translate raw SQLite rows (snake_case columns, 0/1 booleans, `_json` | ||||||||||||||||||||||||||||||||||||||||||||
| * TEXT columns) into the camelCase shapes the domain modules consume. Pure — no DB | ||||||||||||||||||||||||||||||||||||||||||||
| * handle, no module state — so they live as a co-located leaf that every db/ module | ||||||||||||||||||||||||||||||||||||||||||||
| * (and core.ts itself) imports. core.ts re-exports all five so existing call sites that | ||||||||||||||||||||||||||||||||||||||||||||
| * pull these helpers off the core module keep working unchanged. | ||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| type JsonRecord = Record<string, unknown>; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function toSnakeCase(str: string): string { | ||||||||||||||||||||||||||||||||||||||||||||
| return str.replace(/([A-Z])/g, "_$1").toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function toCamelCase(str: string): string { | ||||||||||||||||||||||||||||||||||||||||||||
| return str.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase()); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function objToSnake(obj: unknown): unknown { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!obj || typeof obj !== "object") return obj; | ||||||||||||||||||||||||||||||||||||||||||||
| const result: JsonRecord = {}; | ||||||||||||||||||||||||||||||||||||||||||||
| for (const [k, v] of Object.entries(obj as JsonRecord)) { | ||||||||||||||||||||||||||||||||||||||||||||
| result[toSnakeCase(k)] = v; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function rowToCamel(row: unknown): JsonRecord | null { | ||||||||||||||||||||||||||||||||||||||||||||
| if (!row) return null; | ||||||||||||||||||||||||||||||||||||||||||||
| const result: JsonRecord = {}; | ||||||||||||||||||||||||||||||||||||||||||||
| for (const [k, v] of Object.entries(row as JsonRecord)) { | ||||||||||||||||||||||||||||||||||||||||||||
| const camelKey = toCamelCase(k); | ||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||
| camelKey === "isActive" || | ||||||||||||||||||||||||||||||||||||||||||||
| camelKey === "rateLimitProtection" || | ||||||||||||||||||||||||||||||||||||||||||||
| camelKey === "proxyEnabled" || | ||||||||||||||||||||||||||||||||||||||||||||
| camelKey === "perKeyProxyEnabled" | ||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||
| result[camelKey] = v === 1 || v === true; | ||||||||||||||||||||||||||||||||||||||||||||
| } else if (camelKey === "providerSpecificData" && typeof v === "string") { | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| result[camelKey] = JSON.parse(v); | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| result[camelKey] = v; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } else if (camelKey.endsWith("Json")) { | ||||||||||||||||||||||||||||||||||||||||||||
| // Convention: any column with a `_json` suffix is JSON-encoded TEXT. | ||||||||||||||||||||||||||||||||||||||||||||
| // Surface the parsed object under the friendlier name (key minus the | ||||||||||||||||||||||||||||||||||||||||||||
| // "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds. | ||||||||||||||||||||||||||||||||||||||||||||
| // A NULL/absent column normalizes to `baseKey: null` (not the suffixed | ||||||||||||||||||||||||||||||||||||||||||||
| // key) so read and write paths expose a consistent shape. | ||||||||||||||||||||||||||||||||||||||||||||
| const baseKey = camelKey.slice(0, -"Json".length); | ||||||||||||||||||||||||||||||||||||||||||||
| if (typeof v === "string") { | ||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||
| result[baseKey] = JSON.parse(v); | ||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||
| result[baseKey] = null; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||
| result[baseKey] = v == null ? null : v; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||
| result[camelKey] = v; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| export function cleanNulls(obj: unknown): JsonRecord { | ||||||||||||||||||||||||||||||||||||||||||||
| const result: JsonRecord = {}; | ||||||||||||||||||||||||||||||||||||||||||||
| for (const [k, v] of Object.entries((obj as JsonRecord) || {})) { | ||||||||||||||||||||||||||||||||||||||||||||
| if (v !== null && v !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||||
| result[k] = v; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| return result; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+72
to
+80
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| // Characterization of the db/core.ts column-mapping split (god-file decomposition): the pure | ||
| // snake_case ↔ camelCase row helpers moved into db/caseMapping.ts. Behavior-preserving move — these | ||
| // locks pin the conversion semantics (boolean coercion for known flag columns, JSON parsing for | ||
| // providerSpecificData + `_json` TEXT columns, null-stripping) and that core.ts still re-exports them | ||
| // for the historical `from "./core"` import surface. | ||
| import { test } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
|
|
||
| const M = await import("../../src/lib/db/caseMapping.ts"); | ||
| const CORE = await import("../../src/lib/db/core.ts"); | ||
|
|
||
| test("module exposes the five mapping helpers", () => { | ||
| for (const name of ["toSnakeCase", "toCamelCase", "objToSnake", "rowToCamel", "cleanNulls"]) { | ||
| assert.equal(typeof (M as Record<string, unknown>)[name], "function", `missing ${name}`); | ||
| } | ||
| }); | ||
|
|
||
| test("core.ts re-exports the same helpers (historical surface preserved)", () => { | ||
| for (const name of ["toSnakeCase", "toCamelCase", "objToSnake", "rowToCamel", "cleanNulls"]) { | ||
| assert.equal((CORE as Record<string, unknown>)[name], (M as Record<string, unknown>)[name]); | ||
| } | ||
| }); | ||
|
|
||
| test("toSnakeCase / toCamelCase round-trip column names", () => { | ||
| assert.equal(M.toSnakeCase("maxRequestsPerDay"), "max_requests_per_day"); | ||
| assert.equal(M.toCamelCase("max_requests_per_day"), "maxRequestsPerDay"); | ||
| }); | ||
|
|
||
| test("objToSnake converts keys, passes non-objects through", () => { | ||
| assert.deepEqual(M.objToSnake({ providerId: "x", isActive: 1 }), { | ||
| provider_id: "x", | ||
| is_active: 1, | ||
| }); | ||
| assert.equal(M.objToSnake(null), null); | ||
| assert.equal(M.objToSnake("str"), "str"); | ||
| }); | ||
|
|
||
| test("rowToCamel coerces known boolean flags from 0/1", () => { | ||
| const out = M.rowToCamel({ is_active: 1, proxy_enabled: 0, per_key_proxy_enabled: 1 }); | ||
| assert.equal(out?.isActive, true); | ||
| assert.equal(out?.proxyEnabled, false); | ||
| assert.equal(out?.perKeyProxyEnabled, true); | ||
| assert.equal(M.rowToCamel(null), null); | ||
| }); | ||
|
|
||
| test("rowToCamel parses providerSpecificData JSON string", () => { | ||
| const out = M.rowToCamel({ provider_specific_data: '{"a":1}' }); | ||
| assert.deepEqual(out?.providerSpecificData, { a: 1 }); | ||
| // invalid JSON falls back to the raw string | ||
| const bad = M.rowToCamel({ provider_specific_data: "not json" }); | ||
| assert.equal(bad?.providerSpecificData, "not json"); | ||
| }); | ||
|
|
||
| test("rowToCamel unwraps `_json` columns to the base key", () => { | ||
| const out = M.rowToCamel({ quota_window_thresholds_json: '{"hi":80}' }); | ||
| assert.deepEqual(out?.quotaWindowThresholds, { hi: 80 }); | ||
| assert.equal("quotaWindowThresholdsJson" in (out ?? {}), false); | ||
| // NULL `_json` column normalizes the base key to null | ||
| const nul = M.rowToCamel({ quota_window_thresholds_json: null }); | ||
| assert.equal(nul?.quotaWindowThresholds, null); | ||
| }); | ||
|
|
||
| test("cleanNulls drops null/undefined, keeps falsy-but-present values", () => { | ||
| assert.deepEqual(M.cleanNulls({ a: 0, b: "", c: null, d: undefined, e: false }), { | ||
| a: 0, | ||
| b: "", | ||
| e: false, | ||
| }); | ||
| assert.deepEqual(M.cleanNulls(null), {}); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
objToSnakefunction does not handle arrays. Sincetypeof [] === "object"is true in JavaScript, passing an array toobjToSnakewill cause it to be converted into an object with numeric string keys (e.g.,[{foo: 1}]becomes{"0": {foo: 1}}). We should explicitly check for arrays and recursively map over them or return them as-is.