@@ -39,6 +39,9 @@ const ALLOWED_OPERATIONS = [
3939 'UNION ALL' ,
4040 'JSONExtract' ,
4141 'JSONExtractString' ,
42+ 'JSONExtractInt' ,
43+ 'JSONExtractFloat' ,
44+ 'JSONExtractBool' ,
4245 'JSONExtractRaw' ,
4346 'CASE' ,
4447 'WHEN' ,
@@ -141,6 +144,29 @@ const TABLE_PATTERN = /(?:FROM|JOIN)\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi;
141144const SUBQUERY_PATTERN = / \( S E L E C T .* ?\) / gi;
142145
143146const QuerySecurityValidator = {
147+ transformPropertiesSyntax ( query : string ) : string {
148+ // Match properties.X patterns and convert to JSONExtract calls
149+ const propertiesPattern =
150+ / \b p r o p e r t i e s \. ( [ a - z A - Z _ ] [ a - z A - Z 0 - 9 _ ] * (?: : ( s t r i n g | i n t | f l o a t | b o o l | r a w ) ) ? ) \b / gi;
151+
152+ return query . replace ( propertiesPattern , ( _match , propertyWithType ) => {
153+ const [ propertyName , type ] = propertyWithType . split ( ':' ) ;
154+
155+ switch ( type ) {
156+ case 'int' :
157+ return `JSONExtractInt(properties, '${ propertyName } ')` ;
158+ case 'float' :
159+ return `JSONExtractFloat(properties, '${ propertyName } ')` ;
160+ case 'bool' :
161+ return `JSONExtractBool(properties, '${ propertyName } ')` ;
162+ case 'raw' :
163+ return `JSONExtractRaw(properties, '${ propertyName } ')` ;
164+ default :
165+ return `JSONExtractString(properties, '${ propertyName } ')` ;
166+ }
167+ } ) ;
168+ } ,
169+
144170 normalizeSQL ( query : string ) : string {
145171 return query
146172 . replace ( / \/ \* [ \s \S ] * ?\* \/ / g, '' )
@@ -188,7 +214,7 @@ const QuerySecurityValidator = {
188214 }
189215 } ,
190216
191- validateQueryStructure ( normalizedQuery : string ) : void {
217+ validateQueryStart ( normalizedQuery : string ) : void {
192218 const startsWithSelect = normalizedQuery . startsWith ( 'SELECT' ) ;
193219 const startsWithWith = normalizedQuery . startsWith ( 'WITH' ) ;
194220 const hasValidStart = startsWithSelect || startsWithWith ;
@@ -198,7 +224,9 @@ const QuerySecurityValidator = {
198224 'INVALID_QUERY_START'
199225 ) ;
200226 }
227+ } ,
201228
229+ validateQueryComplexity ( normalizedQuery : string ) : void {
202230 const selectMatches = normalizedQuery . match ( / S E L E C T / g) ;
203231 const selectCount = selectMatches ? selectMatches . length : 0 ;
204232 if ( selectCount > 3 ) {
@@ -214,7 +242,9 @@ const QuerySecurityValidator = {
214242 'UNION_NOT_ALLOWED'
215243 ) ;
216244 }
245+ } ,
217246
247+ validateQuerySyntax ( normalizedQuery : string ) : void {
218248 if ( normalizedQuery . includes ( '/*' ) || normalizedQuery . includes ( '--' ) ) {
219249 throw new SQLValidationError (
220250 'Comments are not allowed in queries' ,
@@ -229,6 +259,15 @@ const QuerySecurityValidator = {
229259 ) ;
230260 }
231261
262+ if ( normalizedQuery . includes ( '\\' ) || normalizedQuery . includes ( '%' ) ) {
263+ throw new SQLValidationError (
264+ 'Escape sequences and URL encoding not allowed' ,
265+ 'ENCODING_NOT_ALLOWED'
266+ ) ;
267+ }
268+ } ,
269+
270+ validateParenthesesBalance ( normalizedQuery : string ) : void {
232271 let parenCount = 0 ;
233272 for ( const char of normalizedQuery ) {
234273 if ( char === '(' ) {
@@ -250,13 +289,13 @@ const QuerySecurityValidator = {
250289 'INVALID_SYNTAX'
251290 ) ;
252291 }
292+ } ,
253293
254- if ( normalizedQuery . includes ( '\\' ) || normalizedQuery . includes ( '%' ) ) {
255- throw new SQLValidationError (
256- 'Escape sequences and URL encoding not allowed' ,
257- 'ENCODING_NOT_ALLOWED'
258- ) ;
259- }
294+ validateQueryStructure ( normalizedQuery : string ) : void {
295+ this . validateQueryStart ( normalizedQuery ) ;
296+ this . validateQueryComplexity ( normalizedQuery ) ;
297+ this . validateQuerySyntax ( normalizedQuery ) ;
298+ this . validateParenthesesBalance ( normalizedQuery ) ;
260299 } ,
261300
262301 validateClientAccess ( query : string , clientId : string ) : string {
@@ -362,13 +401,21 @@ const QuerySecurityValidator = {
362401
363402 QuerySecurityValidator . validateAgainstAttackVectors ( query ) ;
364403
365- const normalizedQuery = QuerySecurityValidator . normalizeSQL ( query ) ;
404+ // Transform properties.X syntax to JSONExtract calls before validation
405+ const transformedQuery =
406+ QuerySecurityValidator . transformPropertiesSyntax ( query ) ;
407+
408+ const normalizedQuery =
409+ QuerySecurityValidator . normalizeSQL ( transformedQuery ) ;
366410
367411 QuerySecurityValidator . validateForbiddenOperations ( normalizedQuery ) ;
368412 QuerySecurityValidator . validateQueryStructure ( normalizedQuery ) ;
369413 QuerySecurityValidator . validateAllowedTables ( normalizedQuery ) ;
370414
371- return QuerySecurityValidator . validateClientAccess ( query , clientId ) ;
415+ return QuerySecurityValidator . validateClientAccess (
416+ transformedQuery ,
417+ clientId
418+ ) ;
372419 } ,
373420} ;
374421
@@ -529,6 +576,19 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
529576 clientIdParameter : '{clientId:String}' ,
530577 required : 'All queries must use parameterized client filtering' ,
531578 } ,
579+ propertiesSyntax : {
580+ description :
581+ 'Automatic JSONExtract transformation from properties.X syntax' ,
582+ syntax : 'properties.property_name[:type]' ,
583+ supportedTypes : [ 'string' , 'int' , 'float' , 'bool' , 'raw' ] ,
584+ examples : [
585+ 'properties.browser_name' ,
586+ 'properties.user_id:int' ,
587+ 'properties.is_active:bool' ,
588+ 'properties.metadata:raw' ,
589+ ] ,
590+ defaultType : 'string (JSONExtractString)' ,
591+ } ,
532592 } ,
533593 } ;
534594 } )
@@ -540,11 +600,11 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
540600 name : 'Monthly Events Count' ,
541601 description : 'Get monthly event counts for your client' ,
542602 query : `
543- SELECT
603+ SELECT
544604 toStartOfMonth(time) as month_start,
545605 count() as event_count
546- FROM analytics.events
547- WHERE
606+ FROM analytics.events
607+ WHERE
548608 time >= now() - INTERVAL 6 MONTH
549609 GROUP BY month_start
550610 ORDER BY month_start DESC
@@ -554,12 +614,12 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
554614 name : 'Top Pages by Views' ,
555615 description : 'Get most popular pages' ,
556616 query : `
557- SELECT
617+ SELECT
558618 path,
559619 count() as page_views,
560620 uniq(session_id) as unique_sessions
561- FROM analytics.events
562- WHERE
621+ FROM analytics.events
622+ WHERE
563623 time >= now() - INTERVAL 30 DAY
564624 AND event_name = 'page_view'
565625 GROUP BY path
@@ -568,30 +628,51 @@ export const customSQL = new Elysia({ prefix: '/v1/custom-sql' })
568628 ` . trim ( ) ,
569629 } ,
570630 {
571- name : 'Browser Analytics' ,
572- description : 'Analyze browser usage' ,
631+ name : 'Browser Analytics (with properties.X syntax) ' ,
632+ description : 'Analyze browser usage using properties.X syntax ' ,
573633 query : `
574- SELECT
575- browser_name,
634+ SELECT
635+ properties. browser_name,
576636 count() as events,
577637 uniq(anonymous_id) as unique_users
578- FROM analytics.events
579- WHERE
638+ FROM analytics.events
639+ WHERE
580640 time >= now() - INTERVAL 7 DAY
581- AND browser_name IS NOT NULL
582- GROUP BY browser_name
641+ AND properties. browser_name IS NOT NULL
642+ GROUP BY properties. browser_name
583643 ORDER BY events DESC
584644 ` . trim ( ) ,
585645 } ,
646+ {
647+ name : 'User Analytics with Typed Properties' ,
648+ description : 'Analyze user behavior with typed property extraction' ,
649+ query : `
650+ SELECT
651+ properties.user_id:int as user_id,
652+ properties.is_premium:bool as is_premium,
653+ properties.session_duration:float as session_duration,
654+ count() as total_events
655+ FROM analytics.events
656+ WHERE
657+ time >= now() - INTERVAL 30 DAY
658+ AND properties.user_id:int IS NOT NULL
659+ GROUP BY
660+ properties.user_id:int,
661+ properties.is_premium:bool,
662+ properties.session_duration:float
663+ ORDER BY total_events DESC
664+ LIMIT 20
665+ ` . trim ( ) ,
666+ } ,
586667 {
587668 name : 'Error Events Analysis' ,
588669 description : 'Analyze error events' ,
589670 query : `
590- SELECT
671+ SELECT
591672 url,
592673 count() as error_count
593- FROM analytics.errors
594- WHERE
674+ FROM analytics.errors
675+ WHERE
595676 time >= now() - INTERVAL 7 DAY
596677 GROUP BY url
597678 ORDER BY error_count DESC
0 commit comments