diff --git a/fixtures/queryer.json b/fixtures/queryer.json index fafb704..8d16410 100644 --- a/fixtures/queryer.json +++ b/fixtures/queryer.json @@ -365,7 +365,9 @@ "name", "age", "archived", - "created_at" + "created_at", + "rating", + "tags" ], "grouped_fields": { "entity": [ @@ -380,7 +382,9 @@ "person": [ "first_name", "last_name", - "age" + "age", + "rating", + "tags" ] }, "field_types": { @@ -391,7 +395,9 @@ "last_name": "text", "name": "text", "age": "numeric", - "created_at": "timestamptz" + "created_at": "timestamptz", + "rating": "float8", + "tags": "jsonb" }, "schemas": { "person": { @@ -405,6 +411,15 @@ }, "age": { "type": "number" + }, + "rating": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -416,6 +431,15 @@ }, "last_name": { "type": "string" + }, + "rating": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -475,6 +499,15 @@ } } } + }, + "rating": { + "type": "number" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } } } } @@ -1290,7 +1323,9 @@ " 'name', organization_2.name,", " 'first_name', person_3.first_name,", " 'last_name', person_3.last_name,", - " 'age', person_3.age", + " 'age', person_3.age,", + " 'rating', person_3.rating,", + " 'tags', person_3.tags", " )", " FROM agreego.entity entity_1", " JOIN agreego.organization organization_2", @@ -1320,6 +1355,8 @@ " 'first_name', person_3.first_name,", " 'last_name', person_3.last_name,", " 'age', person_3.age,", + " 'rating', person_3.rating,", + " 'tags', person_3.tags,", " 'phone_numbers', (", " SELECT COALESCE(jsonb_agg(jsonb_build_object(", " 'id', entity_4.id,", @@ -1570,6 +1607,8 @@ " 'first_name', person_3.first_name,", " 'last_name', person_3.last_name,", " 'age', person_3.age,", + " 'rating', person_3.rating,", + " 'tags', person_3.tags,", " 'phone_numbers', (", " SELECT COALESCE(jsonb_agg(jsonb_build_object(", " 'id', entity_4.id,", @@ -1829,7 +1868,9 @@ " 'name', organization_4.name,", " 'first_name', person_5.first_name,", " 'last_name', person_5.last_name,", - " 'age', person_5.age", + " 'age', person_5.age,", + " 'rating', person_5.rating,", + " 'tags', person_5.tags", " )", " FROM agreego.entity entity_3", " JOIN agreego.organization organization_4", @@ -1939,7 +1980,9 @@ " 'name', organization_9.name,", " 'first_name', person_10.first_name,", " 'last_name', person_10.last_name,", - " 'age', person_10.age", + " 'age', person_10.age,", + " 'rating', person_10.rating,", + " 'tags', person_10.tags", " )", " FROM agreego.entity entity_8", " JOIN agreego.organization organization_9", @@ -1993,7 +2036,9 @@ " 'created_at', entity_6.created_at,", " 'name', organization_7.name,", " 'first_name', person_8.first_name,", - " 'last_name', person_8.last_name", + " 'last_name', person_8.last_name,", + " 'rating', person_8.rating,", + " 'tags', person_8.tags", " )", " FROM agreego.entity entity_6", " JOIN agreego.organization organization_7", @@ -2033,6 +2078,8 @@ " 'first_name', person_5.first_name,", " 'last_name', person_5.last_name,", " 'age', person_5.age,", + " 'rating', person_5.rating,", + " 'tags', person_5.tags,", " 'phone_numbers', (", " SELECT COALESCE(jsonb_agg(jsonb_build_object(", " 'id', entity_6.id,", @@ -2227,7 +2274,9 @@ " 'name', organization_4.name,", " 'first_name', person_5.first_name,", " 'last_name', person_5.last_name,", - " 'age', person_5.age", + " 'age', person_5.age,", + " 'rating', person_5.rating,", + " 'tags', person_5.tags", " )", " FROM agreego.entity entity_3", " JOIN agreego.organization organization_4", @@ -2410,7 +2459,9 @@ " 'name', organization_11.name,", " 'first_name', person_12.first_name,", " 'last_name', person_12.last_name,", - " 'age', person_12.age", + " 'age', person_12.age,", + " 'rating', person_12.rating,", + " 'tags', person_12.tags", " )", " FROM agreego.entity entity_10", " JOIN agreego.organization organization_11", @@ -2437,6 +2488,202 @@ ] ] } + }, + { + "description": "Person filters: float8 comparison and jsonb array containment", + "action": "query", + "schema_id": "full.person", + "filter": { + "rating": { + "$gte": 4.0 + }, + "tags": { + "$eq": "landscaping", + "$of": [ + "plumbing", + "electrical" + ] + } + }, + "expect": { + "success": true, + "sql": [ + [ + "((SELECT jsonb_strip_nulls((", + " SELECT jsonb_build_object(", + " 'id', entity_1.id,", + " 'type', entity_1.type,", + " 'archived', entity_1.archived,", + " 'created_at', entity_1.created_at,", + " 'name', organization_2.name,", + " 'first_name', person_3.first_name,", + " 'last_name', person_3.last_name,", + " 'age', person_3.age,", + " 'rating', person_3.rating,", + " 'tags', person_3.tags,", + " 'phone_numbers', (", + " SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'id', entity_4.id,", + " 'type', entity_4.type,", + " 'archived', entity_4.archived,", + " 'created_at', entity_4.created_at,", + " 'is_primary', contact_6.is_primary,", + " 'target', (", + " SELECT jsonb_build_object(", + " 'id', entity_7.id,", + " 'type', entity_7.type,", + " 'archived', entity_7.archived,", + " 'created_at', entity_7.created_at,", + " 'number', phone_number_8.number", + " )", + " FROM agreego.entity entity_7", + " JOIN agreego.phone_number phone_number_8", + " WHERE", + " NOT entity_7.archived", + " AND relationship_5.target_id = entity_7.id", + " )", + " )), '[]'::jsonb)", + " FROM agreego.entity entity_4", + " JOIN agreego.relationship relationship_5", + " JOIN agreego.contact contact_6", + " WHERE", + " NOT entity_4.archived", + " AND relationship_5.target_type = 'phone_number'", + " AND relationship_5.source_id = entity_1.id", + " ),", + " 'email_addresses', (", + " SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'id', entity_9.id,", + " 'type', entity_9.type,", + " 'archived', entity_9.archived,", + " 'created_at', entity_9.created_at,", + " 'is_primary', contact_11.is_primary,", + " 'target', (", + " SELECT jsonb_build_object(", + " 'id', entity_12.id,", + " 'type', entity_12.type,", + " 'archived', entity_12.archived,", + " 'created_at', entity_12.created_at,", + " 'address', email_address_13.address", + " )", + " FROM agreego.entity entity_12", + " JOIN agreego.email_address email_address_13", + " WHERE", + " NOT entity_12.archived", + " AND relationship_10.target_id = entity_12.id", + " )", + " )), '[]'::jsonb)", + " FROM agreego.entity entity_9", + " JOIN agreego.relationship relationship_10", + " JOIN agreego.contact contact_11", + " WHERE", + " NOT entity_9.archived", + " AND relationship_10.target_type = 'email_address'", + " AND relationship_10.source_id = entity_1.id", + " ),", + " 'addresses', (", + " SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'id', entity_14.id,", + " 'type', entity_14.type,", + " 'archived', entity_14.archived,", + " 'created_at', entity_14.created_at,", + " 'is_primary', contact_16.is_primary,", + " 'target', (", + " SELECT jsonb_build_object(", + " 'id', entity_17.id,", + " 'type', entity_17.type,", + " 'archived', entity_17.archived,", + " 'created_at', entity_17.created_at,", + " 'city', address_18.city", + " )", + " FROM agreego.entity entity_17", + " JOIN agreego.address address_18", + " WHERE", + " NOT entity_17.archived", + " AND relationship_15.target_id = entity_17.id", + " )", + " )), '[]'::jsonb)", + " FROM agreego.entity entity_14", + " JOIN agreego.relationship relationship_15", + " JOIN agreego.contact contact_16", + " WHERE", + " NOT entity_14.archived", + " AND relationship_15.target_type = 'address'", + " AND relationship_15.source_id = entity_1.id", + " ),", + " 'contacts', (", + " SELECT COALESCE(jsonb_agg(jsonb_build_object(", + " 'id', entity_19.id,", + " 'type', entity_19.type,", + " 'archived', entity_19.archived,", + " 'created_at', entity_19.created_at,", + " 'is_primary', contact_21.is_primary,", + " 'target', CASE", + " WHEN relationship_20.target_type = 'phone_number' THEN ((", + " SELECT jsonb_build_object(", + " 'id', entity_22.id,", + " 'type', entity_22.type,", + " 'archived', entity_22.archived,", + " 'created_at', entity_22.created_at,", + " 'number', phone_number_23.number", + " )", + " FROM agreego.entity entity_22", + " JOIN agreego.phone_number phone_number_23", + " WHERE", + " NOT entity_22.archived", + " AND relationship_20.target_id = entity_22.id", + " ))", + " WHEN relationship_20.target_type = 'email_address' THEN ((", + " SELECT jsonb_build_object(", + " 'id', entity_24.id,", + " 'type', entity_24.type,", + " 'archived', entity_24.archived,", + " 'created_at', entity_24.created_at,", + " 'address', email_address_25.address", + " )", + " FROM agreego.entity entity_24", + " JOIN agreego.email_address email_address_25", + " WHERE", + " NOT entity_24.archived", + " AND relationship_20.target_id = entity_24.id", + " ))", + " WHEN relationship_20.target_type = 'address' THEN ((", + " SELECT jsonb_build_object(", + " 'id', entity_26.id,", + " 'type', entity_26.type,", + " 'archived', entity_26.archived,", + " 'created_at', entity_26.created_at,", + " 'city', address_27.city", + " )", + " FROM agreego.entity entity_26", + " JOIN agreego.address address_27", + " WHERE", + " NOT entity_26.archived", + " AND relationship_20.target_id = entity_26.id", + " ))", + " ELSE NULL", + " END", + " )), '[]'::jsonb)", + " FROM agreego.entity entity_19", + " JOIN agreego.relationship relationship_20", + " JOIN agreego.contact contact_21", + " WHERE", + " NOT entity_19.archived", + " AND relationship_20.source_id = entity_1.id", + " )", + " )", + " FROM agreego.entity entity_1", + " JOIN agreego.organization organization_2", + " JOIN agreego.person person_3", + " WHERE", + " NOT entity_1.archived", + " AND person_3.rating >= ($1 #>> '{}')::NUMERIC", + " AND person_3.tags ? ($2 #>> '{)", + " AND person_3.tags ?| ARRAY()", + " ))))" + ] + ] + } } ] } diff --git a/src/queryer/compiler.rs b/src/queryer/compiler.rs index 9108454..9c3e905 100644 --- a/src/queryer/compiler.rs +++ b/src/queryer/compiler.rs @@ -549,7 +549,7 @@ impl<'a> Compiler<'a> { where_clauses.push(format!("NOT {}.archived", entity_alias)); } - self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses); + self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses)?; self.compile_polymorphic_bounds(r#type, type_aliases, &node, &mut where_clauses); let start_len = where_clauses.len(); @@ -689,6 +689,11 @@ impl<'a> Compiler<'a> { || pg_type.contains("int") || pg_type == "real" || pg_type == "double precision" + // pg catalog typnames for real / double precision — the SQL + // names above never appear in field_types (they come from + // pg_type.typname), so these are what actually arrives + || pg_type == "float4" + || pg_type == "float8" { cast = "::numeric"; } else if pg_type == "text" || pg_type.contains("char") { @@ -715,7 +720,7 @@ impl<'a> Compiler<'a> { node: &Node, base_alias: &str, where_clauses: &mut Vec, - ) { + ) -> Result<(), String> { for (i, filter_key) in self.filter_keys.iter().enumerate() { let mut parts = filter_key.split(':'); let full_field_path = parts.next().unwrap_or(filter_key); @@ -745,6 +750,80 @@ impl<'a> Compiler<'a> { let param_index = i + 1; let p_val = format!("${}#>>'{{}}'", param_index); + // jsonb columns never type-check against the text parameter, and for + // array-valued properties (tag lists) the meaning of a condition is + // CONTAINMENT, not equality. Compile them explicitly; reject the rest + // loudly at compile time instead of failing at execution. + let is_jsonb = r#type + .field_types + .as_ref() + .and_then(|v| v.as_object()) + .and_then(|ft| ft.get(field_name)) + .and_then(|v| v.as_str()) + == Some("jsonb"); + if is_jsonb { + let is_array_prop = node + .schema + .obj + .properties + .as_ref() + .and_then(|p| p.get(field_name)) + .map(|ps| { + matches!( + &ps.obj.type_, + Some(crate::database::object::SchemaTypeOrArray::Single(t)) if t == "array" + ) + }) + .unwrap_or(false); + + if is_array_prop { + match op { + // "the array contains this value" + "$eq" => where_clauses.push(format!( + "{}.{} ? ({})", + filter_alias, field_name, p_val + )), + "$ne" => where_clauses.push(format!( + "NOT ({}.{} ? ({}))", + filter_alias, field_name, p_val + )), + // "the array contains ANY of these values" + "$of" => where_clauses.push(format!( + "{}.{} ?| ARRAY(SELECT jsonb_array_elements_text(({})::jsonb))", + filter_alias, field_name, p_val + )), + "$nof" => where_clauses.push(format!( + "NOT ({}.{} ?| ARRAY(SELECT jsonb_array_elements_text(({})::jsonb)))", + filter_alias, field_name, p_val + )), + other => { + return Err(format!( + "operator {} is not supported on array property '{}' (jsonb containment supports $eq/$ne/$of/$nof)", + other, field_name + )) + } + } + } else { + match op { + "$eq" => where_clauses.push(format!( + "{}.{} = ({})::jsonb", + filter_alias, field_name, p_val + )), + "$ne" => where_clauses.push(format!( + "{}.{} != ({})::jsonb", + filter_alias, field_name, p_val + )), + other => { + return Err(format!( + "operator {} is not supported on jsonb property '{}' (only $eq/$ne)", + other, field_name + )) + } + } + } + continue; + } + if op == "$of" || op == "$nof" { let sql_op = if op == "$of" { "IN" } else { "NOT IN" }; let subquery = format!( @@ -796,6 +875,7 @@ impl<'a> Compiler<'a> { )); } } + Ok(()) } fn compile_relation_conditions( diff --git a/src/tests/fixtures.rs b/src/tests/fixtures.rs index b22d10c..5ac8af2 100644 --- a/src/tests/fixtures.rs +++ b/src/tests/fixtures.rs @@ -1301,6 +1301,12 @@ fn test_queryer_0_15() { crate::tests::runner::run_test_case(&path, 0, 15).unwrap(); } +#[test] +fn test_queryer_0_16() { + let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR")); + crate::tests::runner::run_test_case(&path, 0, 16).unwrap(); +} + #[test] fn test_polymorphism_0_0() { let path = format!("{}/fixtures/polymorphism.json", env!("CARGO_MANIFEST_DIR"));