@@ -13,6 +13,7 @@ import {
1313 FILTER_OPERATORS ,
1414 LOGICAL_OPERATORS ,
1515 ALL_OPERATORS ,
16+ parseFilterAST ,
1617 type Filter ,
1718 type QueryFilter ,
1819 type FieldOperators ,
@@ -753,3 +754,162 @@ describe('NormalizedFilterSchema', () => {
753754 expect ( ( ) => NormalizedFilterSchema . parse ( filter ) ) . not . toThrow ( ) ;
754755 } ) ;
755756} ) ;
757+
758+ // ============================================================================
759+ // parseFilterAST Tests
760+ // ============================================================================
761+
762+ describe ( 'parseFilterAST' , ( ) => {
763+ it ( 'should return undefined for null/undefined input' , ( ) => {
764+ expect ( parseFilterAST ( null ) ) . toBeUndefined ( ) ;
765+ expect ( parseFilterAST ( undefined ) ) . toBeUndefined ( ) ;
766+ } ) ;
767+
768+ it ( 'should return undefined for empty array' , ( ) => {
769+ expect ( parseFilterAST ( [ ] ) ) . toBeUndefined ( ) ;
770+ } ) ;
771+
772+ it ( 'should pass through object filters as-is' , ( ) => {
773+ const filter = { status : 'active' } ;
774+ expect ( parseFilterAST ( filter ) ) . toEqual ( { status : 'active' } ) ;
775+ } ) ;
776+
777+ it ( 'should pass through FilterCondition with $and/$or as-is' , ( ) => {
778+ const filter = { $and : [ { priority : 'high' } , { status : 'active' } ] } ;
779+ expect ( parseFilterAST ( filter ) ) . toEqual ( filter ) ;
780+ } ) ;
781+
782+ it ( 'should convert simple equality comparison' , ( ) => {
783+ expect ( parseFilterAST ( [ 'status' , '=' , 'active' ] ) ) . toEqual ( { status : 'active' } ) ;
784+ } ) ;
785+
786+ it ( 'should convert == equality comparison' , ( ) => {
787+ expect ( parseFilterAST ( [ 'status' , '==' , 'active' ] ) ) . toEqual ( { status : 'active' } ) ;
788+ } ) ;
789+
790+ it ( 'should convert != comparison' , ( ) => {
791+ expect ( parseFilterAST ( [ 'status' , '!=' , 'deleted' ] ) ) . toEqual ( { status : { $ne : 'deleted' } } ) ;
792+ } ) ;
793+
794+ it ( 'should convert <> comparison' , ( ) => {
795+ expect ( parseFilterAST ( [ 'status' , '<>' , 'deleted' ] ) ) . toEqual ( { status : { $ne : 'deleted' } } ) ;
796+ } ) ;
797+
798+ it ( 'should convert > comparison' , ( ) => {
799+ expect ( parseFilterAST ( [ 'age' , '>' , 18 ] ) ) . toEqual ( { age : { $gt : 18 } } ) ;
800+ } ) ;
801+
802+ it ( 'should convert >= comparison' , ( ) => {
803+ expect ( parseFilterAST ( [ 'age' , '>=' , 18 ] ) ) . toEqual ( { age : { $gte : 18 } } ) ;
804+ } ) ;
805+
806+ it ( 'should convert < comparison' , ( ) => {
807+ expect ( parseFilterAST ( [ 'age' , '<' , 65 ] ) ) . toEqual ( { age : { $lt : 65 } } ) ;
808+ } ) ;
809+
810+ it ( 'should convert <= comparison' , ( ) => {
811+ expect ( parseFilterAST ( [ 'age' , '<=' , 65 ] ) ) . toEqual ( { age : { $lte : 65 } } ) ;
812+ } ) ;
813+
814+ it ( 'should convert in operator' , ( ) => {
815+ expect ( parseFilterAST ( [ 'role' , 'in' , [ 'admin' , 'editor' ] ] ) ) . toEqual ( { role : { $in : [ 'admin' , 'editor' ] } } ) ;
816+ } ) ;
817+
818+ it ( 'should convert not_in operator' , ( ) => {
819+ expect ( parseFilterAST ( [ 'role' , 'not_in' , [ 'guest' ] ] ) ) . toEqual ( { role : { $nin : [ 'guest' ] } } ) ;
820+ } ) ;
821+
822+ it ( 'should convert contains/like operator' , ( ) => {
823+ expect ( parseFilterAST ( [ 'name' , 'contains' , 'John' ] ) ) . toEqual ( { name : { $contains : 'John' } } ) ;
824+ expect ( parseFilterAST ( [ 'name' , 'like' , 'John' ] ) ) . toEqual ( { name : { $contains : 'John' } } ) ;
825+ } ) ;
826+
827+ it ( 'should convert startswith operator' , ( ) => {
828+ expect ( parseFilterAST ( [ 'name' , 'startswith' , 'A' ] ) ) . toEqual ( { name : { $startsWith : 'A' } } ) ;
829+ expect ( parseFilterAST ( [ 'name' , 'starts_with' , 'A' ] ) ) . toEqual ( { name : { $startsWith : 'A' } } ) ;
830+ } ) ;
831+
832+ it ( 'should convert endswith operator' , ( ) => {
833+ expect ( parseFilterAST ( [ 'name' , 'endswith' , '.pdf' ] ) ) . toEqual ( { name : { $endsWith : '.pdf' } } ) ;
834+ expect ( parseFilterAST ( [ 'name' , 'ends_with' , '.pdf' ] ) ) . toEqual ( { name : { $endsWith : '.pdf' } } ) ;
835+ } ) ;
836+
837+ it ( 'should convert is_null operator' , ( ) => {
838+ expect ( parseFilterAST ( [ 'deleted_at' , 'is_null' , null ] ) ) . toEqual ( { deleted_at : { $null : true } } ) ;
839+ } ) ;
840+
841+ it ( 'should convert is_not_null operator' , ( ) => {
842+ expect ( parseFilterAST ( [ 'deleted_at' , 'is_not_null' , null ] ) ) . toEqual ( { deleted_at : { $null : false } } ) ;
843+ } ) ;
844+
845+ it ( 'should convert AND logical node' , ( ) => {
846+ const input = [ 'and' , [ 'priority' , '=' , 'high' ] , [ 'status' , '=' , 'active' ] ] ;
847+ expect ( parseFilterAST ( input ) ) . toEqual ( {
848+ $and : [ { priority : 'high' } , { status : 'active' } ] ,
849+ } ) ;
850+ } ) ;
851+
852+ it ( 'should convert OR logical node' , ( ) => {
853+ const input = [ 'or' , [ 'role' , '=' , 'admin' ] , [ 'role' , '=' , 'editor' ] ] ;
854+ expect ( parseFilterAST ( input ) ) . toEqual ( {
855+ $or : [ { role : 'admin' } , { role : 'editor' } ] ,
856+ } ) ;
857+ } ) ;
858+
859+ it ( 'should handle nested logical operators' , ( ) => {
860+ const input = [ 'and' , [ 'status' , '=' , 'active' ] , [ 'or' , [ 'priority' , '=' , 'high' ] , [ 'priority' , '=' , 'critical' ] ] ] ;
861+ expect ( parseFilterAST ( input ) ) . toEqual ( {
862+ $and : [
863+ { status : 'active' } ,
864+ { $or : [ { priority : 'high' } , { priority : 'critical' } ] } ,
865+ ] ,
866+ } ) ;
867+ } ) ;
868+
869+ it ( 'should unwrap single-child logical nodes' , ( ) => {
870+ const input = [ 'and' , [ 'status' , '=' , 'active' ] ] ;
871+ expect ( parseFilterAST ( input ) ) . toEqual ( { status : 'active' } ) ;
872+ } ) ;
873+
874+ it ( 'should handle case-insensitive logical operators' , ( ) => {
875+ const input = [ 'AND' , [ 'status' , '=' , 'active' ] , [ 'priority' , '=' , 'high' ] ] ;
876+ expect ( parseFilterAST ( input ) ) . toEqual ( {
877+ $and : [ { status : 'active' } , { priority : 'high' } ] ,
878+ } ) ;
879+ } ) ;
880+
881+ it ( 'should handle legacy flat array of conditions' , ( ) => {
882+ const input = [ [ 'status' , '=' , 'active' ] , [ 'priority' , '=' , 'high' ] ] ;
883+ expect ( parseFilterAST ( input ) ) . toEqual ( {
884+ $and : [ { status : 'active' } , { priority : 'high' } ] ,
885+ } ) ;
886+ } ) ;
887+
888+ it ( 'should handle real-world browser filter example from issue' , ( ) => {
889+ const input = [ 'and' , [ 'priority' , '=' , 'high' ] , [ 'status' , '=' , 'active' ] ] ;
890+ const result = parseFilterAST ( input ) ;
891+ expect ( result ) . toEqual ( {
892+ $and : [ { priority : 'high' } , { status : 'active' } ] ,
893+ } ) ;
894+ // Validate the result is a valid FilterCondition
895+ expect ( ( ) => FilterConditionSchema . parse ( result ) ) . not . toThrow ( ) ;
896+ } ) ;
897+
898+ it ( 'should produce valid FilterCondition for complex nested AST' , ( ) => {
899+ const input = [
900+ 'and' ,
901+ [ 'status' , '!=' , 'deleted' ] ,
902+ [ 'or' , [ 'priority' , '=' , 'high' ] , [ 'age' , '>' , 18 ] ] ,
903+ [ 'role' , 'in' , [ 'admin' , 'editor' ] ] ,
904+ ] ;
905+ const result = parseFilterAST ( input ) ;
906+ expect ( result ) . toEqual ( {
907+ $and : [
908+ { status : { $ne : 'deleted' } } ,
909+ { $or : [ { priority : 'high' } , { age : { $gt : 18 } } ] } ,
910+ { role : { $in : [ 'admin' , 'editor' ] } } ,
911+ ] ,
912+ } ) ;
913+ expect ( ( ) => FilterConditionSchema . parse ( result ) ) . not . toThrow ( ) ;
914+ } ) ;
915+ } ) ;
0 commit comments