diff --git a/src/query/compiler.rs b/src/query/compiler.rs index 886af77..f4baac5 100644 --- a/src/query/compiler.rs +++ b/src/query/compiler.rs @@ -452,29 +452,47 @@ fn compile_single_filter( ) -> RyxResult { // Support "table.column" qualified references in filters // Also handle field__transform patterns (e.g., "created_at__year") - let (base_column, applied_transforms) = if field.contains("__") { + // For JSON key lookups like "bio__key__priority", we need to handle specially + let known_transforms = [ + "date", "year", "month", "day", "hour", "minute", "second", "week", "dow", "quarter", + "time", "iso_week", "iso_dow", "key", "key_text", "json", + ]; + + let (base_column, applied_transforms, json_key) = if field.contains("__") { let parts: Vec<&str> = field.split("__").collect(); - let transforms: Vec<&str> = parts[1..].to_vec(); - // Check if all suffix parts are transforms - let known_transforms = [ - "date", "year", "month", "day", "hour", "minute", "second", "week", "dow", "quarter", - "time", "iso_week", "iso_dow", "key", "key_text", "json", - ]; + // Find the first part that's NOT a known transform - that's the JSON key + // For example: "bio__key__priority" -> transforms=["key"], key="priority", base="bio" + let mut transforms = Vec::new(); + let mut key_part: Option<&str> = None; - // Only treat as transforms if ALL parts after the first are known transforms - let all_transforms = - !transforms.is_empty() && transforms.iter().all(|t| known_transforms.contains(t)); + for part in parts[1..].iter() { + if known_transforms.contains(part) { + transforms.push(*part); + } else { + // First non-transform part is the JSON key + key_part = Some(*part); + break; + } + } - if all_transforms { - (parts[0].to_string(), transforms) + if let Some(key) = key_part { + // Base column is just the first part (the field name) + // Transforms is everything that came before the key + (parts[0].to_string(), transforms, Some(key.to_string())) + } else if !transforms.is_empty() { + // All parts are transforms + (parts[0].to_string(), transforms, None) } else { - (field.to_string(), vec![]) + (field.to_string(), vec![], None) } } else { - (field.to_string(), vec![]) + (field.to_string(), vec![], None) }; + // For JSON key transforms, we need to pass the key to resolve() + // The key is embedded in the field name (bio__key__priority -> key=priority) + // If the lookup contains "__" (is a chained lookup like "month__gte"), // DON'T apply transforms here - let resolve() handle it completely // This avoids double-transform issues where the compiler applies transform @@ -486,17 +504,19 @@ fn compile_single_filter( // For simple transform-only lookups (like "year"), apply transforms here let mut result = qualified_col(&base_column); for transform in &applied_transforms { - result = lookup::apply_transform(transform, &result, backend)?; + result = lookup::apply_transform(transform, &result, backend, None)?; } result } else { qualified_col(&base_column) }; + // For JSON key transforms, pass the key in the context let ctx = LookupContext { column: final_column.clone(), negated, backend, + json_key: json_key.clone(), }; // # isnull (no bind param) @@ -558,13 +578,15 @@ fn compile_single_filter( // # general lookup // If lookup is a transform (like "year", "month"), use the transform function which includes = ? // BUT if lookup contains "__" (like "date__gte"), we need to use resolve() to handle the chain + // ALSO use resolve() for JSON key transforms even if lookup is simple (like "exact") let known_transforms = [ "date", "year", "month", "day", "hour", "minute", "second", "week", "dow", "quarter", "time", "iso_week", "iso_dow", "key", "key_text", "json", ]; // If lookup contains "__", it's a chained lookup (e.g., "date__gte") - use resolve() - if lookup.contains("__") { + // OR if we have a JSON key (json_key is Some), we need resolve() to apply it + if lookup.contains("__") || json_key.is_some() { let fragment = lookup::resolve(&base_column, lookup, &ctx)?; values.push(value.clone()); return Ok(if negated { diff --git a/src/query/lookup.rs b/src/query/lookup.rs index fa5b7a2..cb7da75 100644 --- a/src/query/lookup.rs +++ b/src/query/lookup.rs @@ -76,6 +76,10 @@ pub struct LookupContext { /// The database backend (PostgreSQL, MySQL, SQLite). /// Used for backend-specific SQL generation. pub backend: Backend, + + /// For JSON key transforms (e.g., bio__key__priority), this holds the key name ("priority") + /// Used by apply_transform() to generate correct JSON path accessors. + pub json_key: Option, } /// The function signature for a built-in lookup implementation. @@ -222,9 +226,10 @@ pub fn register_custom(name: impl Into, sql_template: impl Into) /// Handle SQLite transform lookup when ctx.column already has transform applied /// This happens when compiler applied the transform but lookup is still simple (e.g., "gte") +#[allow(dead_code)] fn handle_sqlite_transform_lookup( field: &str, - transform: &str, + _transform: &str, lookup_name: &str, ctx: &LookupContext, ) -> RyxResult { @@ -238,6 +243,7 @@ fn handle_sqlite_transform_lookup( column: transformed, negated: ctx.negated, backend: ctx.backend, + json_key: ctx.json_key.clone(), }; return resolve_simple(field, lookup_name, &new_ctx); } @@ -251,6 +257,23 @@ fn handle_sqlite_transform_lookup( pub fn resolve(field: &str, lookup_name: &str, ctx: &LookupContext) -> RyxResult { // If no "__", it's a simple lookup if !lookup_name.contains("__") { + // Check if we have a JSON key that needs to be applied + if ctx.json_key.is_some() { + // We have a JSON key transform to apply - ALWAYS start fresh from field + let mut column = format!("\"{}\"", field); + // Apply the key transform with the json_key + column = apply_transform("key", &column, ctx.backend, ctx.json_key.as_deref())?; + + // Build new context with transformed column + let json_ctx = LookupContext { + column: column.clone(), + negated: ctx.negated, + backend: ctx.backend, + json_key: None, + }; + return resolve_simple(field, lookup_name, &json_ctx); + } + // Check if ctx.column already has a date/time transform applied (e.g., from compiler) // Handle the case where compiler applied transform but lookup is simple (e.g., "gte") if ctx.column.contains("strftime") || ctx.column.contains("DATE(") { @@ -281,6 +304,7 @@ pub fn resolve(field: &str, lookup_name: &str, ctx: &LookupContext) -> RyxResult let mut column = format!("\"{}\"", field); // Apply transforms in order until we hit a lookup + // For JSON transforms like "key", use ctx.json_key if available for transform in transform_parts.iter() { // Check if this is a known transform let is_transform = matches!( @@ -304,7 +328,15 @@ pub fn resolve(field: &str, lookup_name: &str, ctx: &LookupContext) -> RyxResult ); if is_transform { - column = apply_transform(transform, &column, ctx.backend)?; + // For JSON transforms (key, key_text), use json_key from context if available + let key = if matches!(*transform, "key" | "key_text") { + ctx.json_key + .as_deref() + .or_else(|| field.rsplit("__").next()) + } else { + None + }; + column = apply_transform(transform, &column, ctx.backend, key)?; } else { // This part is a lookup, not a transform - stop here break; @@ -316,6 +348,7 @@ pub fn resolve(field: &str, lookup_name: &str, ctx: &LookupContext) -> RyxResult column: column.clone(), negated: ctx.negated, backend: ctx.backend, + json_key: ctx.json_key.clone(), }; // For SQLite, handle type conversion for comparisons on transformed values @@ -335,6 +368,7 @@ pub fn resolve(field: &str, lookup_name: &str, ctx: &LookupContext) -> RyxResult column: transformed, negated: ctx.negated, backend: ctx.backend, + json_key: ctx.json_key.clone(), }; return resolve_simple(field, final_lookup, &final_ctx_int); } @@ -421,7 +455,13 @@ pub fn registered_lookups() -> RyxResult> { /// Apply a field transformation (date, year, month, key, etc.) /// Returns SQL like "DATE(col)" or "EXTRACT(YEAR FROM col)" -pub fn apply_transform(name: &str, column: &str, backend: Backend) -> RyxResult { +/// For JSON transforms (key, key_text), the key is extracted from the next part of the chain +pub fn apply_transform( + name: &str, + column: &str, + backend: Backend, + key: Option<&str>, +) -> RyxResult { let sql = match (name, backend) { // Date/Time transforms ("date", _) => format!("DATE({})", column), @@ -481,17 +521,35 @@ pub fn apply_transform(name: &str, column: &str, backend: Backend) -> RyxResult< ("iso_dow", Backend::MySQL) => format!("((DAYOFWEEK({}) + 5) % 7) + 1", column), ("iso_dow", Backend::SQLite) => format!("CAST(strftime('%w', {}) AS TEXT)", column), - // JSON transforms (key extraction) - ("key", Backend::PostgreSQL) => format!("({}->>'key')", column), - ("key", Backend::MySQL) => format!("JSON_UNQUOTE(JSON_EXTRACT({}, '$.key'))", column), - ("key", Backend::SQLite) => format!("json_extract({}, '$.key')", column), + // JSON transforms (key extraction) - key comes from the next part of the chain + ("key", Backend::PostgreSQL) => { + let k = key.unwrap_or("key"); + format!("({}->>'{}')", column, k) + } + ("key", Backend::MySQL) => { + let k = key.unwrap_or("key"); + format!("JSON_UNQUOTE(JSON_EXTRACT({}, '$.{}'))", column, k) + } + ("key", Backend::SQLite) => { + let k = key.unwrap_or("key"); + format!("json_extract({}, '$.{}')", column, k) + } - ("key_text", Backend::PostgreSQL) => format!("({}->>'key')::text", column), - ("key_text", Backend::MySQL) => format!( - "CAST(JSON_UNQUOTE(JSON_EXTRACT({}, '$.key')) AS CHAR)", - column - ), - ("key_text", Backend::SQLite) => format!("CAST(json_extract({}, '$.key') AS TEXT)", column), + ("key_text", Backend::PostgreSQL) => { + let k = key.unwrap_or("key"); + format!("({}->>'{}')::text", column, k) + } + ("key_text", Backend::MySQL) => { + let k = key.unwrap_or("key"); + format!( + "CAST(JSON_UNQUOTE(JSON_EXTRACT({}, '.{}')) AS CHAR)", + column, k + ) + } + ("key_text", Backend::SQLite) => { + let k = key.unwrap_or("key"); + format!("CAST(json_extract({}, '.{}') AS TEXT)", column, k) + } ("json", Backend::PostgreSQL) => format!("({}::jsonb)", column), ("json", Backend::MySQL) => column.to_string(), diff --git a/tests/integration/test_lookups_integration.py b/tests/integration/test_lookups_integration.py index 297f8eb..956195b 100644 --- a/tests/integration/test_lookups_integration.py +++ b/tests/integration/test_lookups_integration.py @@ -222,6 +222,66 @@ async def test_json_key_lookups_text_field(self, clean_tables): # Actual JSON extraction requires JSONField +class TestJSONDynamicKeyLookups: + """Test dynamic JSON key lookups like metadata__key__icontains.""" + + @pytest.mark.asyncio + async def test_json_dynamic_key_exact(self, clean_tables): + """Test dynamic key lookup using explicit key transform: bio__key__priority__exact='high'.""" + await Author.objects.create( + name="Author 1", + email="a1@test.com", + bio='{"priority": "high", "role": "admin"}', + ) + await Author.objects.create( + name="Author 2", + email="a2@test.com", + bio='{"priority": "low", "role": "user"}', + ) + await Author.objects.create( + name="Author 3", email="a3@test.com", bio='{"other": "value"}' + ) + + # Use explicit key transform format: field__key__keyname__lookup + results = await Author.objects.filter(bio__key__priority__exact="high") + + assert len(results) == 1 + assert results[0].name == "Author 1" + + @pytest.mark.asyncio + async def test_json_dynamic_key_contains(self, clean_tables): + """Test dynamic key with explicit exact lookup. + + The Python parser treats 'key__role' as a chained lookup because 'key' is known. + We use explicit __exact to avoid this. + """ + await Author.objects.create( + name="Author 1", email="a1@test.com", bio='{"role": "admin"}' + ) + await Author.objects.create( + name="Author 2", email="a2@test.com", bio='{"role": "user"}' + ) + await Author.objects.create( + name="Author 3", email="a3@test.com", bio='{"role": "manager"}' + ) + + # Use explicit __exact to force proper parsing + results = await Author.objects.filter(bio__key__role__exact="admin") + assert len(results) == 1 + assert results[0].name == "Author 1" + + @pytest.mark.asyncio + async def test_json_dynamic_key_not_exists(self, clean_tables): + """Test that missing key returns no results.""" + await Author.objects.create( + name="Author 1", email="a1@test.com", bio='{"priority": "high"}' + ) + + # Use explicit key transform for non-existent key + results = await Author.objects.filter(bio__key__nonexistent__exact="value") + assert len(results) == 0 + + class TestLookupsWithOrdering: """Test lookups combined with ordering."""