Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .fluree-memory/repo.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -1354,6 +1354,21 @@ mem:fact-01kjte1yw4jyrqvnpq27mr2m9v a mem:Fact ;
mem:branch "main" ;
mem:createdAt "2026-03-03T18:06:09.284115+00:00"^^xsd:dateTime .

mem:fact-01ks1m2nn2exrjt7tjkmzgqtg2 a mem:Fact ;
mem:content "SUM/AVG aggregates use a shared `NumericAcc` in fluree-db-query/src/aggregate.rs that handles xsd:integer/decimal/double + BigInt with W3C type promotion (Integer→Decimal→Double, sticky upward). Inputs flow through `binding_to_numeric` (Lit + inline-encoded NUM_INT/NUM_F64) or `extract_numeric_with_gv` in group_aggregate.rs (also decodes NUM_BIG via BinaryGraphView). AVG of integer/decimal inputs yields xsd:decimal (W3C op:numeric-divide), with division precision capped at AVG_DECIMAL_PRECISION=34 (decimal128) to bound output. Both streaming (group_aggregate.rs AggState::Sum/Avg) and non-streaming (aggregate.rs agg_sum/agg_avg) paths share the accumulator." ;
mem:tag "aggregate" ;
mem:tag "avg" ;
mem:tag "decimal" ;
mem:tag "numeric-promotion" ;
mem:tag "sparql" ;
mem:tag "sum" ;
mem:scope mem:repo ;
mem:artifactRef "fluree-db-query/src/aggregate.rs" ;
mem:artifactRef "fluree-db-query/src/group_aggregate.rs" ;
mem:branch "main" ;
mem:createdAt "2026-05-20T02:40:16.034976+00:00"^^xsd:dateTime ;
mem:rationale "Pre-fix the extractors only handled Long/Double/Boolean and silently dropped FlakeValue::Decimal, causing SUM(decimal)=0 and AVG(decimal)=Unbound. The shared NumericAcc keeps the streaming and non-streaming paths in sync and prevents future drift between them." .

mem:constraint-01kp8yczz09ndt54ztchbheeyb a mem:Constraint ;
mem:content "JSON-LD transaction parsing does NOT auto-promote bare string values to IRI references. User data becomes an IRI only when wrapped in `{\"@id\": \"...\"}` or when the property has `@type: \"@id\"` in `@context`. The former `looks_like_iri` heuristic (three sites in parse/jsonld.rs) was removed — it silently promoted strings starting with `http://`/`https://`/`did:`/`fluree:`/`urn:` to IRIs, which violated the JSON-LD spec (especially for `{\"@value\": \"...\"}`, explicitly a literal)." ;
mem:tag "iri" ;
Expand Down
90 changes: 83 additions & 7 deletions fluree-db-api/tests/it_query_sparql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,13 +810,20 @@ async fn sparql_aggregate_avg_over_values() {
.unwrap();
let jsonld = result.to_jsonld(&ledger.snapshot).expect("to_jsonld");

let avg = jsonld
// Per W3C, AVG of integers yields xsd:decimal — JSON-LD renders decimals
// as strings to preserve exactness (vs. xsd:double which renders as a
// number). Parse the string and check the numeric value.
let avg_cell = jsonld
.as_array()
.and_then(|arr| arr.first())
.and_then(|row| row.as_array())
.and_then(|row| row.first())
.and_then(serde_json::Value::as_f64)
.expect("avg result");
.expect("avg cell");
let avg: f64 = avg_cell
.as_str()
.expect("avg rendered as decimal string")
.parse()
.expect("decimal parses as number");
assert!((avg - 17.666_666_666_666_67).abs() < 1e-12);
}

Expand All @@ -840,12 +847,19 @@ async fn sparql_group_by_having_filters_groups() {
.unwrap();
let jsonld = result.to_jsonld(&ledger.snapshot).expect("to_jsonld");

// Per W3C, AVG of integers yields xsd:decimal — JSON-LD serializes
// decimals as strings to preserve precision.
let mut values: Vec<f64> = jsonld
.as_array()
.expect("avg rows array")
.iter()
.flat_map(|row| row.as_array().expect("row array").iter())
.filter_map(serde_json::Value::as_f64)
.map(|cell| {
cell.as_str()
.expect("avg cell rendered as decimal string")
.parse::<f64>()
.expect("parses as number")
})
.collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap());

