Skip to content
54 changes: 38 additions & 16 deletions src/query/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,29 +452,47 @@ fn compile_single_filter(
) -> RyxResult<String> {
// 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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
84 changes: 71 additions & 13 deletions src/query/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// The function signature for a built-in lookup implementation.
Expand Down Expand Up @@ -222,9 +226,10 @@ pub fn register_custom(name: impl Into<String>, sql_template: impl Into<String>)

/// 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<String> {
Expand All @@ -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);
}
Expand All @@ -251,6 +257,23 @@ fn handle_sqlite_transform_lookup(
pub fn resolve(field: &str, lookup_name: &str, ctx: &LookupContext) -> RyxResult<String> {
// 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(") {
Expand Down Expand Up @@ -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!(
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -421,7 +455,13 @@ pub fn registered_lookups() -> RyxResult<Vec<String>> {

/// 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<String> {
/// 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<String> {
let sql = match (name, backend) {
// Date/Time transforms
("date", _) => format!("DATE({})", column),
Expand Down Expand Up @@ -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(),
Expand Down
60 changes: 60 additions & 0 deletions tests/integration/test_lookups_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading