Skip to content

refactor: replace raw SQL duplication with col() helper for cross-DB quoting#2372

Merged
Yeraze merged 5 commits intomainfrom
refactor/drizzle-join-queries
Mar 22, 2026
Merged

refactor: replace raw SQL duplication with col() helper for cross-DB quoting#2372
Yeraze merged 5 commits intomainfrom
refactor/drizzle-join-queries

Conversation

@Yeraze
Copy link
Owner

@Yeraze Yeraze commented Mar 22, 2026

Summary

Three improvements in one PR:

1. Replace raw SQL duplication with col() helper

Adds BaseRepository.col(name) that returns correctly quoted column names per database backend. Replaces 4 isPostgres() branched queries in MiscRepository with unified single-path queries:

  • getPacketLogs — packet_log LEFT JOIN nodes (x2)
  • getPacketLogById — same double JOIN
  • getPacketCountsByNode — packet_log LEFT JOIN nodes with GROUP BY/COUNT
  • getDistinctRelayNodes — SELECT from nodes with bitwise WHERE

2. Fix missing PG/MySQL baseline columns (found by soak test)

  • messages.decrypted_by — PG/MySQL baselines omitted this column, causing DrizzleQueryError on any messages query
  • channel_database.enforceNameValidation — PG/MySQL baselines omitted this column
  • channel_database.sortOrder — PG/MySQL baselines omitted this column

3. Backend soak test

New test script (tests/backend-soak-test.sh) that launches each database backend in sequence, soaks for a configurable duration, and monitors logs for errors. This is how the missing column bugs were discovered.

Files Changed

File Change
src/db/repositories/base.ts Add col() helper + sql import
src/db/repositories/misc.ts Replace 4 branched queries with unified col() versions
src/db/repositories/misc.packetlog.test.ts New — 9 tests for refactored JOIN queries
src/server/migrations/014_add_messages_decrypted_by.ts New — migration for messages + channel_database columns
src/db/migrations.ts Register migration 014
src/db/migrations.test.ts Update registry count/last migration
CLAUDE.md Update migration count to 14
tests/backend-soak-test.sh New — backend soak test
.gitignore Add tests/soak-logs/

Test plan

  • 3061 tests pass, 0 failures (9 new)
  • Build clean
  • Backend soak test: all 3 backends pass (SQLite, PostgreSQL, MySQL)

🤖 Generated with Claude Code

Yeraze and others added 2 commits March 21, 2026 23:39
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…quoting

Adds BaseRepository.col(name) helper that returns correctly quoted column
names (PostgreSQL: "camelCase", SQLite/MySQL: bare identifiers). Replaces
4 isPostgres() branched queries in misc.ts with single unified queries:

- getPacketLogs: packet_log LEFT JOIN nodes (x2)
- getPacketLogById: same double JOIN
- getPacketCountsByNode: packet_log LEFT JOIN nodes with GROUP BY
- getDistinctRelayNodes: SELECT from nodes with bitwise WHERE

Removes ~40 lines of duplicated SQL. Adds 9 new tests verifying JOIN
queries return correct node names, handle unknown nodes, respect
pagination, and maintain sort order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* PostgreSQL requires double-quoted "camelCase" identifiers; SQLite/MySQL do not.
* Returns a raw SQL fragment that can be interpolated into sql`` templates.
*/
protected col(name: string) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Excellent helper method design!

This col() method elegantly solves the column quoting differences between databases:

  • PostgreSQL requires double quotes for camelCase identifiers
  • SQLite/MySQL work without quotes
  • Uses sql.raw() for safe interpolation without SQL injection risks

The method is simple, well-documented, and available to all repository classes. Perfect foundation for eliminating SQL duplication across the codebase.