Expand Down Expand Up @@ -923,8 +937,18 @@ async fn sparql_multiple_select_expressions_with_aggregate_alias() {

let rows = normalize_rows(&jsonld);
assert_eq!(rows.len(), 1);
let avg = rows[0][0].as_f64().expect("avg");
let ceil = rows[0][1].as_f64().expect("ceil");
// Per W3C, AVG of integers yields xsd:decimal (JSON-LD: string);
// CEIL of an xsd:decimal yields xsd:decimal too.
let avg: f64 = rows[0][0]
.as_str()
.expect("avg as decimal string")
.parse()
.expect("parses");
let ceil: f64 = rows[0][1]
.as_str()
.expect("ceil as decimal string")
.parse()
.expect("parses");
assert!((avg - 17.666_666_666_666_67).abs() < 1e-12);
assert!((ceil - 18.0).abs() < 1e-12);
}
Expand Down Expand Up @@ -1018,7 +1042,12 @@ async fn sparql_mix_of_grouped_values_and_aggregates() {
.iter()
.map(|v| v.as_i64().expect("favNum"))
.collect::<Vec<_>>();
let avg = row[1].as_f64().expect("avg");
// AVG of integers → xsd:decimal (JSON string).
let avg: f64 = row[1]
.as_str()
.expect("avg as decimal string")
.parse()
.expect("parses");
let person = row[2].as_str().expect("person").to_string();
let handle = row[3].as_str().expect("handle").to_string();
let max = row[4].as_i64().expect("max");
Expand Down Expand Up @@ -2799,6 +2828,53 @@ async fn sparql_isnumeric_decimal() {
assert_eq!(jsonld, json!([["食べ物", true, false]]));
}

#[tokio::test]
async fn sparql_sum_avg_over_xsd_decimal_repro() {
// Repro for reported bug: SUM(?x) over xsd:decimal returns 0,
// AVG(?x) returns unbound. SUM(xsd:integer) and MIN/MAX over the same
// decimals work, so the bug is specific to arithmetic aggregates +
// xsd:decimal.
let fluree = FlureeBuilder::memory().build_memory();
let ledger = seed_builtin_fn_data(&fluree, "agg:decimal-sum").await;

let query = r"
PREFIX ex: <http://example.org/ns/>
SELECT
(SUM(?price) AS ?total)
(AVG(?price) AS ?avg)
(MIN(?price) AS ?lo)
(MAX(?price) AS ?hi)
(COUNT(?price) AS ?n)
WHERE { ?x ex:price ?price }
";

let result = support::query_sparql(&fluree, &ledger, query)
.await
.expect("aggregate over decimal");
let sparql_json = result
.to_sparql_json(&ledger.snapshot)
.expect("to_sparql_json");
let bindings = normalize_sparql_bindings(&sparql_json);
assert_eq!(bindings.len(), 1);
let row = &bindings[0];

// 12.50 + 7.99 = 20.49 — exact xsd:decimal arithmetic, not lossy f64.
let total = row.get("total").expect("total bound");
assert_eq!(total["value"].as_str().unwrap(), "20.49");
assert_eq!(
total["datatype"].as_str().unwrap(),
"http://www.w3.org/2001/XMLSchema#decimal",
"SUM(xsd:decimal) must yield xsd:decimal per W3C arithmetic promotion"
);

let avg = row.get("avg").expect("avg bound (not Unbound)");
assert_eq!(avg["value"].as_str().unwrap(), "10.245");
assert_eq!(
avg["datatype"].as_str().unwrap(),
"http://www.w3.org/2001/XMLSchema#decimal"
);
}

#[tokio::test]
async fn sparql_ucase_preserves_language_tag() {
// W3C: UCASE must preserve language tags from the input.
Expand Down
21 changes: 13 additions & 8 deletions fluree-db-api/tests/it_query_subquery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,13 +388,14 @@ async fn subquery_inside_union() {
.unwrap()
.to_jsonld(&ledger.snapshot)
.unwrap();
assert_eq!(
normalize_rows(&rows),
normalize_rows(&json!([
["Alice", 42.333_333_333_333_336_f64],
["Cam", 7.5_f64]
]))
);
// Per W3C, AVG of integers yields xsd:decimal (JSON-LD serializes as string).
let normalized = normalize_rows(&rows);
let avg_alice: f64 = normalized[0][1].as_str().unwrap().parse().unwrap();
let avg_cam: f64 = normalized[1][1].as_str().unwrap().parse().unwrap();
assert_eq!(normalized[0][0].as_str().unwrap(), "Alice");
assert!((avg_alice - 42.333_333_333_333_336_f64).abs() < 1e-12);
assert_eq!(normalized[1][0].as_str().unwrap(), "Cam");
assert!((avg_cam - 7.5_f64).abs() < 1e-12);
}

#[tokio::test]
Expand All @@ -418,7 +419,11 @@ async fn subquery_union_branch_query_alone_has_results() {
.unwrap()
.to_jsonld(&ledger.snapshot)
.unwrap();
assert_eq!(rows, json!([["Alice", 42.333_333_333_333_336_f64]]));
// AVG of integers yields xsd:decimal (JSON-LD: string).
let row = &rows.as_array().unwrap()[0].as_array().unwrap();
assert_eq!(row[0].as_str().unwrap(), "Alice");
let avg: f64 = row[1].as_str().unwrap().parse().unwrap();
assert!((avg - 42.333_333_333_333_336_f64).abs() < 1e-12);
}

#[tokio::test]
Expand Down
9 changes: 9 additions & 0 deletions fluree-db-core/src/sid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ impl Sid {
SID.clone()
}

/// XSD `xsd:decimal` SID.
///
/// Cached via `LazyLock` — the `Arc<str>` is allocated once and reused.
pub fn xsd_decimal() -> Sid {
use std::sync::LazyLock;
static SID: LazyLock<Sid> = LazyLock::new(|| Sid::new(namespaces::XSD, xsd_names::DECIMAL));
SID.clone()
}

/// XSD `xsd:string` SID.
///
/// Cached via `LazyLock` — the `Arc<str>` is allocated once and reused.
Expand Down
Loading
Loading