@@ -21,6 +21,7 @@ import {
2121 ResultWrapper ,
2222 rowsToColumnar ,
2323} from '@cubejs-backend/native' ;
24+ import type { SqlFilterItem , SqlFiltersResponse } from '@cubejs-backend/native' ;
2425import type {
2526 Application as ExpressApplication ,
2627 ErrorRequestHandler ,
@@ -119,6 +120,14 @@ type HandleErrorOptions = {
119120 requestStarted ?: Date
120121} ;
121122
123+ /**
124+ * Upper bound on the number of filters a single SQL filters request may carry,
125+ * counting the leaves of filter groups. A batch costs one rewrite of the query
126+ * regardless of its size, so the bound is about how large a predicate the
127+ * rewrite engine is asked to saturate over.
128+ */
129+ const MAX_SQL_FILTERS = 500 ;
130+
122131function userAsyncHandler ( handler : ( req : Request & { context : ExtendedRequestContext } , res : ExpressResponse ) => Promise < void > ) {
123132 return ( req : ExpressRequest , res : ExpressResponse , next : NextFunction ) => {
124133 handler ( req as any , res ) . catch ( next ) ;
@@ -422,6 +431,26 @@ class ApiGateway {
422431 } ) ;
423432 } ) ) ;
424433
434+ app . get ( `${ this . basePath } /v1/sql-filters` , userMiddlewares , userAsyncHandler ( async ( req : any , res ) => {
435+ await this . getSqlFilters ( {
436+ query : req . query . query ,
437+ context : req . context ,
438+ res : this . resToResultFn ( res )
439+ } ) ;
440+ } ) ) ;
441+
442+ app . post ( `${ this . basePath } /v1/sql-filters` , jsonParser , userMiddlewares , userAsyncHandler ( async ( req , res ) => {
443+ await this . modifySqlFilters ( {
444+ query : req . body . query ,
445+ add : req . body . add ,
446+ set : req . body . set ,
447+ delete : req . body . delete ,
448+ replace : req . body . replace ,
449+ context : req . context ,
450+ res : this . resToResultFn ( res )
451+ } ) ;
452+ } ) ) ;
453+
425454 app . get ( `${ this . basePath } /v1/dry-run` , userMiddlewares , userAsyncHandler ( async ( req : any , res ) => {
426455 await this . dryRun ( {
427456 query : req . query . query ,
@@ -1486,6 +1515,154 @@ class ApiGateway {
14861515 }
14871516 }
14881517
1518+ /**
1519+ * Responds with the result of a SQL filters operation. Planning and
1520+ * rewriting failures are reported by the native layer in-band as
1521+ * `{ status: 'error', error }`, so they are mapped onto a 4xx to keep
1522+ * the endpoint's failures visible to status-code-based clients.
1523+ */
1524+ protected async resSqlFilters ( result : SqlFiltersResponse , res : ResponseResultFn ) {
1525+ if ( result . status === 'error' ) {
1526+ await res ( result , { status : 400 } ) ;
1527+ return ;
1528+ }
1529+
1530+ await res ( result ) ;
1531+ }
1532+
1533+ /**
1534+ * Returns the list of Cube filters of a SQL query in Cube query format,
1535+ * extracted from the logical plan of the query.
1536+ */
1537+ protected async getSqlFilters ( {
1538+ query,
1539+ context,
1540+ res,
1541+ } : { query : string } & BaseRequest ) {
1542+ try {
1543+ await this . assertApiScope ( 'sql' , context . securityContext ) ;
1544+
1545+ if ( typeof query !== 'string' || ! query . trim ( ) ) {
1546+ throw new UserError ( 'query parameter must be a non-empty string' ) ;
1547+ }
1548+
1549+ const result = await this . sqlServer . getSqlFilters ( query , context . securityContext ) ;
1550+
1551+ await this . resSqlFilters ( result , res ) ;
1552+ } catch ( e : any ) {
1553+ this . handleError ( {
1554+ e,
1555+ context,
1556+ query,
1557+ res,
1558+ } ) ;
1559+ }
1560+ }
1561+
1562+ /**
1563+ * Modifies the Cube filters of a SQL query. Exactly one of `add`, `set`,
1564+ * `delete` or `replace` must be provided. `add` adds the requested filters,
1565+ * `set` replaces all outermost filters with the specified set, `delete`
1566+ * attempts to delete the requested filters (all occurrences of equal
1567+ * filters are deleted), and `replace` replaces one exact set of filters
1568+ * with another (all occurrences of equal filters are replaced). Filters
1569+ * may be `and`/`or` filter groups; existing filters must match perfectly
1570+ * to be deleted or replaced. Only the outermost SELECT is modified, and
1571+ * only dimensions/measures available in the outermost SELECT can be
1572+ * filtered.
1573+ */
1574+ protected async modifySqlFilters ( {
1575+ query,
1576+ add,
1577+ set,
1578+ delete : deleteFilters ,
1579+ replace,
1580+ context,
1581+ res,
1582+ } : { query : string , add ?: unknown , set ?: unknown , delete ?: unknown , replace ?: unknown } & BaseRequest ) {
1583+ try {
1584+ await this . assertApiScope ( 'sql' , context . securityContext ) ;
1585+
1586+ if ( typeof query !== 'string' || ! query . trim ( ) ) {
1587+ throw new UserError ( 'query parameter must be a non-empty string' ) ;
1588+ }
1589+
1590+ const requestedOps = [ add , set , deleteFilters , replace ] . filter ( ( op ) => op !== undefined ) ;
1591+ if ( requestedOps . length !== 1 ) {
1592+ throw new UserError ( 'Exactly one of add, set, delete or replace parameters is required' ) ;
1593+ }
1594+
1595+ // The size of the predicate the rewrite engine ends up with is what
1596+ // costs, and a filter group nests any number of leaves inside a single
1597+ // array entry, so the leaves are what is bounded
1598+ const countFilters = ( filters : unknown [ ] ) : number => filters . reduce < number > (
1599+ ( total , filter : any ) => {
1600+ // A group is whichever of the two fields holds an array - the other
1601+ // may be present and not an array, which is for the native layer to
1602+ // reject rather than something to recurse into
1603+ const group = Array . isArray ( filter ?. and ) ? filter . and : filter ?. or ;
1604+
1605+ return total + ( Array . isArray ( group ) ? Math . max ( countFilters ( group ) , 1 ) : 1 ) ;
1606+ } ,
1607+ 0 ,
1608+ ) ;
1609+
1610+ const assertFilterArray = ( filters : unknown , name : string ) : SqlFilterItem [ ] => {
1611+ if ( ! Array . isArray ( filters ) ) {
1612+ throw new UserError ( `${ name } parameter must be an array of filters` ) ;
1613+ }
1614+
1615+ if ( countFilters ( filters ) > MAX_SQL_FILTERS ) {
1616+ throw new UserError ( `${ name } parameter must contain at most ${ MAX_SQL_FILTERS } filters` ) ;
1617+ }
1618+
1619+ return filters ;
1620+ } ;
1621+
1622+ if ( add !== undefined ) {
1623+ const result = await this . sqlServer . addSqlFilters ( query , assertFilterArray ( add , 'add' ) , context . securityContext ) ;
1624+
1625+ await this . resSqlFilters ( result , res ) ;
1626+ return ;
1627+ }
1628+
1629+ if ( set !== undefined ) {
1630+ const result = await this . sqlServer . setSqlFilters ( query , assertFilterArray ( set , 'set' ) , context . securityContext ) ;
1631+
1632+ await this . resSqlFilters ( result , res ) ;
1633+ return ;
1634+ }
1635+
1636+ if ( deleteFilters !== undefined ) {
1637+ const result = await this . sqlServer . deleteSqlFilters ( query , assertFilterArray ( deleteFilters , 'delete' ) , context . securityContext ) ;
1638+
1639+ await this . resSqlFilters ( result , res ) ;
1640+ return ;
1641+ }
1642+
1643+ if ( typeof replace !== 'object' || replace === null || Array . isArray ( replace ) ) {
1644+ throw new UserError ( 'replace parameter must be an object with old and new filter arrays' ) ;
1645+ }
1646+
1647+ const { old : oldFilters , new : newFilters } = replace as Record < string , unknown > ;
1648+ const result = await this . sqlServer . replaceSqlFilters (
1649+ query ,
1650+ assertFilterArray ( oldFilters , 'replace.old' ) ,
1651+ assertFilterArray ( newFilters , 'replace.new' ) ,
1652+ context . securityContext ,
1653+ ) ;
1654+
1655+ await this . resSqlFilters ( result , res ) ;
1656+ } catch ( e : any ) {
1657+ this . handleError ( {
1658+ e,
1659+ context,
1660+ query,
1661+ res,
1662+ } ) ;
1663+ }
1664+ }
1665+
14891666 public async sql ( {
14901667 query,
14911668 context,
0 commit comments