Skip to main content

rete_core/sparql/
diag.rs

1//! Query diagnostics: a side channel for the expression **type errors** that
2//! SPARQL silently resolves: a FILTER or HAVING drops the row (an error stays an
3//! error through `!`), BIND, projection and ORDER BY see an unbound value.
4//!
5//! `FILTER(CONTAINS(?iri, "x"))` is not unsupported — it is a type error,
6//! because CONTAINS takes string literals and an IRI is not one, and the spec
7//! says a FILTER whose expression errors drops the row. That is correct and it
8//! stays: results are never changed by this module. What changes is that the error is
9//! *counted*, so a query that came back empty can say why.
10//!
11//! Recording happens only on the error path (and, for the case-sensitivity
12//! hint, once per function per query on the no-match path), into a
13//! thread-local sink — SPARQL evaluation is single-threaded, and a
14//! thread-local keeps two queries running on two server threads from
15//! mixing their warnings. The sink is bounded: one entry per
16//! `(function, argument position, argument kind)`, holding a count and the
17//! first offending value.
18
19use std::cell::RefCell;
20
21use super::{ArithOp, Builtin, FExpr, Op, QueryOutput};
22
23/// Where a type error was raised: a built-in function, or an operator
24/// (`+ - * /`, a comparison, `IN`), which SPARQL defines as functions too
25/// (`op:numeric-add`, `op:numeric-equal`, …) but which have no call syntax.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(crate) enum Site {
28    Fn(Builtin),
29    /// The operator's SPARQL spelling: `+`, `<=`, `IN`, …
30    Op(&'static str),
31}
32
33impl From<Builtin> for Site {
34    fn from(f: Builtin) -> Self {
35        Site::Fn(f)
36    }
37}
38
39impl Site {
40    pub(crate) fn arith(op: ArithOp) -> Site {
41        Site::Op(match op {
42            ArithOp::Add => "+",
43            ArithOp::Sub => "-",
44            ArithOp::Mul => "*",
45            ArithOp::Div => "/",
46        })
47    }
48
49    pub(crate) fn compare(op: Op) -> Site {
50        Site::Op(match op {
51            Op::Eq => "=",
52            Op::Ne => "!=",
53            Op::Lt => "<",
54            Op::Le => "<=",
55            Op::Gt => ">",
56            Op::Ge => ">=",
57        })
58    }
59
60    fn name(self) -> &'static str {
61        match self {
62            Site::Fn(f) => fn_name(f),
63            Site::Op(sym) => sym,
64        }
65    }
66
67    /// The family a site belongs to, which decides the hint.
68    fn family(self) -> Family {
69        match self {
70            Site::Fn(Builtin::Abs | Builtin::Ceil | Builtin::Floor | Builtin::Round) => {
71                Family::Numeric
72            }
73            Site::Op("+" | "-" | "*" | "/") => Family::Numeric,
74            Site::Op(_) => Family::Compare,
75            Site::Fn(Builtin::Lang | Builtin::Datatype) => Family::LiteralAccessor,
76            Site::Fn(Builtin::StrDt | Builtin::StrLang) => Family::Constructor,
77            Site::Fn(_) => Family::Other,
78        }
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum Family {
84    Numeric,
85    Compare,
86    LiteralAccessor,
87    Constructor,
88    Other,
89}
90
91/// How serious a [`QueryWarning`] is.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum WarningSeverity {
95    /// A SPARQL type error was raised and silently absorbed (FILTER drops the
96    /// row, BIND → unbound). The results are spec-correct; the query probably is not
97    /// what its author meant.
98    TypeError,
99    /// No error happened; a guess at why a query came back empty (for example,
100    /// string matching is case-sensitive). Phrased as a suggestion.
101    Hint,
102}
103
104impl WarningSeverity {
105    /// `"type-error"` or `"hint"` — the JSON spelling.
106    pub fn as_str(self) -> &'static str {
107        match self {
108            WarningSeverity::TypeError => "type-error",
109            WarningSeverity::Hint => "hint",
110        }
111    }
112}
113
114/// One diagnostic about a query evaluation: a counted class of type errors, or
115/// a hint. Returned by [`eval_query_with_warnings`](super::eval_query_with_warnings).
116#[derive(Debug, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub struct QueryWarning {
119    pub severity: WarningSeverity,
120    /// The SPARQL function name, upper-case (`"CONTAINS"`), or the operator's
121    /// spelling for an operator: `+` `-` `*` `/`, `=` `!=` `<` `<=` `>` `>=`,
122    /// `IN`.
123    pub function: String,
124    /// 1-based argument position the problem is about (0 = not argument-specific).
125    /// For an operator, 1 is the left operand and 2 the right one.
126    pub argument: usize,
127    /// What the argument was: `iri`, `blank-node`, `quoted-triple`, `numeric`,
128    /// `typed-literal`, `string`, `language-tagged`, `invalid-number`,
129    /// `language-mismatch`, `unbound`, `invalid-regex`, `not-a-datetime`,
130    /// `division-by-zero` (argument 2 of `/`), `incomparable` (a comparison
131    /// whose two operands no operator is defined for; argument 0), or, for the
132    /// hints, `case-sensitive` and `string-vs-number` (an `=` / `!=` / `IN`
133    /// that found a string unequal to the number its text spells; argument 0).
134    pub kind: String,
135    /// How many evaluations raised this error (an evaluation is one row reaching
136    /// the expression; LIMIT / ASK stop early, so this is not a data count).
137    pub count: u64,
138    /// The first offending value, truncated to 80 characters (plus `…`).
139    pub sample: Option<String>,
140    /// A short, actionable suggestion.
141    pub hint: String,
142    /// The whole warning as one plain-language sentence.
143    pub message: String,
144}
145
146/// Samples are cut to this many characters (plus `…`).
147const SAMPLE_CHARS: usize = 80;
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub(crate) enum ArgKind {
151    Iri,
152    Blank,
153    QuotedTriple,
154    Numeric,
155    TypedLiteral,
156    LangMismatch,
157    Unbound,
158    InvalidRegex,
159    NotDatetime,
160    /// A string literal (plain or `xsd:string`) where a number, a literal
161    /// accessor's operand or a simple literal was needed.
162    StringLit,
163    /// A language-tagged literal where a simple literal was needed.
164    LangTagged,
165    /// A literal of a numeric datatype whose lexical form is not a number.
166    InvalidNumber,
167    /// The divisor of `/` was zero.
168    DivByZero,
169    /// A comparison's two operands are a pair §17.3 defines no operator for
170    /// (a string and a number under `<`, two IRIs under `<`, two different
171    /// literals of an unknown datatype under `=`). Not argument-specific.
172    Incomparable,
173    /// Not an error: `=` / `!=` / `IN` compared a string with a number whose
174    /// value the string's text spells (`"2020" = 2020`), which is false.
175    StringVsNumber,
176}
177
178impl ArgKind {
179    fn as_str(self) -> &'static str {
180        match self {
181            ArgKind::Iri => "iri",
182            ArgKind::Blank => "blank-node",
183            ArgKind::QuotedTriple => "quoted-triple",
184            ArgKind::Numeric => "numeric",
185            ArgKind::TypedLiteral => "typed-literal",
186            ArgKind::LangMismatch => "language-mismatch",
187            ArgKind::Unbound => "unbound",
188            ArgKind::InvalidRegex => "invalid-regex",
189            ArgKind::NotDatetime => "not-a-datetime",
190            ArgKind::StringLit => "string",
191            ArgKind::LangTagged => "language-tagged",
192            ArgKind::InvalidNumber => "invalid-number",
193            ArgKind::DivByZero => "division-by-zero",
194            ArgKind::Incomparable => "incomparable",
195            ArgKind::StringVsNumber => "string-vs-number",
196        }
197    }
198}
199
200struct Entry {
201    site: Site,
202    pos: usize,
203    kind: ArgKind,
204    count: u64,
205    sample: String,
206    /// Kind-specific detail: a datatype, the two language tags, a regex error.
207    detail: String,
208}
209
210/// Bits of the case-sensitivity probe ([`note_no_match`]).
211pub(crate) const MISS_CONTAINS: u8 = 1;
212pub(crate) const MISS_STRSTARTS: u8 = 2;
213pub(crate) const MISS_STRENDS: u8 = 4;
214pub(crate) const MISS_REGEX: u8 = 8;
215
216#[derive(Default)]
217struct Sink {
218    entries: Vec<Entry>,
219    /// Constant needles of string predicates that returned `false` on valid
220    /// string arguments at least once: `(function, needle)`.
221    misses: Vec<(Builtin, String)>,
222}
223
224thread_local! {
225    static SINK: RefCell<Sink> = RefCell::new(Sink::default());
226}
227
228/// Start a fresh diagnostic scope (drops anything a previous query left).
229pub(crate) fn begin() {
230    SINK.with(|s| {
231        let mut s = s.borrow_mut();
232        s.entries.clear();
233        s.misses.clear();
234    });
235}
236
237/// Record one type error. `describe` builds `(sample, detail)` and runs only
238/// for the first occurrence of a `(func, pos, kind)` key.
239#[cold]
240#[inline(never)]
241pub(crate) fn record(
242    site: impl Into<Site>,
243    pos: usize,
244    kind: ArgKind,
245    describe: impl FnOnce() -> (String, String),
246) {
247    let site = site.into();
248    SINK.with(|s| {
249        let mut s = s.borrow_mut();
250        if let Some(e) = s
251            .entries
252            .iter_mut()
253            .find(|e| e.site == site && e.pos == pos && e.kind == kind)
254        {
255            e.count += 1;
256            return;
257        }
258        let (sample, detail) = describe();
259        s.entries.push(Entry {
260            site,
261            pos,
262            kind,
263            count: 1,
264            sample: truncate(&sample),
265            detail,
266        });
267    });
268}
269
270/// Record that a string predicate returned no match on valid string
271/// arguments. Called once per function per query (the caller keeps a bit in
272/// `Ctx`); the needle is kept only when it is a constant in the query.
273#[cold]
274#[inline(never)]
275pub(crate) fn note_no_match(func: Builtin, needle: &FExpr) {
276    if let FExpr::Const(c) = needle {
277        let lex = crate::terms::literal_lexical(c).unwrap_or_else(|| c.clone());
278        SINK.with(|s| s.borrow_mut().misses.push((func, lex)));
279    }
280}
281
282/// A string function rejected the bound term `token` at 1-based `pos`.
283#[cold]
284#[inline(never)]
285pub(crate) fn bad_arg(func: Builtin, pos: usize, token: &str) {
286    record(func, pos, kind_of(token), || {
287        (token.to_string(), detail_of(token))
288    });
289}
290
291/// Both arguments of a binary string function are strings, but argument 2
292/// carries a language tag that argument 1 does not share (§17.4.3).
293#[cold]
294#[inline(never)]
295pub(crate) fn lang_mismatch(func: Builtin, text: &str, pat: &str) {
296    record(func, 2, ArgKind::LangMismatch, || {
297        let tag = |t: &str| match crate::terms::lang_tag(t).filter(|l| !l.is_empty()) {
298            Some(l) => format!("@{l}"),
299            None => "no tag".to_string(),
300        };
301        (pat.to_string(), format!("{} vs {}", tag(pat), tag(text)))
302    });
303}
304
305/// The argument at 1-based `pos` is the variable `var`, unbound in this row.
306#[cold]
307#[inline(never)]
308pub(crate) fn unbound(site: impl Into<Site>, pos: usize, var: &str) {
309    record(site, pos, ArgKind::Unbound, || {
310        (format!("?{var}"), String::new())
311    });
312}
313
314/// The REGEX / REPLACE pattern does not compile.
315#[cold]
316#[inline(never)]
317pub(crate) fn invalid_regex(func: Builtin, pattern: &str, flags: &str) {
318    record(func, 2, ArgKind::InvalidRegex, || {
319        (
320            pattern.to_string(),
321            crate::row::Resolver::regex_error(pattern, flags),
322        )
323    });
324}
325
326/// A date/time accessor got a value that does not parse as an xsd:dateTime.
327#[cold]
328#[inline(never)]
329pub(crate) fn bad_datetime(func: Builtin, token: &str) {
330    record(func, 1, ArgKind::NotDatetime, || {
331        let detail = match crate::terms::literal_datatype(token) {
332            Some(dt) if token.starts_with('"') => short_dt(&dt),
333            _ => kind_of(token).as_str().to_string(),
334        };
335        (token.to_string(), detail)
336    });
337}
338
339/// A numeric function or an arithmetic operator got a term that is not a
340/// number: a literal of a numeric datatype with a valid lexical form is one,
341/// a string is not, whatever its text.
342#[cold]
343#[inline(never)]
344pub(crate) fn not_a_number(site: impl Into<Site>, pos: usize, token: &str) {
345    let kind = match term_kind(token) {
346        ArgKind::Numeric => ArgKind::InvalidNumber,
347        k => k,
348    };
349    record(site, pos, kind, || {
350        (token.to_string(), detail_of_any(token))
351    });
352}
353
354/// A comparison operator (`=` … `>=`, `IN`) got two operands §17.3 defines it
355/// for no pair of: a type error.
356#[cold]
357#[inline(never)]
358pub(crate) fn incomparable(site: Site, a: &str, b: &str) {
359    record(site, 0, ArgKind::Incomparable, || {
360        (
361            format!("{a} vs {b}"),
362            format!("{} vs {}", operand_kind(a), operand_kind(b)),
363        )
364    });
365}
366
367/// `=` / `!=` / `IN` compared a string with a number: unequal, as SPARQL says.
368/// Counted (as a hint, not an error) only when the string's text spells that
369/// number — `"2020" = 2020` — the case whose answer looks wrong to a reader.
370pub(crate) fn string_vs_number(site: Site, a: &str, b: &str) {
371    let (s, n) = if a.starts_with('"') && super::value::numeric(a).is_none() {
372        (a, b)
373    } else {
374        (b, a)
375    };
376    let (Some(text), Some(num)) = (crate::terms::as_number(s), super::value::numeric(n)) else {
377        return;
378    };
379    if text == num {
380        record(site, 0, ArgKind::StringVsNumber, || {
381            (format!("{a} vs {b}"), String::new())
382        });
383    }
384}
385
386/// How an operand reads in a comparison warning: `a string`, `xsd:integer`, …
387fn operand_kind(token: &str) -> String {
388    use super::value::{classify, Class};
389    match classify(token) {
390        Class::Blank => "a blank node".into(),
391        Class::Iri => "an IRI".into(),
392        Class::Triple => "a quoted triple".into(),
393        Class::Str => "a string".into(),
394        Class::Lang => match crate::terms::lang_tag(token) {
395            Some(l) if !l.is_empty() => format!("a language-tagged literal (@{l})"),
396            _ => "a language-tagged literal".into(),
397        },
398        Class::Other => match crate::terms::literal_datatype(token) {
399            Some(dt) if super::value::compared_by_value(&dt) => {
400                format!("an invalid {} literal", short_dt(&dt))
401            }
402            Some(dt) => format!("a literal of datatype {}", short_dt(&dt)),
403            None => "a literal".into(),
404        },
405        _ => match crate::terms::literal_datatype(token) {
406            Some(dt) => short_dt(&dt),
407            None => "a number".into(),
408        },
409    }
410}
411
412/// `/` with a zero divisor (`token` is the divisor as written).
413#[cold]
414#[inline(never)]
415pub(crate) fn div_by_zero(token: &str) {
416    record(Site::Op("/"), 2, ArgKind::DivByZero, || {
417        (token.to_string(), detail_of(token))
418    });
419}
420
421/// LANG / DATATYPE got a term that is not a literal.
422#[cold]
423#[inline(never)]
424pub(crate) fn not_a_literal(func: Builtin, token: &str) {
425    record(func, 1, kind_of(token), || {
426        (token.to_string(), String::new())
427    });
428}
429
430/// STRDT / STRLANG argument 1 is not a simple literal, or STRDT argument 2 is
431/// not an IRI.
432#[cold]
433#[inline(never)]
434pub(crate) fn bad_constructor_arg(func: Builtin, pos: usize, token: &str) {
435    record(func, pos, term_kind(token), || {
436        (token.to_string(), detail_of_any(token))
437    });
438}
439
440/// Classify any term, telling string and language-tagged literals apart from
441/// other typed ones (no allocation for IRIs and blank nodes).
442fn term_kind(token: &str) -> ArgKind {
443    if !token.starts_with('"') {
444        return kind_of(token);
445    }
446    if crate::terms::lang_tag(token).is_some_and(|l| !l.is_empty()) {
447        return ArgKind::LangTagged;
448    }
449    match crate::terms::literal_datatype(token) {
450        Some(dt) if dt == "http://www.w3.org/2001/XMLSchema#string" => ArgKind::StringLit,
451        None => ArgKind::StringLit,
452        Some(dt) if is_numeric_datatype(&dt) => ArgKind::Numeric,
453        Some(_) => ArgKind::TypedLiteral,
454    }
455}
456
457/// [`detail_of`], but a language-tagged literal reports its tag (`@en`).
458fn detail_of_any(token: &str) -> String {
459    match crate::terms::lang_tag(token).filter(|l| !l.is_empty()) {
460        Some(l) if token.starts_with('"') => format!("@{l}"),
461        _ => detail_of(token),
462    }
463}
464
465/// Classify a bound term that a string function rejected (no allocation for
466/// IRIs and blank nodes, the common case).
467fn kind_of(token: &str) -> ArgKind {
468    if token.starts_with("<<") {
469        ArgKind::QuotedTriple
470    } else if token.starts_with('<') {
471        ArgKind::Iri
472    } else if token.starts_with("_:") {
473        ArgKind::Blank
474    } else if crate::terms::literal_datatype(token).is_some_and(|dt| is_numeric_datatype(&dt)) {
475        ArgKind::Numeric
476    } else {
477        ArgKind::TypedLiteral
478    }
479}
480
481/// The short datatype of a literal (`xsd:integer`), else `""`.
482fn detail_of(token: &str) -> String {
483    match crate::terms::literal_datatype(token) {
484        Some(dt) if token.starts_with('"') => short_dt(&dt),
485        _ => String::new(),
486    }
487}
488
489fn is_numeric_datatype(dt: &str) -> bool {
490    dt.strip_prefix("http://www.w3.org/2001/XMLSchema#")
491        .is_some_and(|local| {
492            matches!(
493                local,
494                "integer"
495                    | "decimal"
496                    | "float"
497                    | "double"
498                    | "int"
499                    | "long"
500                    | "short"
501                    | "byte"
502                    | "nonNegativeInteger"
503                    | "nonPositiveInteger"
504                    | "negativeInteger"
505                    | "positiveInteger"
506                    | "unsignedInt"
507                    | "unsignedLong"
508                    | "unsignedShort"
509                    | "unsignedByte"
510            )
511        })
512}
513
514fn short_dt(dt: &str) -> String {
515    match dt.strip_prefix("http://www.w3.org/2001/XMLSchema#") {
516        Some(local) => format!("xsd:{local}"),
517        None => format!("<{dt}>"),
518    }
519}
520
521fn truncate(s: &str) -> String {
522    match s.char_indices().nth(SAMPLE_CHARS) {
523        Some((i, _)) => format!("{}…", &s[..i]),
524        None => s.to_string(),
525    }
526}
527
528/// SPARQL spelling of a builtin, for messages.
529pub(crate) fn fn_name(f: Builtin) -> &'static str {
530    match f {
531        Builtin::Str => "STR",
532        Builtin::StrLen => "STRLEN",
533        Builtin::UCase => "UCASE",
534        Builtin::LCase => "LCASE",
535        Builtin::Concat => "CONCAT",
536        Builtin::SubStr => "SUBSTR",
537        Builtin::StrBefore => "STRBEFORE",
538        Builtin::StrAfter => "STRAFTER",
539        Builtin::Contains => "CONTAINS",
540        Builtin::StrStarts => "STRSTARTS",
541        Builtin::StrEnds => "STRENDS",
542        Builtin::Regex => "REGEX",
543        Builtin::Replace => "REPLACE",
544        Builtin::EncodeForUri => "ENCODE_FOR_URI",
545        Builtin::Md5 => "MD5",
546        Builtin::Sha1 => "SHA1",
547        Builtin::Sha256 => "SHA256",
548        Builtin::Sha384 => "SHA384",
549        Builtin::Sha512 => "SHA512",
550        Builtin::Year => "YEAR",
551        Builtin::Month => "MONTH",
552        Builtin::Day => "DAY",
553        Builtin::Hours => "HOURS",
554        Builtin::Minutes => "MINUTES",
555        Builtin::Seconds => "SECONDS",
556        Builtin::Timezone => "TIMEZONE",
557        Builtin::Tz => "TZ",
558        Builtin::Abs => "ABS",
559        Builtin::Ceil => "CEIL",
560        Builtin::Floor => "FLOOR",
561        Builtin::Round => "ROUND",
562        Builtin::Lang => "LANG",
563        Builtin::Datatype => "DATATYPE",
564        Builtin::StrDt => "STRDT",
565        Builtin::StrLang => "STRLANG",
566        _ => "function",
567    }
568}
569
570fn ordinal(site: Site, pos: usize) -> String {
571    match (site, pos) {
572        (Site::Op(_), 1) => "the left operand".to_string(),
573        (Site::Op(_), 2) => "the right operand".to_string(),
574        _ => format!("argument {pos}"),
575    }
576}
577
578fn rows(n: u64) -> String {
579    if n == 1 {
580        "1 row".to_string()
581    } else {
582        format!("{n} rows")
583    }
584}
585
586/// The hint for a site family other than the string functions, or `None` to
587/// keep the kind's own hint (which was written for the string functions).
588fn family_hint(family: Family, pos: usize, kind: ArgKind) -> Option<&'static str> {
589    use ArgKind::*;
590    Some(match (family, kind) {
591        (
592            _,
593            Unbound | InvalidRegex | NotDatetime | LangMismatch | DivByZero | Incomparable
594            | StringVsNumber,
595        ) => return None,
596        (Family::Numeric, StringLit) => {
597            "a string is not a number; cast one that holds a number first, e.g. \
598             xsd:decimal(?x), or restrict the variable with isNumeric()"
599        }
600        (Family::Numeric, InvalidNumber) => {
601            "its text is not a valid number for its datatype; restrict the variable with \
602             isNumeric(), which checks the lexical form"
603        }
604        (Family::Numeric, _) => "it is not a number; restrict the variable with isNumeric()",
605        (Family::LiteralAccessor, _) => {
606            "LANG() and DATATYPE() take a literal; restrict the variable with isLiteral()"
607        }
608        (Family::Constructor, Blank | QuotedTriple) if pos == 1 => {
609            "argument 1 must be a simple literal; restrict the variable with isLiteral()"
610        }
611        (Family::Constructor, _) if pos == 1 => {
612            "argument 1 must be a simple literal (no language tag, no datatype); use STR() on \
613             it to take its lexical form"
614        }
615        (Family::Constructor, _) => {
616            "argument 2 of STRDT must be a datatype IRI, e.g. xsd:integer; IRI(?x) turns a \
617             string into one"
618        }
619        (Family::Compare | Family::Other, _) => return None,
620    })
621}
622
623/// Is a comparison's `"<kind> vs <kind>"` detail a string against a number?
624fn string_and_number(detail: &str) -> bool {
625    let numeric = |k: &str| {
626        k.strip_prefix("xsd:")
627            .is_some_and(|l| is_numeric_datatype(&format!("http://www.w3.org/2001/XMLSchema#{l}")))
628            || k == "a number"
629    };
630    match detail.split_once(" vs ") {
631        Some((l, r)) => (l == "a string" && numeric(r)) || (numeric(l) && r == "a string"),
632        None => false,
633    }
634}
635
636impl Entry {
637    /// The two comparison kinds, which are about a pair of operands rather
638    /// than one argument.
639    fn into_comparison_warning(self) -> QueryWarning {
640        let name = self.site.name();
641        let n = rows(self.count);
642        let sample = (!self.sample.is_empty()).then(|| format!("; e.g. {}", self.sample));
643        let (severity, hint, message) = if self.kind == ArgKind::StringVsNumber {
644            let hint = "SPARQL never reads a string as a number; to compare values cast \
645                        the string, e.g. xsd:integer(?x) or xsd:decimal(?x), or to compare \
646                        text use STR() on the number"
647                .to_string();
648            let message = format!(
649                "Operator {name} compared a string with a number in {n} and found them \
650                 unequal although the string's text is that number{}. Hint: {hint}.",
651                sample.unwrap_or_default()
652            );
653            (WarningSeverity::Hint, hint, message)
654        } else {
655            let hint = if string_and_number(&self.detail) {
656                "a string is never compared as a number; cast it first, e.g. \
657                 xsd:integer(?x) or xsd:decimal(?x), or compare the text of both sides \
658                 with STR()"
659            } else if self.detail.contains("an invalid ") {
660                "a value whose text is not valid for its datatype (an ill-typed literal, \
661                 such as a year of fewer than four digits typed xsd:gYear) compares with \
662                 nothing; fix the data, or compare the text of both sides with STR()"
663            } else {
664                "SPARQL orders only two numbers, two strings (with the same language tag), \
665                 two booleans or two date/time values of one type, and = cannot tell two \
666                 different literals of an unrecognised datatype apart; cast both sides to \
667                 one type, or compare their text with STR()"
668            }
669            .to_string();
670            let message = format!(
671                "Operator {name} compared {} in {n}: a SPARQL type error, which makes a \
672                 FILTER drop the row, even under ! (BIND leaves the variable unbound){}. \
673                 Hint: {hint}.",
674                self.detail.replacen(" vs ", " with ", 1),
675                sample.unwrap_or_default()
676            );
677            (WarningSeverity::TypeError, hint, message)
678        };
679        QueryWarning {
680            severity,
681            function: name.to_string(),
682            argument: 0,
683            kind: self.kind.as_str().to_string(),
684            count: self.count,
685            sample: (!self.sample.is_empty()).then_some(self.sample),
686            hint,
687            message,
688        }
689    }
690
691    fn into_warning(self) -> QueryWarning {
692        if matches!(self.kind, ArgKind::Incomparable | ArgKind::StringVsNumber) {
693            return self.into_comparison_warning();
694        }
695        let name = self.site.name();
696        let arg = ordinal(self.site, self.pos);
697        let n = rows(self.count);
698        let dt = |what: &str| {
699            if self.detail.is_empty() {
700                what.to_string()
701            } else {
702                format!("{what} ({})", self.detail)
703            }
704        };
705        let (what, hint) = match self.kind {
706            ArgKind::Iri => (
707                "an IRI".to_string(),
708                "wrap it in STR() to match the IRI's text".to_string(),
709            ),
710            ArgKind::Blank => (
711                "a blank node".to_string(),
712                "blank nodes are not strings; restrict the variable with isLiteral()".to_string(),
713            ),
714            ArgKind::QuotedTriple => (
715                "a quoted triple".to_string(),
716                "quoted triples are not strings; restrict the variable with isLiteral()"
717                    .to_string(),
718            ),
719            ArgKind::Numeric => (
720                dt("a number"),
721                "wrap it in STR() to match its lexical form".to_string(),
722            ),
723            ArgKind::TypedLiteral => (
724                dt("a non-string typed literal"),
725                "wrap it in STR() to match its lexical form".to_string(),
726            ),
727            ArgKind::StringLit => (
728                "a string".to_string(),
729                "wrap it in STR() to match its lexical form".to_string(),
730            ),
731            ArgKind::LangTagged => (
732                dt("a language-tagged literal"),
733                "use STR() on it to drop the language tag".to_string(),
734            ),
735            ArgKind::InvalidNumber => (
736                dt("a literal whose text is not a valid number for its datatype"),
737                String::new(),
738            ),
739            ArgKind::LangMismatch => (
740                format!(
741                    "a literal whose language tag does not match argument 1 ({})",
742                    self.detail
743                ),
744                "the language tags differ; use STR() on both arguments".to_string(),
745            ),
746            ArgKind::Unbound => (
747                format!("an unbound variable ({})", self.sample),
748                "the variable has no value in those rows (an OPTIONAL that did not match, \
749                 or a misspelled variable); use BOUND() or COALESCE() if that is intended"
750                    .to_string(),
751            ),
752            ArgKind::InvalidRegex => (
753                "an invalid regex pattern".to_string(),
754                format!(
755                    "invalid regex pattern ({}); rete uses Rust regex syntax, which has no \
756                     look-around or back-references",
757                    self.detail
758                ),
759            ),
760            ArgKind::NotDatetime => (
761                dt("a value that is not an xsd:dateTime"),
762                "this function needs an xsd:dateTime; for a year in a plain or xsd:date value \
763                 use SUBSTR(STR(?x), 1, 4)"
764                    .to_string(),
765            ),
766            ArgKind::DivByZero => (
767                "zero".to_string(),
768                "guard the divisor, e.g. IF(?d != 0, ?n / ?d, 0), or FILTER(?d != 0) first"
769                    .to_string(),
770            ),
771            ArgKind::Incomparable | ArgKind::StringVsNumber => {
772                unreachable!("handled by into_comparison_warning")
773            }
774        };
775        let hint = match family_hint(self.site.family(), self.pos, self.kind) {
776            Some(h) => h.to_string(),
777            None => hint,
778        };
779        let mut message = match (self.kind, self.site) {
780            (ArgKind::DivByZero, _) => format!("{name} divided by zero in {n}: a SPARQL error"),
781            (_, Site::Op(_)) => {
782                format!("Operator {name} received {what} as {arg} in {n}: a SPARQL type error")
783            }
784            _ => format!("{name} received {what} as {arg} in {n}: a SPARQL type error"),
785        };
786        message.push_str(
787            ", which makes a FILTER drop the row, even under ! (BIND leaves the variable unbound)",
788        );
789        if !matches!(self.kind, ArgKind::Unbound | ArgKind::DivByZero) && !self.sample.is_empty() {
790            message.push_str(&format!("; e.g. {}", self.sample));
791        }
792        message.push_str(&format!(". Hint: {hint}."));
793        QueryWarning {
794            severity: WarningSeverity::TypeError,
795            function: name.to_string(),
796            argument: self.pos,
797            kind: self.kind.as_str().to_string(),
798            count: self.count,
799            sample: (!self.sample.is_empty()).then_some(self.sample),
800            hint,
801            message,
802        }
803    }
804}
805
806fn is_empty_result(out: &QueryOutput) -> bool {
807    match out {
808        QueryOutput::Select(_, rows) => rows.is_empty(),
809        QueryOutput::Ask(b) => !*b,
810        QueryOutput::Construct(t) => t.is_empty(),
811    }
812}
813
814/// Close the scope opened by [`begin`]: turn the sink into warnings for `out`.
815pub(crate) fn finish(out: &QueryOutput) -> Vec<QueryWarning> {
816    let (entries, misses) = SINK.with(|s| {
817        let mut s = s.borrow_mut();
818        (
819            std::mem::take(&mut s.entries),
820            std::mem::take(&mut s.misses),
821        )
822    });
823    let errored = !entries.is_empty();
824    let mut out_w: Vec<QueryWarning> = entries.into_iter().map(Entry::into_warning).collect();
825    // Case-sensitivity hint: only for an empty result, only when no type
826    // error offers a better explanation, only for a constant needle that has
827    // letters with case, and only when the predicate actually ran on string
828    // values and said no. It is a suggestion, never an error.
829    if !errored && is_empty_result(out) {
830        let mut seen: Vec<(Builtin, String)> = Vec::new();
831        for (func, needle) in misses {
832            if needle.to_lowercase() == needle.to_uppercase()
833                || seen.iter().any(|(f, n)| *f == func && *n == needle)
834            {
835                continue;
836            }
837            seen.push((func, needle.clone()));
838            let name = fn_name(func);
839            let hint = if func == Builtin::Regex {
840                format!("add the \"i\" flag: REGEX(?x, \"{needle}\", \"i\")")
841            } else {
842                format!(
843                    "compare case-folded: {name}(LCASE(?x), \"{}\")",
844                    needle.to_lowercase()
845                )
846            };
847            out_w.push(QueryWarning {
848                severity: WarningSeverity::Hint,
849                function: name.to_string(),
850                argument: 2,
851                kind: "case-sensitive".to_string(),
852                count: 0,
853                sample: Some(truncate(&needle)),
854                message: format!(
855                    "No results. {name} ran on string values but never matched \"{}\"; \
856                     string matching is case-sensitive. If you expected matches, {hint}.",
857                    truncate(&needle)
858                ),
859                hint,
860            });
861        }
862    }
863    out_w
864}
865
866/// The warnings as a JSON array (`[]` when empty), in the field order of
867/// [`QueryWarning`] with `argKind` for `kind`.
868pub fn warnings_json(warnings: &[QueryWarning]) -> String {
869    use crate::results::push_json_string;
870    let mut s = String::from("[");
871    for (i, w) in warnings.iter().enumerate() {
872        if i > 0 {
873            s.push(',');
874        }
875        s.push_str(r#"{"severity":"#);
876        push_json_string(&mut s, w.severity.as_str());
877        s.push_str(r#","function":"#);
878        push_json_string(&mut s, &w.function);
879        s.push_str(&format!(r#","argument":{}"#, w.argument));
880        s.push_str(r#","argKind":"#);
881        push_json_string(&mut s, &w.kind);
882        s.push_str(&format!(r#","count":{}"#, w.count));
883        s.push_str(r#","sample":"#);
884        match &w.sample {
885            Some(v) => push_json_string(&mut s, v),
886            None => s.push_str("null"),
887        }
888        s.push_str(r#","hint":"#);
889        push_json_string(&mut s, &w.hint);
890        s.push_str(r#","message":"#);
891        push_json_string(&mut s, &w.message);
892        s.push('}');
893    }
894    s.push(']');
895    s
896}