@@ -90,6 +90,11 @@ def clean_properties(props):
9090 return {k : v for k , v in props .items () if k not in vector_keys }
9191
9292
93+ def escape_sql_string (value : str ) -> str :
94+ """Escape single quotes in SQL string."""
95+ return value .replace ("'" , "''" )
96+
97+
9398class PolarDBGraphDB (BaseGraphDB ):
9499 """PolarDB-based implementation using Apache AGE graph database extension."""
95100
@@ -3438,9 +3443,11 @@ def build_cypher_filter_condition(condition_dict: dict) -> str:
34383443 """Build a Cypher WHERE condition for a single filter item."""
34393444 condition_parts = []
34403445 for key , value in condition_dict .items ():
3441- # Check if value is a dict with comparison operators (gt, lt, gte, lte, =, contains)
3446+ # Check if value is a dict with comparison operators (gt, lt, gte, lte, =, contains, in, like )
34423447 if isinstance (value , dict ):
3443- # Handle comparison operators: gt, lt, gte, lte, =, contains
3448+ # Handle comparison operators: gt, lt, gte, lte, =, contains, in, like
3449+ # Supports multiple operators for the same field, e.g.:
3450+ # will generate: n.created_at >= '2025-09-19' AND n.created_at <= '2025-12-31'
34443451 for op , op_value in value .items ():
34453452 if op in ("gt" , "lt" , "gte" , "lte" ):
34463453 # Map operator to Cypher operator
@@ -3540,40 +3547,90 @@ def build_cypher_filter_condition(condition_dict: dict) -> str:
35403547 condition_parts .append (f"n.{ key } = { op_value } " )
35413548 elif op == "contains" :
35423549 # Handle contains operator (for array fields)
3543- # Only supports array format: {"field": {"contains": ["value1", "value2"]}}
3544- # Single string values are not supported, use array format instead: {"field": {"contains": ["value"]}}
3550+ # Check if key starts with "info." prefix
3551+ if key .startswith ("info." ):
3552+ info_field = key [5 :] # Remove "info." prefix
3553+ if isinstance (op_value , str ):
3554+ escaped_value = escape_cypher_string (op_value )
3555+ condition_parts .append (
3556+ f"'{ escaped_value } ' IN n.info.{ info_field } "
3557+ )
3558+ else :
3559+ condition_parts .append (f"{ op_value } IN n.info.{ info_field } " )
3560+ else :
3561+ # Direct property access
3562+ if isinstance (op_value , str ):
3563+ escaped_value = escape_cypher_string (op_value )
3564+ condition_parts .append (f"'{ escaped_value } ' IN n.{ key } " )
3565+ else :
3566+ condition_parts .append (f"{ op_value } IN n.{ key } " )
3567+ elif op == "in" :
3568+ # Handle in operator (for checking if field value is in a list)
3569+ # Supports array format: {"field": {"in": ["value1", "value2"]}}
3570+ # Generates: n.field IN ['value1', 'value2'] or (n.field = 'value1' OR n.field = 'value2')
35453571 if not isinstance (op_value , list ):
35463572 raise ValueError (
3547- f"contains operator only supports array format. "
3548- f"Use {{'{ key } ': {{'contains ': ['{ op_value } ']}}}} instead of {{'{ key } ': {{'contains ': '{ op_value } '}}}}"
3573+ f"in operator only supports array format. "
3574+ f"Use {{'{ key } ': {{'in ': ['{ op_value } ']}}}} instead of {{'{ key } ': {{'in ': '{ op_value } '}}}}"
35493575 )
35503576 # Check if key starts with "info." prefix
35513577 if key .startswith ("info." ):
35523578 info_field = key [5 :] # Remove "info." prefix
3553- # Handle array of values: generate AND conditions for each value (all must be present)
3554- and_conditions = []
3555- for item in op_value :
3579+ # Build OR conditions for nested properties (Apache AGE compatibility)
3580+ if len (op_value ) == 0 :
3581+ # Empty list means no match
3582+ condition_parts .append ("false" )
3583+ elif len (op_value ) == 1 :
3584+ # Single value, use equality
3585+ item = op_value [0 ]
35563586 if isinstance (item , str ):
35573587 escaped_value = escape_cypher_string (item )
3558- and_conditions .append (
3559- f"' { escaped_value } ' IN n.info.{ info_field } "
3588+ condition_parts .append (
3589+ f"n.info.{ info_field } = ' { escaped_value } ' "
35603590 )
35613591 else :
3562- and_conditions .append (f"{ item } IN n.info.{ info_field } " )
3563- if and_conditions :
3564- condition_parts .append (f"({ ' AND ' .join (and_conditions )} )" )
3592+ condition_parts .append (f"n.info.{ info_field } = { item } " )
3593+ else :
3594+ # Multiple values, use OR conditions instead of IN (Apache AGE compatibility)
3595+ or_conditions = []
3596+ for item in op_value :
3597+ if isinstance (item , str ):
3598+ escaped_value = escape_cypher_string (item )
3599+ or_conditions .append (
3600+ f"n.info.{ info_field } = '{ escaped_value } '"
3601+ )
3602+ else :
3603+ or_conditions .append (
3604+ f"n.info.{ info_field } = { item } "
3605+ )
3606+ if or_conditions :
3607+ condition_parts .append (
3608+ f"({ ' OR ' .join (or_conditions )} )"
3609+ )
35653610 else :
35663611 # Direct property access
3567- # Handle array of values: generate AND conditions for each value (all must be present)
3568- and_conditions = []
3569- for item in op_value :
3612+ # Build array for IN clause or OR conditions
3613+ if len (op_value ) == 0 :
3614+ # Empty list means no match
3615+ condition_parts .append ("false" )
3616+ elif len (op_value ) == 1 :
3617+ # Single value, use equality
3618+ item = op_value [0 ]
35703619 if isinstance (item , str ):
35713620 escaped_value = escape_cypher_string (item )
3572- and_conditions .append (f"' { escaped_value } ' IN n. { key } " )
3621+ condition_parts .append (f"n. { key } = ' { escaped_value } ' " )
35733622 else :
3574- and_conditions .append (f"{ item } IN n.{ key } " )
3575- if and_conditions :
3576- condition_parts .append (f"({ ' AND ' .join (and_conditions )} )" )
3623+ condition_parts .append (f"n.{ key } = { item } " )
3624+ else :
3625+ # Multiple values, use IN clause
3626+ escaped_items = [
3627+ f"'{ escape_cypher_string (str (item ))} '"
3628+ if isinstance (item , str )
3629+ else str (item )
3630+ for item in op_value
3631+ ]
3632+ array_str = "[" + ", " .join (escaped_items ) + "]"
3633+ condition_parts .append (f"n.{ key } IN { array_str } " )
35773634 elif op == "like" :
35783635 # Handle like operator (for fuzzy matching, similar to SQL LIKE '%value%')
35793636 # Check if key starts with "info." prefix
@@ -3781,47 +3838,28 @@ def build_filter_condition(condition_dict: dict) -> str:
37813838 f"ag_catalog.agtype_access_operator(properties, '\" { key } \" '::agtype) = { op_value } ::agtype"
37823839 )
37833840 elif op == "contains" :
3784- # Handle contains operator (for array fields) - use @> operator
3785- # Only supports array format: {"field": {"contains": ["value1", "value2"]}}
3786- # Single string values are not supported, use array format instead: {"field": {"contains": ["value"]}}
3787- if not isinstance (op_value , list ):
3841+ # Handle contains operator (for string fields only)
3842+ # Check if agtype contains value (using @> operator)
3843+ if not isinstance (op_value , str ):
37883844 raise ValueError (
3789- f"contains operator only supports array format. "
3790- f"Use {{'{ key } ': {{'contains': [ '{ op_value } '] }}}} instead of {{'{ key } ': {{'contains': ' { op_value } ' }}}}"
3845+ f"contains operator only supports string format. "
3846+ f"Use {{'{ key } ': {{'contains': '{ op_value } '}}}} instead of {{'{ key } ': {{'contains': { op_value } }}}}"
37913847 )
37923848 # Check if key starts with "info." prefix
37933849 if key .startswith ("info." ):
37943850 info_field = key [5 :] # Remove "info." prefix
3795- # Handle array of values: generate AND conditions for each value (all must be present)
3796- and_conditions = []
3797- for item in op_value :
3798- if isinstance (item , str ):
3799- escaped_value = escape_sql_string (item )
3800- and_conditions .append (
3801- f"ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\" info\" '::ag_catalog.agtype, '\" { info_field } \" '::ag_catalog.agtype]) @> '\" { escaped_value } \" '::agtype"
3802- )
3803- else :
3804- and_conditions .append (
3805- f"ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\" info\" '::ag_catalog.agtype, '\" { info_field } \" '::ag_catalog.agtype]) @> { item } ::agtype"
3806- )
3807- if and_conditions :
3808- condition_parts .append (f"({ ' AND ' .join (and_conditions )} )" )
3851+ # String contains: use @> operator for agtype contains
3852+ escaped_value = escape_sql_string (op_value )
3853+ condition_parts .append (
3854+ f"ag_catalog.agtype_access_operator(VARIADIC ARRAY[properties, '\" info\" '::ag_catalog.agtype, '\" { info_field } \" '::ag_catalog.agtype]) @> '\" { escaped_value } \" '::agtype"
3855+ )
38093856 else :
38103857 # Direct property access
3811- # Handle array of values: generate AND conditions for each value (all must be present)
3812- and_conditions = []
3813- for item in op_value :
3814- if isinstance (item , str ):
3815- escaped_value = escape_sql_string (item )
3816- and_conditions .append (
3817- f"ag_catalog.agtype_access_operator(properties, '\" { key } \" '::agtype) @> '\" { escaped_value } \" '::agtype"
3818- )
3819- else :
3820- and_conditions .append (
3821- f"ag_catalog.agtype_access_operator(properties, '\" { key } \" '::agtype) @> { item } ::agtype"
3822- )
3823- if and_conditions :
3824- condition_parts .append (f"({ ' AND ' .join (and_conditions )} )" )
3858+ # String contains: use @> operator for agtype contains
3859+ escaped_value = escape_sql_string (op_value )
3860+ condition_parts .append (
3861+ f"ag_catalog.agtype_access_operator(properties, '\" { key } \" '::agtype) @> '\" { escaped_value } \" '::agtype"
3862+ )
38253863 elif op == "like" :
38263864 # Handle like operator (for fuzzy matching, similar to SQL LIKE '%value%')
38273865 # Check if key starts with "info." prefix
0 commit comments