1use std::cell::RefCell;
20
21use super::{ArithOp, Builtin, FExpr, Op, QueryOutput};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(crate) enum Site {
28 Fn(Builtin),
29 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[non_exhaustive]
94pub enum WarningSeverity {
95 TypeError,
99 Hint,
102}
103
104impl WarningSeverity {
105 pub fn as_str(self) -> &'static str {
107 match self {
108 WarningSeverity::TypeError => "type-error",
109 WarningSeverity::Hint => "hint",
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub struct QueryWarning {
119 pub severity: WarningSeverity,
120 pub function: String,
124 pub argument: usize,
127 pub kind: String,
133 pub count: u64,
136 pub sample: Option<String>,
138 pub hint: String,
140 pub message: String,
142}
143
144const SAMPLE_CHARS: usize = 80;
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub(crate) enum ArgKind {
149 Iri,
150 Blank,
151 QuotedTriple,
152 Numeric,
153 TypedLiteral,
154 LangMismatch,
155 Unbound,
156 InvalidRegex,
157 NotDatetime,
158 StringLit,
161 LangTagged,
163 InvalidNumber,
165 DivByZero,
167}
168
169impl ArgKind {
170 fn as_str(self) -> &'static str {
171 match self {
172 ArgKind::Iri => "iri",
173 ArgKind::Blank => "blank-node",
174 ArgKind::QuotedTriple => "quoted-triple",
175 ArgKind::Numeric => "numeric",
176 ArgKind::TypedLiteral => "typed-literal",
177 ArgKind::LangMismatch => "language-mismatch",
178 ArgKind::Unbound => "unbound",
179 ArgKind::InvalidRegex => "invalid-regex",
180 ArgKind::NotDatetime => "not-a-datetime",
181 ArgKind::StringLit => "string",
182 ArgKind::LangTagged => "language-tagged",
183 ArgKind::InvalidNumber => "invalid-number",
184 ArgKind::DivByZero => "division-by-zero",
185 }
186 }
187}
188
189struct Entry {
190 site: Site,
191 pos: usize,
192 kind: ArgKind,
193 count: u64,
194 sample: String,
195 detail: String,
197}
198
199pub(crate) const MISS_CONTAINS: u8 = 1;
201pub(crate) const MISS_STRSTARTS: u8 = 2;
202pub(crate) const MISS_STRENDS: u8 = 4;
203pub(crate) const MISS_REGEX: u8 = 8;
204
205#[derive(Default)]
206struct Sink {
207 entries: Vec<Entry>,
208 misses: Vec<(Builtin, String)>,
211}
212
213thread_local! {
214 static SINK: RefCell<Sink> = RefCell::new(Sink::default());
215}
216
217pub(crate) fn begin() {
219 SINK.with(|s| {
220 let mut s = s.borrow_mut();
221 s.entries.clear();
222 s.misses.clear();
223 });
224}
225
226#[cold]
229#[inline(never)]
230pub(crate) fn record(
231 site: impl Into<Site>,
232 pos: usize,
233 kind: ArgKind,
234 describe: impl FnOnce() -> (String, String),
235) {
236 let site = site.into();
237 SINK.with(|s| {
238 let mut s = s.borrow_mut();
239 if let Some(e) = s
240 .entries
241 .iter_mut()
242 .find(|e| e.site == site && e.pos == pos && e.kind == kind)
243 {
244 e.count += 1;
245 return;
246 }
247 let (sample, detail) = describe();
248 s.entries.push(Entry {
249 site,
250 pos,
251 kind,
252 count: 1,
253 sample: truncate(&sample),
254 detail,
255 });
256 });
257}
258
259#[cold]
263#[inline(never)]
264pub(crate) fn note_no_match(func: Builtin, needle: &FExpr) {
265 if let FExpr::Const(c) = needle {
266 let lex = crate::terms::literal_lexical(c).unwrap_or_else(|| c.clone());
267 SINK.with(|s| s.borrow_mut().misses.push((func, lex)));
268 }
269}
270
271#[cold]
273#[inline(never)]
274pub(crate) fn bad_arg(func: Builtin, pos: usize, token: &str) {
275 record(func, pos, kind_of(token), || {
276 (token.to_string(), detail_of(token))
277 });
278}
279
280#[cold]
283#[inline(never)]
284pub(crate) fn lang_mismatch(func: Builtin, text: &str, pat: &str) {
285 record(func, 2, ArgKind::LangMismatch, || {
286 let tag = |t: &str| match crate::terms::lang_tag(t).filter(|l| !l.is_empty()) {
287 Some(l) => format!("@{l}"),
288 None => "no tag".to_string(),
289 };
290 (pat.to_string(), format!("{} vs {}", tag(pat), tag(text)))
291 });
292}
293
294#[cold]
296#[inline(never)]
297pub(crate) fn unbound(site: impl Into<Site>, pos: usize, var: &str) {
298 record(site, pos, ArgKind::Unbound, || {
299 (format!("?{var}"), String::new())
300 });
301}
302
303#[cold]
305#[inline(never)]
306pub(crate) fn invalid_regex(func: Builtin, pattern: &str, flags: &str) {
307 record(func, 2, ArgKind::InvalidRegex, || {
308 (
309 pattern.to_string(),
310 crate::row::Resolver::regex_error(pattern, flags),
311 )
312 });
313}
314
315#[cold]
317#[inline(never)]
318pub(crate) fn bad_datetime(func: Builtin, token: &str) {
319 record(func, 1, ArgKind::NotDatetime, || {
320 let detail = match crate::terms::literal_datatype(token) {
321 Some(dt) if token.starts_with('"') => short_dt(&dt),
322 _ => kind_of(token).as_str().to_string(),
323 };
324 (token.to_string(), detail)
325 });
326}
327
328#[cold]
332#[inline(never)]
333pub(crate) fn not_a_number(site: impl Into<Site>, pos: usize, token: &str) {
334 let kind = match term_kind(token) {
335 ArgKind::Numeric => ArgKind::InvalidNumber,
336 k => k,
337 };
338 record(site, pos, kind, || {
339 (token.to_string(), detail_of_any(token))
340 });
341}
342
343#[cold]
345#[inline(never)]
346pub(crate) fn div_by_zero(token: &str) {
347 record(Site::Op("/"), 2, ArgKind::DivByZero, || {
348 (token.to_string(), detail_of(token))
349 });
350}
351
352#[cold]
354#[inline(never)]
355pub(crate) fn not_a_literal(func: Builtin, token: &str) {
356 record(func, 1, kind_of(token), || {
357 (token.to_string(), String::new())
358 });
359}
360
361#[cold]
364#[inline(never)]
365pub(crate) fn bad_constructor_arg(func: Builtin, pos: usize, token: &str) {
366 record(func, pos, term_kind(token), || {
367 (token.to_string(), detail_of_any(token))
368 });
369}
370
371fn term_kind(token: &str) -> ArgKind {
374 if !token.starts_with('"') {
375 return kind_of(token);
376 }
377 if crate::terms::lang_tag(token).is_some_and(|l| !l.is_empty()) {
378 return ArgKind::LangTagged;
379 }
380 match crate::terms::literal_datatype(token) {
381 Some(dt) if dt == "http://www.w3.org/2001/XMLSchema#string" => ArgKind::StringLit,
382 None => ArgKind::StringLit,
383 Some(dt) if is_numeric_datatype(&dt) => ArgKind::Numeric,
384 Some(_) => ArgKind::TypedLiteral,
385 }
386}
387
388fn detail_of_any(token: &str) -> String {
390 match crate::terms::lang_tag(token).filter(|l| !l.is_empty()) {
391 Some(l) if token.starts_with('"') => format!("@{l}"),
392 _ => detail_of(token),
393 }
394}
395
396fn kind_of(token: &str) -> ArgKind {
399 if token.starts_with("<<") {
400 ArgKind::QuotedTriple
401 } else if token.starts_with('<') {
402 ArgKind::Iri
403 } else if token.starts_with("_:") {
404 ArgKind::Blank
405 } else if crate::terms::literal_datatype(token).is_some_and(|dt| is_numeric_datatype(&dt)) {
406 ArgKind::Numeric
407 } else {
408 ArgKind::TypedLiteral
409 }
410}
411
412fn detail_of(token: &str) -> String {
414 match crate::terms::literal_datatype(token) {
415 Some(dt) if token.starts_with('"') => short_dt(&dt),
416 _ => String::new(),
417 }
418}
419
420fn is_numeric_datatype(dt: &str) -> bool {
421 dt.strip_prefix("http://www.w3.org/2001/XMLSchema#")
422 .is_some_and(|local| {
423 matches!(
424 local,
425 "integer"
426 | "decimal"
427 | "float"
428 | "double"
429 | "int"
430 | "long"
431 | "short"
432 | "byte"
433 | "nonNegativeInteger"
434 | "nonPositiveInteger"
435 | "negativeInteger"
436 | "positiveInteger"
437 | "unsignedInt"
438 | "unsignedLong"
439 | "unsignedShort"
440 | "unsignedByte"
441 )
442 })
443}
444
445fn short_dt(dt: &str) -> String {
446 match dt.strip_prefix("http://www.w3.org/2001/XMLSchema#") {
447 Some(local) => format!("xsd:{local}"),
448 None => format!("<{dt}>"),
449 }
450}
451
452fn truncate(s: &str) -> String {
453 match s.char_indices().nth(SAMPLE_CHARS) {
454 Some((i, _)) => format!("{}…", &s[..i]),
455 None => s.to_string(),
456 }
457}
458
459pub(crate) fn fn_name(f: Builtin) -> &'static str {
461 match f {
462 Builtin::Str => "STR",
463 Builtin::StrLen => "STRLEN",
464 Builtin::UCase => "UCASE",
465 Builtin::LCase => "LCASE",
466 Builtin::Concat => "CONCAT",
467 Builtin::SubStr => "SUBSTR",
468 Builtin::StrBefore => "STRBEFORE",
469 Builtin::StrAfter => "STRAFTER",
470 Builtin::Contains => "CONTAINS",
471 Builtin::StrStarts => "STRSTARTS",
472 Builtin::StrEnds => "STRENDS",
473 Builtin::Regex => "REGEX",
474 Builtin::Replace => "REPLACE",
475 Builtin::EncodeForUri => "ENCODE_FOR_URI",
476 Builtin::Md5 => "MD5",
477 Builtin::Sha1 => "SHA1",
478 Builtin::Sha256 => "SHA256",
479 Builtin::Sha384 => "SHA384",
480 Builtin::Sha512 => "SHA512",
481 Builtin::Year => "YEAR",
482 Builtin::Month => "MONTH",
483 Builtin::Day => "DAY",
484 Builtin::Hours => "HOURS",
485 Builtin::Minutes => "MINUTES",
486 Builtin::Seconds => "SECONDS",
487 Builtin::Timezone => "TIMEZONE",
488 Builtin::Tz => "TZ",
489 Builtin::Abs => "ABS",
490 Builtin::Ceil => "CEIL",
491 Builtin::Floor => "FLOOR",
492 Builtin::Round => "ROUND",
493 Builtin::Lang => "LANG",
494 Builtin::Datatype => "DATATYPE",
495 Builtin::StrDt => "STRDT",
496 Builtin::StrLang => "STRLANG",
497 _ => "function",
498 }
499}
500
501fn ordinal(site: Site, pos: usize) -> String {
502 match (site, pos) {
503 (Site::Op(_), 1) => "the left operand".to_string(),
504 (Site::Op(_), 2) => "the right operand".to_string(),
505 _ => format!("argument {pos}"),
506 }
507}
508
509fn rows(n: u64) -> String {
510 if n == 1 {
511 "1 row".to_string()
512 } else {
513 format!("{n} rows")
514 }
515}
516
517fn family_hint(family: Family, pos: usize, kind: ArgKind) -> Option<&'static str> {
520 use ArgKind::*;
521 Some(match (family, kind) {
522 (_, Unbound | InvalidRegex | NotDatetime | LangMismatch | DivByZero) => return None,
523 (Family::Numeric, StringLit) => {
524 "a string is not a number; cast one that holds a number first, e.g. \
525 xsd:decimal(?x), or restrict the variable with isNumeric()"
526 }
527 (Family::Numeric, InvalidNumber) => {
528 "its text is not a valid number for its datatype; restrict the variable with \
529 isNumeric(), which checks the lexical form"
530 }
531 (Family::Numeric, _) => "it is not a number; restrict the variable with isNumeric()",
532 (Family::LiteralAccessor, _) => {
533 "LANG() and DATATYPE() take a literal; restrict the variable with isLiteral()"
534 }
535 (Family::Constructor, Blank | QuotedTriple) if pos == 1 => {
536 "argument 1 must be a simple literal; restrict the variable with isLiteral()"
537 }
538 (Family::Constructor, _) if pos == 1 => {
539 "argument 1 must be a simple literal (no language tag, no datatype); use STR() on \
540 it to take its lexical form"
541 }
542 (Family::Constructor, _) => {
543 "argument 2 of STRDT must be a datatype IRI, e.g. xsd:integer; IRI(?x) turns a \
544 string into one"
545 }
546 (Family::Compare | Family::Other, _) => return None,
547 })
548}
549
550impl Entry {
551 fn into_warning(self) -> QueryWarning {
552 let name = self.site.name();
553 let arg = ordinal(self.site, self.pos);
554 let n = rows(self.count);
555 let dt = |what: &str| {
556 if self.detail.is_empty() {
557 what.to_string()
558 } else {
559 format!("{what} ({})", self.detail)
560 }
561 };
562 let (what, hint) = match self.kind {
563 ArgKind::Iri => (
564 "an IRI".to_string(),
565 "wrap it in STR() to match the IRI's text".to_string(),
566 ),
567 ArgKind::Blank => (
568 "a blank node".to_string(),
569 "blank nodes are not strings; restrict the variable with isLiteral()".to_string(),
570 ),
571 ArgKind::QuotedTriple => (
572 "a quoted triple".to_string(),
573 "quoted triples are not strings; restrict the variable with isLiteral()"
574 .to_string(),
575 ),
576 ArgKind::Numeric => (
577 dt("a number"),
578 "wrap it in STR() to match its lexical form".to_string(),
579 ),
580 ArgKind::TypedLiteral => (
581 dt("a non-string typed literal"),
582 "wrap it in STR() to match its lexical form".to_string(),
583 ),
584 ArgKind::StringLit => (
585 "a string".to_string(),
586 "wrap it in STR() to match its lexical form".to_string(),
587 ),
588 ArgKind::LangTagged => (
589 dt("a language-tagged literal"),
590 "use STR() on it to drop the language tag".to_string(),
591 ),
592 ArgKind::InvalidNumber => (
593 dt("a literal whose text is not a valid number for its datatype"),
594 String::new(),
595 ),
596 ArgKind::LangMismatch => (
597 format!(
598 "a literal whose language tag does not match argument 1 ({})",
599 self.detail
600 ),
601 "the language tags differ; use STR() on both arguments".to_string(),
602 ),
603 ArgKind::Unbound => (
604 format!("an unbound variable ({})", self.sample),
605 "the variable has no value in those rows (an OPTIONAL that did not match, \
606 or a misspelled variable); use BOUND() or COALESCE() if that is intended"
607 .to_string(),
608 ),
609 ArgKind::InvalidRegex => (
610 "an invalid regex pattern".to_string(),
611 format!(
612 "invalid regex pattern ({}); rete uses Rust regex syntax, which has no \
613 look-around or back-references",
614 self.detail
615 ),
616 ),
617 ArgKind::NotDatetime => (
618 dt("a value that is not an xsd:dateTime"),
619 "this function needs an xsd:dateTime; for a year in a plain or xsd:date value \
620 use SUBSTR(STR(?x), 1, 4)"
621 .to_string(),
622 ),
623 ArgKind::DivByZero => (
624 "zero".to_string(),
625 "guard the divisor, e.g. IF(?d != 0, ?n / ?d, 0), or FILTER(?d != 0) first"
626 .to_string(),
627 ),
628 };
629 let hint = match family_hint(self.site.family(), self.pos, self.kind) {
630 Some(h) => h.to_string(),
631 None => hint,
632 };
633 let mut message = match (self.kind, self.site) {
634 (ArgKind::DivByZero, _) => format!("{name} divided by zero in {n}: a SPARQL error"),
635 (_, Site::Op(_)) => {
636 format!("Operator {name} received {what} as {arg} in {n}: a SPARQL type error")
637 }
638 _ => format!("{name} received {what} as {arg} in {n}: a SPARQL type error"),
639 };
640 message.push_str(
641 ", which makes a FILTER drop the row, even under ! (BIND leaves the variable unbound)",
642 );
643 if !matches!(self.kind, ArgKind::Unbound | ArgKind::DivByZero) && !self.sample.is_empty() {
644 message.push_str(&format!("; e.g. {}", self.sample));
645 }
646 message.push_str(&format!(". Hint: {hint}."));
647 QueryWarning {
648 severity: WarningSeverity::TypeError,
649 function: name.to_string(),
650 argument: self.pos,
651 kind: self.kind.as_str().to_string(),
652 count: self.count,
653 sample: (!self.sample.is_empty()).then_some(self.sample),
654 hint,
655 message,
656 }
657 }
658}
659
660fn is_empty_result(out: &QueryOutput) -> bool {
661 match out {
662 QueryOutput::Select(_, rows) => rows.is_empty(),
663 QueryOutput::Ask(b) => !*b,
664 QueryOutput::Construct(t) => t.is_empty(),
665 }
666}
667
668pub(crate) fn finish(out: &QueryOutput) -> Vec<QueryWarning> {
670 let (entries, misses) = SINK.with(|s| {
671 let mut s = s.borrow_mut();
672 (
673 std::mem::take(&mut s.entries),
674 std::mem::take(&mut s.misses),
675 )
676 });
677 let errored = !entries.is_empty();
678 let mut out_w: Vec<QueryWarning> = entries.into_iter().map(Entry::into_warning).collect();
679 if !errored && is_empty_result(out) {
684 let mut seen: Vec<(Builtin, String)> = Vec::new();
685 for (func, needle) in misses {
686 if needle.to_lowercase() == needle.to_uppercase()
687 || seen.iter().any(|(f, n)| *f == func && *n == needle)
688 {
689 continue;
690 }
691 seen.push((func, needle.clone()));
692 let name = fn_name(func);
693 let hint = if func == Builtin::Regex {
694 format!("add the \"i\" flag: REGEX(?x, \"{needle}\", \"i\")")
695 } else {
696 format!(
697 "compare case-folded: {name}(LCASE(?x), \"{}\")",
698 needle.to_lowercase()
699 )
700 };
701 out_w.push(QueryWarning {
702 severity: WarningSeverity::Hint,
703 function: name.to_string(),
704 argument: 2,
705 kind: "case-sensitive".to_string(),
706 count: 0,
707 sample: Some(truncate(&needle)),
708 message: format!(
709 "No results. {name} ran on string values but never matched \"{}\"; \
710 string matching is case-sensitive. If you expected matches, {hint}.",
711 truncate(&needle)
712 ),
713 hint,
714 });
715 }
716 }
717 out_w
718}
719
720pub fn warnings_json(warnings: &[QueryWarning]) -> String {
723 use crate::results::push_json_string;
724 let mut s = String::from("[");
725 for (i, w) in warnings.iter().enumerate() {
726 if i > 0 {
727 s.push(',');
728 }
729 s.push_str(r#"{"severity":"#);
730 push_json_string(&mut s, w.severity.as_str());
731 s.push_str(r#","function":"#);
732 push_json_string(&mut s, &w.function);
733 s.push_str(&format!(r#","argument":{}"#, w.argument));
734 s.push_str(r#","argKind":"#);
735 push_json_string(&mut s, &w.kind);
736 s.push_str(&format!(r#","count":{}"#, w.count));
737 s.push_str(r#","sample":"#);
738 match &w.sample {
739 Some(v) => push_json_string(&mut s, v),
740 None => s.push_str("null"),
741 }
742 s.push_str(r#","hint":"#);
743 push_json_string(&mut s, &w.hint);
744 s.push_str(r#","message":"#);
745 push_json_string(&mut s, &w.message);
746 s.push('}');
747 }
748 s.push(']');
749 s
750}