1+ import * as fs from 'fs' ;
2+ import ts from 'typescript' ;
13import type {
24 AnalyzerIssue ,
35 DrizzleSchema ,
@@ -14,6 +16,7 @@ import {
1416 isNumericColumn ,
1517 isEnumColumn ,
1618} from '../parsers/drizzle-schema-parser.js' ;
19+ import { parseFile , walkAst , getLineAndColumn } from '../utils/ast-utils.js' ;
1720
1821/**
1922 * Check persistence operations against Drizzle schema
@@ -37,6 +40,9 @@ export function checkPersistence(
3740 }
3841 }
3942
43+ // Check for aggregate type assertion issues (sql<number> with COUNT/SUM/etc.)
44+ issues . push ( ...checkAggregateTypeAssertions ( files ) ) ;
45+
4046 return issues ;
4147}
4248
@@ -407,3 +413,112 @@ function checkTypeMismatch(
407413
408414 return undefined ;
409415}
416+
417+ // ============================================================================
418+ // Aggregate Type Assertion Check
419+ // ============================================================================
420+
421+ const AGGREGATE_PATTERN = / \b ( S U M | A V G | C O U N T | M I N | M A X ) \s * \( / i;
422+
423+ /**
424+ * Check for sql<number> tagged templates containing aggregate functions.
425+ * Databases return aggregate results as strings, so sql<number>`COUNT(*)`
426+ * will silently return "123" instead of 123.
427+ */
428+ function checkAggregateTypeAssertions ( files : string [ ] ) : AnalyzerIssue [ ] {
429+ const issues : AnalyzerIssue [ ] = [ ] ;
430+
431+ for ( const file of files ) {
432+ try {
433+ const content = fs . readFileSync ( file , 'utf-8' ) ;
434+ const sourceFile = parseFile ( file , content ) ;
435+
436+ walkAst ( sourceFile , ( node ) => {
437+ if ( ! ts . isTaggedTemplateExpression ( node ) ) return ;
438+
439+ const tag = node . tag ;
440+ if ( ! ts . isIdentifier ( tag ) || tag . text !== 'sql' ) return ;
441+
442+ const typeArgs = node . typeArguments ;
443+ if ( ! typeArgs || typeArgs . length === 0 ) return ;
444+
445+ if ( ! typeContainsNumber ( typeArgs [ 0 ] ) ) return ;
446+
447+ const templateText = extractRawTemplateText ( node . template ) ;
448+ if ( ! templateText ) return ;
449+
450+ const match = templateText . match ( AGGREGATE_PATTERN ) ;
451+ if ( ! match ) return ;
452+
453+ const aggFunc = match [ 1 ] . toUpperCase ( ) ;
454+ const loc = getLineAndColumn ( sourceFile , node . getStart ( sourceFile ) ) ;
455+ const snippet = content
456+ . slice ( node . getStart ( sourceFile ) , node . getEnd ( ) )
457+ . replace ( / \s + / g, ' ' )
458+ . trim ( ) ;
459+
460+ issues . push ( {
461+ category : 'drizzle' ,
462+ severity : 'error' ,
463+ message : `sql<number> with ${ aggFunc } () -- database returns string, not number` ,
464+ location : { file, line : loc . line , column : loc . column } ,
465+ code : snippet . length > 120 ? snippet . slice ( 0 , 117 ) + '...' : snippet ,
466+ suggestion : `Cast the result: Number(result) or use sql<string> and convert explicitly` ,
467+ } ) ;
468+ } ) ;
469+ } catch {
470+ // Skip unparseable files
471+ }
472+ }
473+
474+ return issues ;
475+ }
476+
477+ /**
478+ * Check if a TypeNode contains the `number` type
479+ */
480+ function typeContainsNumber ( typeNode : ts . TypeNode ) : boolean {
481+ if ( typeNode . kind === ts . SyntaxKind . NumberKeyword ) {
482+ return true ;
483+ }
484+
485+ if ( ts . isUnionTypeNode ( typeNode ) ) {
486+ return typeNode . types . some ( ( t ) => typeContainsNumber ( t ) ) ;
487+ }
488+
489+ if ( ts . isIntersectionTypeNode ( typeNode ) ) {
490+ return typeNode . types . some ( ( t ) => typeContainsNumber ( t ) ) ;
491+ }
492+
493+ if ( ts . isTypeLiteralNode ( typeNode ) ) {
494+ for ( const member of typeNode . members ) {
495+ if ( ts . isPropertySignature ( member ) && member . type ) {
496+ if ( typeContainsNumber ( member . type ) ) {
497+ return true ;
498+ }
499+ }
500+ }
501+ }
502+
503+ return false ;
504+ }
505+
506+ /**
507+ * Extract raw text from a template literal (head + all span literals)
508+ */
509+ function extractRawTemplateText (
510+ template : ts . TemplateLiteral
511+ ) : string | undefined {
512+ if ( ts . isNoSubstitutionTemplateLiteral ( template ) ) {
513+ return template . text ;
514+ }
515+ if ( ts . isTemplateExpression ( template ) ) {
516+ let result = template . head . text ;
517+ for ( const span of template . templateSpans ) {
518+ result += '___' ;
519+ result += span . literal . text ;
520+ }
521+ return result ;
522+ }
523+ return undefined ;
524+ }
0 commit comments