Skip to content

Commit 4aba547

Browse files
committed
fix(scripts): make the sql Date-binding audit precise and crash-proof
Resolve the drizzle `sql` tag from its import binding, scope Date bindings lexically, tolerate unparseable files, accept the allow annotation above a multi-line template, and scan the root scripts directory.
1 parent 93b68f0 commit 4aba547

2 files changed

Lines changed: 491 additions & 93 deletions

File tree

scripts/check-sql-date-binding.test.ts

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { describe, expect, test } from 'bun:test'
2-
import { findSqlDateBindingViolations } from './check-sql-date-binding'
2+
import { analyzeSource, findSqlDateBindingViolations, SCAN_DIRS } from './check-sql-date-binding'
3+
4+
const DRIZZLE_IMPORT = "import { sql } from 'drizzle-orm'"
35

46
describe('sql Date binding audit', () => {
57
test('rejects every unbound Date form that reaches a raw template', () => {
68
const violations = findSqlDateBindingViolations(`
9+
${DRIZZLE_IMPORT}
710
const now = new Date()
811
const threshold = new Date(now.getTime() - 1000)
912
const alias = threshold
@@ -34,6 +37,7 @@ describe('sql Date binding audit', () => {
3437
test('accepts column-bound params, non-Date values, and annotated exceptions', () => {
3538
expect(
3639
findSqlDateBindingViolations(`
40+
${DRIZZLE_IMPORT}
3741
const now = new Date()
3842
const bound = sql\`col < \${sql.param(now, asyncJobs.startedAt)}\`
3943
const fragment = sql\`col < \${sql.param(new Date(), table.createdAt)}\`
@@ -48,6 +52,7 @@ describe('sql Date binding audit', () => {
4852

4953
test('rejects annotation markers that are malformed or incidental', () => {
5054
const violations = findSqlDateBindingViolations(`
55+
${DRIZZLE_IMPORT}
5156
const now = new Date()
5257
// sql-date-bound:
5358
const bareMarker = sql\`col < \${now}\`
@@ -59,4 +64,168 @@ describe('sql Date binding audit', () => {
5964

6065
expect(violations.map((violation) => violation.expression)).toEqual(['now', 'now', 'now'])
6166
})
67+
68+
describe('tag resolution', () => {
69+
test('ignores a postgres-js client tag that happens to be named sql', () => {
70+
expect(
71+
findSqlDateBindingViolations(`
72+
import postgres from 'postgres'
73+
const sql = postgres(process.env.DATABASE_URL)
74+
const now = new Date()
75+
const rows = await sql\`select * from runs where started_at < \${now}\`
76+
`)
77+
).toEqual([])
78+
})
79+
80+
test('flags an aliased drizzle import', () => {
81+
expect(
82+
findSqlDateBindingViolations(`
83+
import { sql as raw } from 'drizzle-orm'
84+
const now = new Date()
85+
const fragment = raw\`col < \${now}\`
86+
`).map((violation) => violation.expression)
87+
).toEqual(['now'])
88+
})
89+
90+
test('flags a namespace-imported drizzle tag and its param helper', () => {
91+
expect(
92+
findSqlDateBindingViolations(`
93+
import * as d from 'drizzle-orm'
94+
const now = new Date()
95+
const fragment = d.sql\`col < \${now}\`
96+
const unencoded = d.sql.param(now)
97+
`).map((violation) => violation.expression)
98+
).toEqual(['now', 'now'])
99+
})
100+
})
101+
102+
describe('scoping', () => {
103+
test('does not let an interface field poison identifiers elsewhere in the file', () => {
104+
expect(
105+
findSqlDateBindingViolations(`
106+
${DRIZZLE_IMPORT}
107+
interface Row {
108+
start: Date
109+
}
110+
function query(start: number) {
111+
return sql\`limit \${start}\`
112+
}
113+
`)
114+
).toEqual([])
115+
})
116+
117+
test('does not let a Date in one function bind a same-named value in another', () => {
118+
expect(
119+
findSqlDateBindingViolations(`
120+
${DRIZZLE_IMPORT}
121+
function stamp() {
122+
const now = new Date()
123+
return now.toISOString()
124+
}
125+
function paginate() {
126+
const now = Date.now()
127+
return sql\`created_at_ms < \${now}\`
128+
}
129+
`)
130+
).toEqual([])
131+
})
132+
133+
test('still flags a module-scope Date used inside a function', () => {
134+
expect(
135+
findSqlDateBindingViolations(`
136+
${DRIZZLE_IMPORT}
137+
const now = new Date()
138+
function paginate() {
139+
return sql\`created_at < \${now}\`
140+
}
141+
`).map((violation) => violation.expression)
142+
).toEqual(['now'])
143+
})
144+
145+
test('still flags a destructured Date parameter typed inline', () => {
146+
expect(
147+
findSqlDateBindingViolations(`
148+
${DRIZZLE_IMPORT}
149+
function query({ since }: { since: Date }) {
150+
return sql\`col < \${since}\`
151+
}
152+
`).map((violation) => violation.expression)
153+
).toEqual(['since'])
154+
})
155+
156+
test('still flags a destructured Date parameter typed by a named interface', () => {
157+
expect(
158+
findSqlDateBindingViolations(`
159+
${DRIZZLE_IMPORT}
160+
interface Range {
161+
since: Date
162+
}
163+
function query({ since }: Range) {
164+
return sql\`col < \${since}\`
165+
}
166+
`).map((violation) => violation.expression)
167+
).toEqual(['since'])
168+
})
169+
})
170+
171+
describe('allow annotation placement', () => {
172+
test('accepts the annotation above a multi-line template', () => {
173+
expect(
174+
findSqlDateBindingViolations(`
175+
${DRIZZLE_IMPORT}
176+
const now = new Date()
177+
// sql-date-bound: text column, compared as an ISO string
178+
const fragment = sql\`
179+
CASE
180+
WHEN started_at < \${now} THEN 1
181+
ELSE 0
182+
END
183+
\`
184+
`)
185+
).toEqual([])
186+
})
187+
188+
test('still rejects a bare marker above a multi-line template', () => {
189+
expect(
190+
findSqlDateBindingViolations(`
191+
${DRIZZLE_IMPORT}
192+
const now = new Date()
193+
// sql-date-bound:
194+
const fragment = sql\`
195+
CASE
196+
WHEN started_at < \${now} THEN 1
197+
END
198+
\`
199+
`).map((violation) => violation.expression)
200+
).toEqual(['now'])
201+
})
202+
})
203+
204+
test('scans the root scripts directory', () => {
205+
expect(SCAN_DIRS.some((dir) => dir.endsWith('/scripts'))).toBe(true)
206+
})
207+
208+
describe('parser robustness', () => {
209+
test('parses decorators instead of throwing', () => {
210+
const analysis = analyzeSource(`
211+
${DRIZZLE_IMPORT}
212+
@Injectable()
213+
class Repo {
214+
find(since: Date) {
215+
return sql\`col < \${since}\`
216+
}
217+
}
218+
`)
219+
220+
expect(analysis.parseError).toBeUndefined()
221+
expect(analysis.violations.map((violation) => violation.expression)).toEqual(['since'])
222+
})
223+
224+
test('reports an unparseable file as skipped instead of throwing', () => {
225+
const analysis = analyzeSource('const a = (', 'broken.ts')
226+
227+
expect(analysis.parseError).toBeTruthy()
228+
expect(analysis.violations).toEqual([])
229+
})
230+
})
62231
})

0 commit comments

Comments
 (0)