Skip to content

Commit b7b526b

Browse files
rwdaigleclaude
andauthored
Fix SQL extraction: named columns and import aliases (#32)
* Fix SQL extraction: handle named column selection and import aliases Add two deterministic fixes to improve SQL extraction accuracy: 1. Extract specific columns from .select({ col1: table.col1 }) patterns instead of always emitting SELECT * 2. Resolve import aliases (e.g., import { tasks as tasksTable }) back to the actual SQL table name before generating queries All fixes tested with new test cases covering both issues in isolation and in combination. All 177 tests pass. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * Bump version to 0.8.1 for SQL extraction accuracy fix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Rewrite README for clarity and usability Restructure around the two main workflows (running checks and extracting SQL) with concise option tables, check summaries, and realistic example output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 661e6df commit b7b526b

File tree

8 files changed

+264
-105
lines changed

8 files changed

+264
-105
lines changed

README.md

Lines changed: 94 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,129 @@
11
# roto-rooter
22

3-
Static analysis and functional verifier tool for React Router applications.
3+
A static analysis tool for [React Router](https://reactrouter.com/) apps. It catches common bugs -- broken links, missing loaders, hydration mismatches, disconnected UI elements, and incorrect database operations -- by reading your route definitions and cross-referencing them against your components.
44

5-
## Installation
6-
7-
```bash
5+
```
86
npm install -g roto-rooter
97
```
108

11-
## Usage
9+
## Running Checks
1210

13-
```bash
14-
# Check all files in current directory
15-
rr
11+
```
12+
rr [OPTIONS] [FILES...]
13+
```
1614

17-
# Check specific file(s)
18-
rr app/routes/employees.tsx
15+
Point `rr` at your project and it scans your route files for issues. With no arguments it runs the **default checks** (links, loader, params, interactivity) against all files in the current directory.
1916

20-
# Run specific checks only
21-
rr --check links,forms
17+
| Option | Description |
18+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19+
| `-c, --check <checks>` | Comma-separated checks to run. Use `defaults` for the default set, `all` for everything, or pick individual checks: `links`, `loader`, `params`, `interactivity`, `forms`, `hydration`, `drizzle` |
20+
| `-f, --format <format>` | Output format: `text` (default) or `json` |
21+
| `-r, --root <path>` | Project root containing the `app/` folder (default: cwd) |
22+
| `--fix` | Auto-fix issues where possible |
23+
| `--dry-run` | Preview fixes without writing files |
24+
| `--drizzle-schema <path>` | Path to Drizzle schema file (auto-discovered by default) |
2225

23-
# Run all checks (including optional ones)
24-
rr --check all
26+
**Checks at a glance:**
2527

26-
# Run default checks plus specific optional checks
27-
rr --check defaults,forms
28+
- **links** (default) -- validates `<Link>`, `redirect()`, `navigate()` targets exist as routes
29+
- **loader** (default) -- ensures `useLoaderData()` is backed by a loader; catches `clientLoader` importing server-only modules
30+
- **params** (default) -- ensures `useParams()` only accesses params defined in the route path
31+
- **interactivity** (default) -- catches "Save" buttons that don't save, "Delete" buttons that don't delete, and empty click handlers
32+
- **forms** (opt-in) -- validates `<Form>` targets have actions and that field names match `formData.get()` calls
33+
- **hydration** (opt-in) -- detects SSR/client mismatches from `new Date()`, `Math.random()`, `window` access in render
34+
- **drizzle** (opt-in) -- validates Drizzle ORM operations against your schema (missing columns, type mismatches, etc.)
2835

29-
# Output as JSON
30-
rr --format json
36+
**Example output:**
3137

32-
# Set project root (the directory containing the app/ folder)
33-
rr --root ./my-app
38+
```
39+
$ rr --root my-app
40+
41+
rr found 5 issues:
3442
35-
# Automatically fix issues where possible
36-
rr --fix
43+
[error] dashboard.tsx:12:7
44+
href="/employeees"
45+
x No matching route
46+
-> Did you mean: /employees?
3747
38-
# Preview fixes without applying
39-
rr --dry-run
48+
[error] tasks.tsx:6:16
49+
useLoaderData()
50+
x useLoaderData() called but route has no loader
51+
-> Add a loader function or remove the hook
4052
41-
# Fix specific file(s)
42-
rr --fix app/routes/dashboard.tsx
53+
[error] employees.$id.edit.tsx:7:32
54+
useParams().invalidParam
55+
x useParams() accesses "invalidParam" but route has no :invalidParam parameter
56+
-> Available params: :id
4357
44-
# Enable Drizzle ORM persistence checking (auto-discovers schema)
45-
rr --check drizzle
58+
[error] disconnected-dialog.tsx:27:11
59+
<Button onClick={...}>Save Changes</Button>
60+
x "Save Changes" button in Dialog only closes dialog without saving data
61+
-> Wrap inputs in a <Form> component or use useFetcher.submit() to persist data
4662
47-
# Drizzle checking with explicit schema path
48-
rr --check drizzle --drizzle-schema src/db/schema.ts
63+
[warning] disconnected-dialog.tsx:78:11
64+
<Button onClick={...}>Add Item</Button>
65+
x "Add Item" button has an empty or stub onClick handler
66+
-> Implement the handler or remove the button if not needed
4967
50-
# Extract SQL queries from Drizzle ORM code
51-
rr sql --drizzle
68+
Summary: 4 errors, 1 warning
69+
Run with --help for options.
70+
```
5271

53-
# Extract queries from a specific file
54-
rr sql --drizzle app/routes/users.tsx
72+
## Extracting SQL
5573

56-
# SQL output as JSON
57-
rr sql --drizzle --format json
5874
```
75+
rr sql --drizzle [OPTIONS] [FILES...]
76+
```
77+
78+
Reads your Drizzle ORM code and prints the equivalent SQL for every query it finds. Useful for reviewing what your app actually sends to the database.
79+
80+
| Option | Description |
81+
| ------------------------- | -------------------------------------------------------- |
82+
| `--drizzle` | Required. Specifies the ORM to analyze. |
83+
| `-f, --format <format>` | Output format: `text` (default) or `json` |
84+
| `-r, --root <path>` | Project root directory (default: cwd) |
85+
| `--drizzle-schema <path>` | Path to Drizzle schema file (auto-discovered by default) |
86+
87+
**Example output:**
5988

60-
## Checks
61-
62-
**Default checks** (run automatically):
63-
64-
- **links**: Validates `<Link>`, `redirect()`, and `navigate()` targets exist as defined routes. Suggests closest matching route when a typo is detected. Auto-fixable when a close match exists.
65-
- **loader**: Validates `useLoaderData()` is only used in routes that export a loader function. Detects `clientLoader`/`clientAction` that import server-only modules (database drivers, `fs`, etc.) which will fail in the browser. Auto-fixable by renaming to `loader`/`action`.
66-
- **params**: Validates `useParams()` accesses only params defined in the route path (e.g., `:id` in `/users/:id`).
67-
- **interactivity**: Detects disconnected interactive elements:
68-
- Dialog/modal forms where "Save" button only closes the dialog without persisting data
69-
- "Delete" confirmation buttons that only close without performing the action
70-
- Buttons with empty or stub onClick handlers (console.log only)
71-
- Validates dialogs use React Router `<Form>` or `useFetcher.submit()` for data operations
72-
73-
**Optional checks** (opt-in via `--check`):
74-
75-
- **forms**: Validates `<Form>` components submit to routes with action exports, and that form fields match what the action reads via `formData.get()`. Supports intent-based dispatch patterns. Auto-fixable when targeting a mistyped route.
76-
- **hydration**: Detects SSR hydration mismatch risks:
77-
- Date/time operations without consistent timezone handling
78-
- Locale-dependent formatting (e.g., `toLocaleString()`) without explicit `timeZone` option
79-
- Random value generation during render (`Math.random()`, `uuid()`, `nanoid()`)
80-
- Browser-only API access outside `useEffect` (`window`, `document`, `localStorage`)
81-
82-
Some hydration issues are auto-fixable (e.g., adding `{ timeZone: "UTC" }` to locale methods, replacing `uuid()` with `useId()`).
83-
84-
- **drizzle** (persistence): Validates database operations against Drizzle ORM schema. Auto-discovers schema from common locations (`db/schema.ts`, `src/db/schema.ts`, etc.) or use `--drizzle-schema` for custom paths.
85-
- Unknown table or column references in `db.insert()`, `db.update()`, `db.delete()`
86-
- Missing required columns on `db.insert()` calls
87-
- Null literal assigned to `notNull` column (insert or update)
88-
- Invalid enum literal values (checked against schema-defined allowed values)
89-
- Type mismatches: string from `formData.get()` to integer, boolean, timestamp, or json column
90-
- Writing to auto-generated columns (e.g., serial, auto-increment) on insert
91-
- `DELETE` or `UPDATE` without `.where()` clause (affects all rows)
92-
- Enum columns receiving unvalidated external input
93-
94-
## SQL Query Extraction
95-
96-
The `rr sql` command extracts database queries from ORM code and generates equivalent SQL statements.
97-
98-
```bash
99-
rr sql --drizzle # extract all SQL queries
100-
rr sql --drizzle app/routes/users.tsx # extract from specific file
101-
rr sql --drizzle --format json # JSON output
102-
rr sql --drizzle --drizzle-schema db/schema.ts # explicit schema path
10389
```
90+
$ rr sql --drizzle --root my-app
91+
92+
Found 6 SQL queries:
10493
105-
Supports SELECT, INSERT, UPDATE, and DELETE patterns with parameterized queries and column type inference from the schema.
94+
File: app/routes/users.tsx:13:26
95+
SELECT * FROM users
10696
107-
## Programmatic API
97+
File: app/routes/users.tsx:16:9
98+
SELECT id, name, email FROM users
10899
109-
```typescript
110-
import { analyze, applyFixes } from 'roto-rooter';
100+
File: app/routes/users.tsx:24:29
101+
SELECT * FROM users WHERE status = 'active'
111102
112-
// Run analysis
113-
const result = analyze({
114-
root: './my-app',
115-
files: [], // empty = all files
116-
checks: [], // empty = all checks
117-
format: 'text',
118-
});
103+
File: app/routes/users.tsx:36:9
104+
INSERT INTO users (name, email, status) VALUES ($1, $2, $3)
105+
Parameters:
106+
$1: name (text)
107+
$2: email (text)
108+
$3: status (enum)
119109
120-
console.log(result.issues);
110+
File: app/routes/orders.tsx:16:9
111+
INSERT INTO orders (status, user_id, total) VALUES ($1, $2, $3)
112+
Parameters:
113+
$1: status (enum)
114+
$2: userId (integer)
115+
$3: total (integer)
121116
122-
// Apply auto-fixes
123-
const fixResult = applyFixes(result.issues);
124-
console.log(`Fixed ${fixResult.fixesApplied} issues`);
117+
File: app/routes/users.tsx:42:9
118+
DELETE FROM users WHERE id = $1
119+
Parameters:
120+
$1: Number(params.id) (serial)
125121
```
126122

127123
## Development
128124

129-
```bash
130-
npm install # Install dependencies
131-
npm test # Run tests
132-
npm run build # Build for distribution
125+
```
126+
npm install # install dependencies
127+
npm test # run tests
128+
npm run build # build for distribution
133129
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "roto-rooter",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "Static analysis and functional verifier tool for React Router applications",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

src/sql/drizzle.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ function extractQueriesFromFile(
9292
// Track db variable names (could be imported as different names)
9393
const dbVariables = new Set<string>(['db']);
9494

95-
// First pass: find db imports/variables
95+
// Build import alias map: local name -> original export name
96+
// e.g., import { tasks as tasksTable } -> "tasksTable" -> "tasks"
97+
const importAliases = new Map<string, string>();
98+
99+
// First pass: find db imports/variables and import aliases
96100
walkAst(sourceFile, (node) => {
97101
if (ts.isImportDeclaration(node) && node.importClause?.namedBindings) {
98102
if (ts.isNamedImports(node.importClause.namedBindings)) {
@@ -103,6 +107,10 @@ function extractQueriesFromFile(
103107
) {
104108
dbVariables.add(element.name.text);
105109
}
110+
if (element.propertyName) {
111+
// { original as alias } -> map alias to original
112+
importAliases.set(element.name.text, element.propertyName.text);
113+
}
106114
}
107115
}
108116
}
@@ -127,7 +135,8 @@ function extractQueriesFromFile(
127135
filePath,
128136
content,
129137
schema,
130-
dbVariables
138+
dbVariables,
139+
importAliases
131140
);
132141
if (query) {
133142
queries.push(query);
@@ -156,7 +165,8 @@ function parseDbQuery(
156165
filePath: string,
157166
content: string,
158167
schema: DrizzleSchema,
159-
dbVariables: Set<string>
168+
dbVariables: Set<string>,
169+
importAliases: Map<string, string>
160170
): ExtractedQuery | undefined {
161171
let expr: ts.Node = node;
162172
if (ts.isAwaitExpression(expr)) {
@@ -179,6 +189,15 @@ function parseDbQuery(
179189
node.getEnd()
180190
);
181191

192+
// Resolve import aliases for table name (e.g., tasksTable -> tasks)
193+
chainInfo.tableName =
194+
importAliases.get(chainInfo.tableName) || chainInfo.tableName;
195+
if (chainInfo.joins) {
196+
for (const join of chainInfo.joins) {
197+
join.table = importAliases.get(join.table) || join.table;
198+
}
199+
}
200+
182201
const query = generateSql(chainInfo, schema);
183202
if (!query) {
184203
return undefined;
@@ -265,6 +284,12 @@ function analyzeDbChain(
265284

266285
for (const { method, args } of chain) {
267286
switch (method) {
287+
case 'select':
288+
if (args.length > 0 && ts.isObjectLiteralExpression(args[0])) {
289+
info.selectColumns = extractSelectColumns(args[0]);
290+
}
291+
break;
292+
268293
case 'from':
269294
if (args.length > 0 && !info.tableName) {
270295
info.tableName = getTableNameFromArg(args[0]);
@@ -380,6 +405,27 @@ function getTableNameFromArg(arg: ts.Expression): string {
380405
return '';
381406
}
382407

408+
function extractSelectColumns(arg: ts.ObjectLiteralExpression): string[] {
409+
const columns: string[] = [];
410+
411+
for (const prop of arg.properties) {
412+
if (ts.isPropertyAssignment(prop)) {
413+
const key = ts.isIdentifier(prop.name)
414+
? prop.name.text
415+
: ts.isStringLiteral(prop.name)
416+
? prop.name.text
417+
: undefined;
418+
if (key) {
419+
columns.push(key);
420+
}
421+
} else if (ts.isShorthandPropertyAssignment(prop)) {
422+
columns.push(prop.name.text);
423+
}
424+
}
425+
426+
return columns;
427+
}
428+
383429
function extractObjectValues(arg: ts.Expression): Map<string, ValueInfo> {
384430
const values = new Map<string, ValueInfo>();
385431

test/fixtures/sample-app/app/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default [
3131
route('/delete-no-where', 'routes/delete-no-where.tsx'),
3232
route('/update-no-where', 'routes/update-no-where.tsx'),
3333
route('/delete-with-where', 'routes/delete-with-where.tsx'),
34-
route('/import-alias', 'routes/import-alias.tsx'),
34+
route('/import-alias/:id?', 'routes/import-alias.tsx'),
3535
// Interactivity check fixtures
3636
route('/disconnected-dialog', 'routes/disconnected-dialog.tsx'),
3737
route('/connected-dialog', 'routes/connected-dialog.tsx'),

test/fixtures/sample-app/app/routes/import-alias.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,29 @@
11
// Test fixture: Import aliases for table names
22
// import { users as usersTable } should resolve to the 'users' table in schema
33

4-
import { ActionFunctionArgs } from 'react-router';
4+
import { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
55
import { db } from '~/db';
66
import { users as usersTable } from '~/db/schema';
7+
import { eq } from 'drizzle-orm';
8+
9+
export async function loader({ params }: LoaderFunctionArgs) {
10+
// SELECT with aliased table name - should resolve to 'users'
11+
const allUsers = await db.select().from(usersTable);
12+
13+
// SELECT specific columns with aliased table
14+
const userNames = await db
15+
.select({ id: usersTable.id, name: usersTable.name })
16+
.from(usersTable);
17+
18+
// SELECT with where using aliased table
19+
const user = await db
20+
.select()
21+
.from(usersTable)
22+
.where(eq(usersTable.id, Number(params.id)))
23+
.limit(1);
24+
25+
return { allUsers, userNames, user };
26+
}
727

828
export async function action({ request }: ActionFunctionArgs) {
929
const formData = await request.formData();

test/fixtures/sample-app/app/routes/sql-operations.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export async function loader({ params }: LoaderFunctionArgs) {
1212
// Simple select all
1313
const allUsers = await db.select().from(users);
1414

15+
// Select specific columns
16+
await db
17+
.select({ id: users.id, name: users.name, email: users.email })
18+
.from(users);
19+
20+
// Select single column
21+
await db.select({ id: users.id }).from(users);
22+
1523
// Select with where clause
1624
const activeUsers = await db
1725
.select()

0 commit comments

Comments
 (0)