ORDER BY pl.timestamp DESC, pl.created_at DESC LIMIT ${limit} OFFSET ${offset}
`;
}
const longName = this.col('longName');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Great query unification!

This refactoring successfully eliminates the PostgreSQL vs SQLite/MySQL branching that existed here previously. The col() helper handles the column quoting differences transparently.

Before: ~20 lines of duplicated SQL with isPostgres() conditionals
After: Single clean query using the helper

The JOIN logic and result processing remain identical, maintaining full compatibility.

for (const rv of relayValues) {
const matchRows = await this.executeQuery(sql.raw(`${matchQuery}${rv}`));
const matchRows = await this.executeQuery(
sql`SELECT ${longName}, ${shortName} FROM nodes WHERE (${nodeNum} & 255) = ${rv}`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smart bitwise operation handling

The bitwise WHERE clause (${nodeNum} & 255) = ${rv} correctly extracts the last byte for relay node matching per the Meshtastic protobuf spec. Using col() ensures the column reference works across all database backends.

This query demonstrates how the helper method enables complex operations while maintaining cross-DB compatibility.

db.close();
});

describe('getPacketLogs', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Comprehensive test coverage

These tests provide excellent validation for the refactored JOIN queries:

Core Functionality: Verifies node name joins work correctly
Edge Cases: Tests unknown node handling (NULL longName)
Pagination: Validates limit/offset behavior
Sort Order: Confirms timestamp DESC + created_at DESC ordering

The test setup properly creates both nodes and packet_log tables with realistic data, ensuring the JOIN operations are thoroughly validated.


## Risk

Low — method signatures and return types don't change. The Drizzle query builder generates the same SQL that's currently hardcoded, just with correct quoting per backend. If any query doesn't translate cleanly to the Drizzle builder, we keep the raw SQL but use Drizzle column references for auto-quoting instead of full builder conversion.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📖 Excellent design documentation

This design spec demonstrates thorough planning:

Clear scope definition - Phase 1 focuses only on column quoting differences
Risk assessment - Correctly identifies this as low-risk due to unchanged signatures
Detailed analysis - Lists all 4 queries with line numbers and complexity
Implementation strategy - Explains the col() helper approach with examples

The fallback strategy mentioned (keep raw SQL with Drizzle column references) shows good contingency planning, though the actual implementation successfully used the builder approach.

Yeraze and others added 3 commits March 22, 2026 08:44
Launches SQLite, PostgreSQL, and MySQL in sequence, monitors logs for
errors during a configurable soak period (default 5 minutes each).
Logs saved to tests/soak-logs/ for post-test review.

Usage: tests/backend-soak-test.sh [duration_seconds]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The PostgreSQL and MySQL baseline migrations omitted the decrypted_by
column from the messages CREATE TABLE, causing DrizzleQueryError on
any SELECT from messages. SQLite baseline had it correctly.

Found by the new backend soak test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migration 014 now also adds enforceNameValidation and sortOrder to
channel_database for PostgreSQL and MySQL — both were omitted from
the v3.7 baseline. Also improved soak test exclude patterns for
expected ECONNREFUSED errors in dev environment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* PostgreSQL requires double-quoted "camelCase" identifiers; SQLite/MySQL do not.
* Returns a raw SQL fragment that can be interpolated into sql`` templates.
*/
protected col(name: string) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Excellent helper method design!

This col() method elegantly solves the column quoting differences between databases:

  • PostgreSQL requires double quotes for camelCase identifiers
  • SQLite/MySQL work without quotes
  • Uses sql.raw() for safe interpolation without SQL injection risks

The method is simple, well-documented, and available to all repository classes. Perfect foundation for eliminating SQL duplication across the codebase.

ORDER BY pl.timestamp DESC, pl.created_at DESC LIMIT ${limit} OFFSET ${offset}
`;
}
const longName = this.col('longName');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Great query unification!

This refactoring successfully eliminates the PostgreSQL vs SQLite/MySQL branching that existed here previously. The col() helper handles the column quoting differences transparently.

Before: ~20 lines of duplicated SQL with isPostgres() conditionals
After: Single clean query using the helper

The JOIN logic and result processing remain identical, maintaining full compatibility.

for (const rv of relayValues) {
const matchRows = await this.executeQuery(sql.raw(`${matchQuery}${rv}`));
const matchRows = await this.executeQuery(
sql`SELECT ${longName}, ${shortName} FROM nodes WHERE (${nodeNum} & 255) = ${rv}`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smart bitwise operation handling

The bitwise WHERE clause (${nodeNum} & 255) = ${rv} correctly extracts the last byte for relay node matching per the Meshtastic protobuf spec. Using col() ensures the column reference works across all database backends.

This query demonstrates how the helper method enables complex operations while maintaining cross-DB compatibility.

db.close();
});

describe('getPacketLogs', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Comprehensive test coverage

These tests provide excellent validation for the refactored JOIN queries:

Core Functionality: Verifies node name joins work correctly
Edge Cases: Tests unknown node handling (NULL longName)
Pagination: Validates limit/offset behavior
Sort Order: Confirms timestamp DESC + created_at DESC ordering

The test setup properly creates both nodes and packet_log tables with realistic data, ensuring the JOIN operations are thoroughly validated.

Comment on lines +78 to +82
- Build verification (TypeScript clean)

## Risk

Low — method signatures and return types don't change. The Drizzle query builder generates the same SQL that's currently hardcoded, just with correct quoting per backend. If any query doesn't translate cleanly to the Drizzle builder, we keep the raw SQL but use Drizzle column references for auto-quoting instead of full builder conversion.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📖 Excellent design documentation

This design spec demonstrates thorough planning:

Clear scope definition - Phase 1 focuses only on column quoting differences
Risk assessment - Correctly identifies this as low-risk due to unchanged signatures
Detailed analysis - Lists all 4 queries with line numbers and complexity
Implementation strategy - Explains the col() helper approach with examples

The fallback strategy mentioned (keep raw SQL with Drizzle column references) shows good contingency planning, though the actual implementation successfully used the builder approach.

@Yeraze Yeraze merged commit 22be3b8 into main Mar 22, 2026
17 checks passed
@Yeraze Yeraze deleted the refactor/drizzle-join-queries branch March 22, 2026 13:55
@Yeraze Yeraze mentioned this pull request Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant