Support sqlite#189
Conversation
📝 WalkthroughWalkthroughThis PR adds complete SQLite database support to db-studio by implementing a new adapter, query builders, type mappers, and database manager integration. The changes span server backend logic, shared type definitions, initialization scripts, and frontend UI updates to advertise SQLite as a supported database alongside PostgreSQL, MySQL, MSSQL, and MongoDB. ChangesSQLite Database Support
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Bundle Size
File breakdown
File breakdown |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/server/src/adapters/sqlite/sqlite.adapter.ts`:
- Around line 352-413: The public adapter methods getDatabasesList,
getCurrentDatabase, getDatabaseConnectionInfo, and getTablesList must be wrapped
in try/catch blocks so any errors are rethrown via the adapter helper; update
each of these methods to enclose the current body in try { ... } catch (e) {
throw this.wrapError(e); } (and do the same for the other adapter overrides
mentioned in the later block around lines 495–557) so better-sqlite3/filesystem
failures are converted into the adapter’s 503-mapped error using this.wrapError.
- Around line 241-284: The ORDER BY must include the PK tie-breaker columns from
cursorColumns to ensure stable ordering; update the logic that sets
effectiveSortClause (which currently starts from sortClause built by
buildSortClause) to append any cursorColumns not already present in the
sortClause with the correct direction (use effectiveSortDirection for
tie-breaker direction when direction is "asc", and invert appropriately when
direction is "desc" or when you applied the TEMP_DESC swap), ensuring you
preserve existing explicit sort entries from buildSortClause and only add
missing PK columns (quoted the same way as other columns) so the final
effectiveSortClause deterministically orders by (requested sort..., pk1,
pk2...).
- Around line 758-765: addRecord() and bulkInsertRecords() currently bind
object/array values raw while updateRecords() stringifies them, causing
inconsistent storage for JSON/array columns; update the insert paths used in
addRecord(), bulkInsertRecords() (the code building values, colNames,
placeholders and calling sqliteDb.prepare(...).run(...)) to mirror
updateRecords() by mapping over Object.values(data) and JSON.stringify any value
that is an object or array before passing to .run(...), ensuring consistent
serialization for object/array types across inserts and updates.
In `@packages/server/src/adapters/sqlite/sqlite.query-builder.ts`:
- Around line 13-14: The code interpolates raw identifiers (e.g.,
filter.columnName and entries in sortColumns) into quoted SQL identifiers (see
the line that sets col = `"${filter.columnName}"` and the other occurrences
around sort/ cursor construction), which allows injection if the value contains
quotes; fix by validating or escaping identifiers before interpolation: either
whitelist/validate each identifier against a safe pattern like
/^[A-Za-z_][A-Za-z0-9_]*$/ (reject or throw on invalid names) or escape double
quotes by replacing " with "" per SQLite identifier quoting rules, then use the
sanitized identifier in place of filter.columnName and in all uses of
sortColumns and cursor column interpolation. Ensure the same sanitization
utility is used consistently in the functions that build filters, sorts, and
cursor clauses (the places referenced around the col assignment and the
sorts/cursors).
In `@packages/server/src/db-manager.ts`:
- Around line 165-170: The SQLite file handle is only closed by closeSqliteDb()
when closeAll() runs, but closePool() and closePoolByDatabase() skip it and can
leave the handle open; update closePool() and closePoolByDatabase() to call
closeSqliteDb() (or perform the same sqliteDb?.close() and sqliteDb = null
logic) whenever the pool being closed corresponds to the SQLite database (match
by the same database identifier used in those methods), ensuring the sqliteDb is
closed and nulled in selective-close flows as well; keep closeSqliteDb() as the
single implementation to avoid duplication and call it from
closePool()/closePoolByDatabase().
In `@packages/shared/src/types/column.type.ts`:
- Around line 630-639: The type-normalization logic misclassifies parameterized
types like "bigint(20)" or "smallint(6)" because the generic includes("int")
branch runs first; update the comparisons to match whole words or prefixes
before the generic match (e.g., use normalized.startsWith("bigint") /
/^bigint(\(|$)/, and similarly for smallint, tinyint, mediumint) and do the same
for float/double (startsWith or regex to catch "float(8)" / "double
precision(10)"); ensure these checks still return the correct
StandardizedDataType values and keep the generic normalized.includes("int")
fallback last (apply the same change to the corresponding float/double block
referenced in the diff).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 97c0aa4a-25bb-4a54-8971-47f5182d58e8
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (19)
CLAUDE.mddb/dbstudio.sqlitedb/dbstudio.sqlite-shmdb/dbstudio.sqlite-walpackage.jsonpackages/server/package.jsonpackages/server/src/adapters/connections.tspackages/server/src/adapters/register.tspackages/server/src/adapters/sqlite/sqlite.adapter.tspackages/server/src/adapters/sqlite/sqlite.query-builder.tspackages/server/src/cmd/get-db-url.tspackages/server/src/db-manager.tspackages/server/tests/adapters/sqlite.adapter.test.tspackages/server/tests/adapters/sqlite.query-builder.test.tspackages/shared/src/types/column.type.tspackages/shared/src/types/database.types.tsscripts/init-db-sqlite.shwww/src/components/ui/svgs/sqliteWordmark.tsxwww/src/routes/(main)/_pathlessLayout/index.tsx
| const cursorColumns = [ | ||
| ...sortColumns, | ||
| ...pkColumns.filter((pk) => !sortColumns.includes(pk)), | ||
| ]; | ||
| const useRowid = cursorColumns.length === 0; | ||
| if (useRowid) cursorColumns.push("rowid"); | ||
|
|
||
| const { clause: filterWhere, values: filterValues } = buildWhereClause(filters); | ||
|
|
||
| let cursorWhere = ""; | ||
| let cursorValues: unknown[] = []; | ||
| if (cursor) { | ||
| const cursorData = this.decodeCursor(cursor); | ||
| if (cursorData) { | ||
| const res = buildCursorWhereClause(cursorData, direction, effectiveSortDirection); | ||
| cursorWhere = res.clause; | ||
| cursorValues = res.values; | ||
| } | ||
| } | ||
|
|
||
| let combinedWhere = ""; | ||
| if (filterWhere && cursorWhere) { | ||
| combinedWhere = `WHERE ${filterWhere.replace(/^WHERE\s+/i, "")} AND ${cursorWhere}`; | ||
| } else if (filterWhere) { | ||
| combinedWhere = filterWhere; | ||
| } else if (cursorWhere) { | ||
| combinedWhere = `WHERE ${cursorWhere}`; | ||
| } | ||
|
|
||
| const sortClause = buildSortClause(Array.isArray(sort) ? sort : sort, order); | ||
| let effectiveSortClause = sortClause; | ||
|
|
||
| if (direction === "desc") { | ||
| if (sortClause) { | ||
| effectiveSortClause = sortClause | ||
| .replace(/\bASC\b/gi, "TEMP_DESC") | ||
| .replace(/\bDESC\b/gi, "ASC") | ||
| .replace(/TEMP_DESC/g, "DESC"); | ||
| } else { | ||
| effectiveSortClause = `ORDER BY ${cursorColumns.map((col) => `"${col}" ${effectiveSortDirection === "asc" ? "DESC" : "ASC"}`).join(", ")}`; | ||
| } | ||
| } else if (!sortClause) { | ||
| effectiveSortClause = `ORDER BY ${cursorColumns.map((col) => `"${col}" ${effectiveSortDirection.toUpperCase()}`).join(", ")}`; | ||
| } |
There was a problem hiding this comment.
Append the PK tie-breakers to ORDER BY too.
cursorColumns correctly adds primary-key columns for stable cursors, but effectiveSortClause still orders only by the requested sort. If that sort is non-unique, the query order inside each peer group is undefined while the cursor predicate compares (sort, pk), so rows can be skipped or duplicated across pages.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/adapters/sqlite/sqlite.adapter.ts` around lines 241 -
284, The ORDER BY must include the PK tie-breaker columns from cursorColumns to
ensure stable ordering; update the logic that sets effectiveSortClause (which
currently starts from sortClause built by buildSortClause) to append any
cursorColumns not already present in the sortClause with the correct direction
(use effectiveSortDirection for tie-breaker direction when direction is "asc",
and invert appropriately when direction is "desc" or when you applied the
TEMP_DESC swap), ensuring you preserve existing explicit sort entries from
buildSortClause and only add missing PK columns (quoted the same way as other
columns) so the final effectiveSortClause deterministically orders by (requested
sort..., pk1, pk2...).
| async getDatabasesList(): Promise<DatabaseInfoSchemaType[]> { | ||
| const sqliteDb = getSqliteDb(); | ||
| const rows = sqliteDb.prepare("PRAGMA database_list").all() as Array<{ | ||
| seq: number; | ||
| name: string; | ||
| file: string; | ||
| }>; | ||
|
|
||
| if (!rows.length) | ||
| throw new HTTPException(500, { message: "No databases returned from SQLite" }); | ||
|
|
||
| return rows.map((row) => ({ | ||
| name: row.name, | ||
| size: this.getFileSize(row.file), | ||
| owner: "", | ||
| encoding: "UTF-8", | ||
| })); | ||
| } | ||
|
|
||
| async getCurrentDatabase(): Promise<DatabaseSchemaType> { | ||
| const sqliteDb = getSqliteDb(); | ||
| const rows = sqliteDb.prepare("PRAGMA database_list").all() as Array<{ name: string }>; | ||
| const main = rows.find((r) => r.name === "main"); | ||
| return { db: main?.name ?? "main" }; | ||
| } | ||
|
|
||
| async getDatabaseConnectionInfo(): Promise<ConnectionInfoSchemaType> { | ||
| const sqliteDb = getSqliteDb(); | ||
| const versionRow = sqliteDb.prepare("SELECT sqlite_version() as version").get() as { | ||
| version: string; | ||
| }; | ||
|
|
||
| return { | ||
| host: null, | ||
| port: null, | ||
| user: "", | ||
| database: "main", | ||
| version: `SQLite ${versionRow.version}`, | ||
| active_connections: 1, | ||
| max_connections: 1, | ||
| }; | ||
| } | ||
|
|
||
| // ========================================================= | ||
| // IDbAdapter — Tables | ||
| // ========================================================= | ||
|
|
||
| async getTablesList(_db: DatabaseSchemaType["db"]): Promise<TableInfoSchemaType[]> { | ||
| const sqliteDb = getSqliteDb(); | ||
| const tables = sqliteDb | ||
| .prepare( | ||
| `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, | ||
| ) | ||
| .all() as Array<{ name: string }>; | ||
|
|
||
| return tables.map((t) => { | ||
| const countRow = sqliteDb.prepare(`SELECT COUNT(*) as count FROM "${t.name}"`).get() as { | ||
| count: number; | ||
| }; | ||
| return { tableName: t.name, rowCount: countRow?.count ?? 0 }; | ||
| }); | ||
| } |
There was a problem hiding this comment.
Wrap these public overrides with throw this.wrapError(e) as well.
These methods call better-sqlite3/filesystem APIs directly without a top-level try/catch, so connection failures bypass the adapter’s normal 503 mapping and surface as generic errors instead.
As per coding guidelines, "packages/server/src/adapters/**/*.adapter.ts: Database adapter template method overrides must wrap their body in try/catch and call throw this.wrapError(e) to surface connection errors as 503."
Also applies to: 495-557
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/adapters/sqlite/sqlite.adapter.ts` around lines 352 -
413, The public adapter methods getDatabasesList, getCurrentDatabase,
getDatabaseConnectionInfo, and getTablesList must be wrapped in try/catch blocks
so any errors are rethrown via the adapter helper; update each of these methods
to enclose the current body in try { ... } catch (e) { throw this.wrapError(e);
} (and do the same for the other adapter overrides mentioned in the later block
around lines 495–557) so better-sqlite3/filesystem failures are converted into
the adapter’s 503-mapped error using this.wrapError.
| const newColDefs = colInfo.map((col) => { | ||
| const isTarget = col.name === columnName; | ||
| const type = isTarget ? columnType : col.type; | ||
| const nullable = isTarget ? isNullable : col.notnull === 0; | ||
| const defVal = isTarget ? defaultValue?.trim() || null : col.dflt_value; | ||
| const isPk = col.pk > 0; | ||
|
|
||
| let def = `"${col.name}" ${type}`; | ||
| if (!hasCompositePk && isPk) def += " PRIMARY KEY"; | ||
| if (!isPk && !nullable) def += " NOT NULL"; | ||
| if (defVal) def += ` DEFAULT ${defVal}`; | ||
| return def; | ||
| }); | ||
|
|
||
| if (hasCompositePk) { | ||
| newColDefs.push(`PRIMARY KEY (${pkCols.map((c) => `"${c.name}"`).join(", ")})`); | ||
| } | ||
|
|
||
| const fks = sqliteDb.prepare(`PRAGMA foreign_key_list("${tableName}")`).all() as FkRow[]; | ||
| const fksByGroup = new Map<number, FkRow[]>(); | ||
| for (const fk of fks) { | ||
| const arr = fksByGroup.get(fk.id) ?? []; | ||
| arr.push(fk); | ||
| fksByGroup.set(fk.id, arr); | ||
| } | ||
| const fkDefs = Array.from(fksByGroup.values()).map((group) => { | ||
| const from = group.map((f) => `"${f.from}"`).join(", "); | ||
| const to = group.map((f) => `"${f.to}"`).join(", "); | ||
| const { table, on_update, on_delete } = group[0]; | ||
| return `FOREIGN KEY (${from}) REFERENCES "${table}" (${to}) ON UPDATE ${on_update} ON DELETE ${on_delete}`; | ||
| }); | ||
|
|
||
| const colNames = colInfo.map((c) => `"${c.name}"`).join(", "); | ||
| const tempName = `${tableName}_alter_${Date.now()}`; | ||
|
|
||
| sqliteDb.pragma("foreign_keys = OFF"); | ||
| const doAlter = sqliteDb.transaction(() => { | ||
| sqliteDb | ||
| .prepare(`CREATE TABLE "${tempName}" (${[...newColDefs, ...fkDefs].join(", ")})`) | ||
| .run(); | ||
| sqliteDb | ||
| .prepare( | ||
| `INSERT INTO "${tempName}" (${colNames}) SELECT ${colNames} FROM "${tableName}"`, | ||
| ) | ||
| .run(); | ||
| sqliteDb.prepare(`DROP TABLE "${tableName}"`).run(); | ||
| sqliteDb.prepare(`ALTER TABLE "${tempName}" RENAME TO "${tableName}"`).run(); |
There was a problem hiding this comment.
Recreating the table here drops secondary schema objects.
This rebuild only preserves column definitions, PKs, defaults, and FKs from PRAGMA table_info/foreign_key_list. Existing UNIQUE, CHECK, collations, generated columns, indexes, and triggers are not recreated, so altering one column can silently weaken constraints and query plans on unrelated columns.
| const values = Object.values(data); | ||
| const colNames = columns.map((c) => `"${c}"`).join(", "); | ||
| const placeholders = columns.map(() => "?").join(", "); | ||
|
|
||
| try { | ||
| const result = sqliteDb | ||
| .prepare(`INSERT INTO "${tableName}" (${colNames}) VALUES (${placeholders})`) | ||
| .run(...values); |
There was a problem hiding this comment.
Serialize object/array values on insert paths the same way as updates.
updateRecords() stringifies object values before binding, but addRecord() and bulkInsertRecords() pass them through raw. That makes JSON/array columns inconsistent: updating works, while inserting the same payload can fail or persist a different representation.
Also applies to: 933-938
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/adapters/sqlite/sqlite.adapter.ts` around lines 758 -
765, addRecord() and bulkInsertRecords() currently bind object/array values raw
while updateRecords() stringifies them, causing inconsistent storage for
JSON/array columns; update the insert paths used in addRecord(),
bulkInsertRecords() (the code building values, colNames, placeholders and
calling sqliteDb.prepare(...).run(...)) to mirror updateRecords() by mapping
over Object.values(data) and JSON.stringify any value that is an object or array
before passing to .run(...), ensuring consistent serialization for object/array
types across inserts and updates.
| const col = `"${filter.columnName}"`; | ||
| switch (filter.operator) { |
There was a problem hiding this comment.
Escape or whitelist dynamic identifiers before interpolating them into SQL.
columnName/sortColumns are inserted verbatim between double quotes here. A crafted value containing " can break out of the identifier context and turn filter/sort/cursor clauses into injectable SQL.
🔒 Suggested hardening
+function quoteIdentifier(name: string): string {
+ return `"${name.replaceAll('"', '""')}"`;
+}
+
export function buildWhereClause(filters: FilterType[]): {
clause: string;
values: unknown[];
} {
@@
for (const filter of filters) {
- const col = `"${filter.columnName}"`;
+ const col = quoteIdentifier(filter.columnName);
@@
export function buildSortClause(sorts: SortType[] | string, order: SortDirection): string {
if (Array.isArray(sorts)) {
if (!sorts.length) return "";
- return `ORDER BY ${sorts.map((s) => `"${s.columnName}" ${s.direction.toUpperCase()}`).join(", ")}`;
+ return `ORDER BY ${sorts.map((s) => `${quoteIdentifier(s.columnName)} ${s.direction === "desc" ? "DESC" : "ASC"}`).join(", ")}`;
}
if (sorts && typeof sorts === "string") {
- return `ORDER BY "${sorts}" ${order?.toUpperCase() || "ASC"}`;
+ return `ORDER BY ${quoteIdentifier(sorts)} ${order === "desc" ? "DESC" : "ASC"}`;
}
@@
- const columnList = sortColumns.map((col) => `"${col}"`).join(", ");
+ const columnList = sortColumns.map((col) => quoteIdentifier(col)).join(", ");Also applies to: 65-71, 93-95
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/adapters/sqlite/sqlite.query-builder.ts` around lines 13
- 14, The code interpolates raw identifiers (e.g., filter.columnName and entries
in sortColumns) into quoted SQL identifiers (see the line that sets col =
`"${filter.columnName}"` and the other occurrences around sort/ cursor
construction), which allows injection if the value contains quotes; fix by
validating or escaping identifiers before interpolation: either
whitelist/validate each identifier against a safe pattern like
/^[A-Za-z_][A-Za-z0-9_]*$/ (reject or throw on invalid names) or escape double
quotes by replacing " with "" per SQLite identifier quoting rules, then use the
sanitized identifier in place of filter.columnName and in all uses of
sortColumns and cursor column interpolation. Ensure the same sanitization
utility is used consistently in the functions that build filters, sorts, and
cursor clauses (the places referenced around the col assignment and the
sorts/cursors).
| closeSqliteDb(): void { | ||
| if (this.sqliteDb) { | ||
| this.sqliteDb.close(); | ||
| this.sqliteDb = null; | ||
| } | ||
| } |
There was a problem hiding this comment.
SQLite handle is only closed in closeAll(), not in targeted close flows.
closePool() / closePoolByDatabase() still skip SQLite, so selective-close paths can leave the file handle open.
♻️ Proposed fix
async closePool(connectionString: string): Promise<void> {
+ if (this.baseConfig?.dbType === "sqlite" && connectionString === this.baseConfig.url) {
+ this.closeSqliteDb();
+ return;
+ }
await this.closePgPool(connectionString);
await this.closeMysqlPool(connectionString);
await this.closeMssqlPool(connectionString);
}
async closePoolByDatabase(database: string): Promise<void> {
+ if (this.baseConfig?.dbType === "sqlite") {
+ this.closeSqliteDb();
+ return;
+ }
const connectionString = this.buildConnectionString(database);
await this.closePool(connectionString);
}Also applies to: 419-419
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/server/src/db-manager.ts` around lines 165 - 170, The SQLite file
handle is only closed by closeSqliteDb() when closeAll() runs, but closePool()
and closePoolByDatabase() skip it and can leave the handle open; update
closePool() and closePoolByDatabase() to call closeSqliteDb() (or perform the
same sqliteDb?.close() and sqliteDb = null logic) whenever the pool being closed
corresponds to the SQLite database (match by the same database identifier used
in those methods), ensuring the sqliteDb is closed and nulled in selective-close
flows as well; keep closeSqliteDb() as the single implementation to avoid
duplication and call it from closePool()/closePoolByDatabase().
| if (normalized === "integer" || normalized === "int") return StandardizedDataType.int; | ||
| if (normalized === "bigint") return StandardizedDataType.bigint; | ||
| if (normalized === "smallint") return StandardizedDataType.smallint; | ||
| if (normalized === "tinyint") return StandardizedDataType.tinyint; | ||
| if (normalized === "mediumint") return StandardizedDataType.mediumint; | ||
| if (normalized.includes("int")) return StandardizedDataType.int; | ||
|
|
||
| if (normalized === "real" || normalized === "float") return StandardizedDataType.float; | ||
| if (normalized === "double" || normalized === "double precision") | ||
| return StandardizedDataType.double; |
There was a problem hiding this comment.
Parameterized SQLite numeric types can be mislabeled.
bigint(20) / smallint(6) fall through to includes("int") and become int, and parameterized float/double types may miss their specific labels.
🎯 Proposed fix
- if (normalized === "bigint") return StandardizedDataType.bigint;
- if (normalized === "smallint") return StandardizedDataType.smallint;
- if (normalized === "tinyint") return StandardizedDataType.tinyint;
- if (normalized === "mediumint") return StandardizedDataType.mediumint;
+ if (normalized === "bigint" || normalized.startsWith("bigint("))
+ return StandardizedDataType.bigint;
+ if (normalized === "smallint" || normalized.startsWith("smallint("))
+ return StandardizedDataType.smallint;
+ if (normalized === "tinyint" || normalized.startsWith("tinyint("))
+ return StandardizedDataType.tinyint;
+ if (normalized === "mediumint" || normalized.startsWith("mediumint("))
+ return StandardizedDataType.mediumint;
if (normalized.includes("int")) return StandardizedDataType.int;
- if (normalized === "real" || normalized === "float") return StandardizedDataType.float;
- if (normalized === "double" || normalized === "double precision")
+ if (normalized === "real" || normalized === "float" || normalized.startsWith("float("))
+ return StandardizedDataType.float;
+ if (
+ normalized === "double" ||
+ normalized === "double precision" ||
+ normalized.startsWith("double(")
+ )
return StandardizedDataType.double;Also applies to: 641-648
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/shared/src/types/column.type.ts` around lines 630 - 639, The
type-normalization logic misclassifies parameterized types like "bigint(20)" or
"smallint(6)" because the generic includes("int") branch runs first; update the
comparisons to match whole words or prefixes before the generic match (e.g., use
normalized.startsWith("bigint") / /^bigint(\(|$)/, and similarly for smallint,
tinyint, mediumint) and do the same for float/double (startsWith or regex to
catch "float(8)" / "double precision(10)"); ensure these checks still return the
correct StandardizedDataType values and keep the generic
normalized.includes("int") fallback last (apply the same change to the
corresponding float/double block referenced in the diff).
Summary by CodeRabbit