Skip to content

Commit 48d77cd

Browse files
author
Benjamin Geer
authored
fix!(gravsearch): Handle UNION scopes with FILTER correctly (DSP-1240) (#1790)
1 parent b1e1b9e commit 48d77cd

11 files changed

Lines changed: 623 additions & 53 deletions

File tree

‎docs/03-apis/api-v2/query-language.md‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ It is certainly possible to write Gravsearch queries by hand, but we expect
5151
that in general, they will be automatically generated by client
5252
software, e.g. by a client user interface.
5353

54+
For a more detailed overview of Gravsearch, see
55+
[Gravsearch: Transforming SPARQL to query humanities data](https://doi.org/10.3233/SW-200386).
56+
57+
5458
## Submitting Gravsearch Queries
5559

5660
The recommended way to submit a Gravsearch query is via HTTP POST:
@@ -1087,3 +1091,123 @@ CONSTRUCT {
10871091
FILTER(?pubdate = "JULIAN:1497-03-01"^^knora-api:Date) .
10881092
}
10891093
```
1094+
1095+
## Scoping Issues
1096+
1097+
SPARQL is evaluated [from the bottom up](https://sourceforge.net/p/bigdata/news/2015/09/understanding-sparqls-bottom-up-semantics/).
1098+
A `UNION` block therefore opens a new scope, in which variables bound at
1099+
higher levels are not necessarily in scope. This can cause unexpected results if queries
1100+
are not carefully designed. Gravsearch tries to prevent this by rejecting queries in the
1101+
following cases.
1102+
1103+
### FILTER in UNION
1104+
1105+
A `FILTER` in a `UNION` block can only use variables that are bound in the same block, otherwise the query will be rejected. This query is invalid because `?text` is not bound in the `UNION` block containing the `FILTER` where the variable is used:
1106+
1107+
```sparql
1108+
PREFIX knora-api: <http://api.knora.org/ontology/knora-api/simple/v2#>
1109+
PREFIX mls: <http://0.0.0.0:3333/ontology/0807/mls/simple/v2#>
1110+
1111+
CONSTRUCT {
1112+
?lemma knora-api:isMainResource true .
1113+
?lemma mls:hasLemmaText ?text .
1114+
} WHERE {
1115+
?lemma a mls:Lemma .
1116+
?lemma mls:hasLemmaText ?text .
1117+
1118+
{
1119+
?lemma mls:hasPseudonym ?pseudo .
1120+
FILTER regex(?pseudo, "Abel", "i") .
1121+
} UNION {
1122+
FILTER regex(?text, "Abel", "i") .
1123+
}
1124+
}
1125+
ORDER BY ASC(?text)
1126+
OFFSET 0
1127+
```
1128+
1129+
It can be corrected like this:
1130+
1131+
```sparql
1132+
PREFIX knora-api: <http://api.knora.org/ontology/knora-api/simple/v2#>
1133+
PREFIX mls: <http://0.0.0.0:3333/ontology/0807/mls/simple/v2#>
1134+
1135+
CONSTRUCT {
1136+
?lemma knora-api:isMainResource true .
1137+
?lemma mls:hasLemmaText ?text .
1138+
} WHERE {
1139+
?lemma a mls:Lemma .
1140+
?lemma mls:hasLemmaText ?text .
1141+
1142+
{
1143+
?lemma mls:hasPseudonym ?pseudo .
1144+
FILTER regex(?pseudo, "Abel", "i") .
1145+
} UNION {
1146+
?lemma mls:hasLemmaText ?text .
1147+
FILTER regex(?text, "Abel", "i") .
1148+
}
1149+
}
1150+
ORDER BY ASC(?text)
1151+
OFFSET 0
1152+
```
1153+
1154+
### ORDER BY
1155+
1156+
A variable used in `ORDER BY` must be bound at the top level of the `WHERE` clause. This query is invalid, because `?int` is not bound at the top level of the `WHERE` clause:
1157+
1158+
```sparql
1159+
PREFIX knora-api: <http://api.knora.org/ontology/knora-api/v2#>
1160+
PREFIX anything: <http://0.0.0.0:3333/ontology/0001/anything/v2#>
1161+
1162+
CONSTRUCT {
1163+
?thing knora-api:isMainResource true .
1164+
?thing anything:hasInteger ?int .
1165+
?thing anything:hasRichtext ?richtext .
1166+
?thing anything:hasText ?text .
1167+
} WHERE {
1168+
?thing a knora-api:Resource .
1169+
?thing a anything:Thing .
1170+
1171+
{
1172+
?thing anything:hasRichtext ?richtext .
1173+
FILTER knora-api:matchText(?richtext, "test")
1174+
?thing anything:hasInteger ?int .
1175+
}
1176+
UNION
1177+
{
1178+
?thing anything:hasText ?text .
1179+
FILTER knora-api:matchText(?text, "test")
1180+
?thing anything:hasInteger ?int .
1181+
}
1182+
}
1183+
ORDER BY (?int)
1184+
```
1185+
1186+
It can be corrected like this:
1187+
1188+
```sparql
1189+
PREFIX knora-api: <http://api.knora.org/ontology/knora-api/v2#>
1190+
PREFIX anything: <http://0.0.0.0:3333/ontology/0001/anything/v2#>
1191+
1192+
CONSTRUCT {
1193+
?thing knora-api:isMainResource true .
1194+
?thing anything:hasInteger ?int .
1195+
?thing anything:hasRichtext ?richtext .
1196+
?thing anything:hasText ?text .
1197+
} WHERE {
1198+
?thing a knora-api:Resource .
1199+
?thing a anything:Thing .
1200+
?thing anything:hasInteger ?int .
1201+
1202+
{
1203+
?thing anything:hasRichtext ?richtext .
1204+
FILTER knora-api:matchText(?richtext, "test")
1205+
}
1206+
UNION
1207+
{
1208+
?thing anything:hasText ?text .
1209+
FILTER knora-api:matchText(?text, "test")
1210+
}
1211+
}
1212+
ORDER BY (?int)
1213+
```

‎docs/05-internals/design/api-v2/gravsearch.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ License along with Knora. If not, see <http://www.gnu.org/licenses/>.
1919

2020
# Gravsearch Design
2121

22+
For a detailed overview of Gravsearch, see
23+
[Gravsearch: Transforming SPARQL to query humanities data](https://doi.org/10.3233/SW-200386).
24+
2225
## Gravsearch Package
2326

2427
The classes that process Gravsearch queries and results can be found in `org.knora.webapi.messages.util.search.gravsearch`.

‎webapi/src/main/scala/org/knora/webapi/messages/util/search/QueryTraverser.scala‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ trait WhereTransformer {
5959
*/
6060
def optimiseQueryPatterns(patterns: Seq[QueryPattern]): Seq[QueryPattern]
6161

62+
/**
63+
* Called before entering a UNION block.
64+
*/
65+
def enteringUnionBlock(): Unit
66+
67+
/**
68+
* Called before leaving a UNION block.
69+
*/
70+
def leavingUnionBlock(): Unit
71+
6272
/**
6373
* Transforms a [[StatementPattern]] in a WHERE clause into zero or more query patterns.
6474
*
@@ -240,10 +250,13 @@ object QueryTraverser {
240250
Seq(OptionalPattern(patterns = transformedPatterns))
241251

242252
case unionPattern: UnionPattern =>
243-
val transformedBlocks: Seq[Seq[QueryPattern]] = unionPattern.blocks.map { blockPatterns =>
244-
transformWherePatterns(patterns = blockPatterns,
245-
whereTransformer = whereTransformer,
246-
inputOrderBy = inputOrderBy)
253+
val transformedBlocks: Seq[Seq[QueryPattern]] = unionPattern.blocks.map { blockPatterns: Seq[QueryPattern] =>
254+
whereTransformer.enteringUnionBlock()
255+
val transformedPatterns: Seq[QueryPattern] = transformWherePatterns(patterns = blockPatterns,
256+
whereTransformer = whereTransformer,
257+
inputOrderBy = inputOrderBy)
258+
whereTransformer.leavingUnionBlock()
259+
transformedPatterns
247260
}
248261

249262
Seq(UnionPattern(blocks = transformedBlocks))

‎webapi/src/main/scala/org/knora/webapi/messages/util/search/SparqlQuery.scala‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ trait SelectQueryColumn extends Entity
6666
*/
6767
case class QueryVariable(variableName: String) extends SelectQueryColumn {
6868
override def toSparql: String = s"?$variableName"
69+
70+
override def getVariables: Set[QueryVariable] = Set(this)
6971
}
7072

7173
/**
@@ -83,6 +85,8 @@ case class GroupConcat(inputVariable: QueryVariable, separator: Char, outputVari
8385
override def toSparql: String = {
8486
s"""(GROUP_CONCAT(DISTINCT(IF(BOUND(${inputVariable.toSparql}), STR(${inputVariable.toSparql}), "")); SEPARATOR='$separator') AS ${outputVariable.toSparql})"""
8587
}
88+
89+
override def getVariables: Set[QueryVariable] = Set(inputVariable)
8690
}
8791

8892
/**
@@ -108,6 +112,7 @@ case class Count(inputVariable: QueryVariable, distinct: Boolean, outputVariable
108112
s"(COUNT($distinctAsStr ${inputVariable.toSparql}) AS ${outputVariable.toSparql})"
109113
}
110114

115+
override def getVariables: Set[QueryVariable] = Set(inputVariable, outputVariable)
111116
}
112117

113118
/**
@@ -135,6 +140,8 @@ case class IriRef(iri: SmartIri, propertyPathOperator: Option[Char] = None) exte
135140
def toOntologySchema(targetSchema: OntologySchema): IriRef = {
136141
copy(iri = iri.toOntologySchema(targetSchema))
137142
}
143+
144+
override def getVariables: Set[QueryVariable] = Set.empty
138145
}
139146

140147
/**
@@ -156,6 +163,8 @@ case class XsdLiteral(value: String, datatype: SmartIri) extends Entity {
156163

157164
"\"" + value + "\"^^" + transformedDatatype.toSparql
158165
}
166+
167+
override def getVariables: Set[QueryVariable] = Set.empty
159168
}
160169

161170
/**
@@ -198,7 +207,7 @@ case class StatementPattern(subj: Entity, pred: Entity, obj: Entity, namedGraph:
198207

199208
private def entityToOntologySchema(entity: Entity, targetSchema: OntologySchema): Entity = {
200209
entity match {
201-
case iriRef: IriRef => iriRef.toOntologySchema(InternalSchema)
210+
case iriRef: IriRef => iriRef.toOntologySchema(targetSchema)
202211
case other => other
203212
}
204213
}
@@ -314,7 +323,13 @@ object CompareExpressionOperator extends Enumeration {
314323
/**
315324
* Represents an expression that can be used in a FILTER.
316325
*/
317-
sealed trait Expression extends SparqlGenerator
326+
sealed trait Expression extends SparqlGenerator {
327+
328+
/**
329+
* Returns the set of query variables used in this expression.
330+
*/
331+
def getVariables: Set[QueryVariable]
332+
}
318333

319334
/**
320335
* Represents a comparison expression in a FILTER.
@@ -326,6 +341,8 @@ sealed trait Expression extends SparqlGenerator
326341
case class CompareExpression(leftArg: Expression, operator: CompareExpressionOperator.Value, rightArg: Expression)
327342
extends Expression {
328343
override def toSparql: String = s"(${leftArg.toSparql} $operator ${rightArg.toSparql})"
344+
345+
override def getVariables: Set[QueryVariable] = leftArg.getVariables ++ rightArg.getVariables
329346
}
330347

331348
/**
@@ -336,6 +353,8 @@ case class CompareExpression(leftArg: Expression, operator: CompareExpressionOpe
336353
*/
337354
case class AndExpression(leftArg: Expression, rightArg: Expression) extends Expression {
338355
override def toSparql: String = s"(${leftArg.toSparql} && ${rightArg.toSparql})"
356+
357+
override def getVariables: Set[QueryVariable] = leftArg.getVariables ++ rightArg.getVariables
339358
}
340359

341360
/**
@@ -346,6 +365,8 @@ case class AndExpression(leftArg: Expression, rightArg: Expression) extends Expr
346365
*/
347366
case class OrExpression(leftArg: Expression, rightArg: Expression) extends Expression {
348367
override def toSparql: String = s"(${leftArg.toSparql} || ${rightArg.toSparql})"
368+
369+
override def getVariables: Set[QueryVariable] = leftArg.getVariables ++ rightArg.getVariables
349370
}
350371

351372
/**
@@ -372,6 +393,8 @@ case object MinusOperator extends ArithmeticOperator {
372393
*/
373394
case class IntegerLiteral(value: Int) extends Expression {
374395
override def toSparql: String = value.toString
396+
397+
override def getVariables: Set[QueryVariable] = Set.empty
375398
}
376399

377400
/**
@@ -380,6 +403,8 @@ case class IntegerLiteral(value: Int) extends Expression {
380403
case class ArithmeticExpression(leftArg: Expression, operator: ArithmeticOperator, rightArg: Expression)
381404
extends Expression {
382405
override def toSparql: String = s"""${leftArg.toSparql} $operator ${rightArg.toSparql}"""
406+
407+
override def getVariables: Set[QueryVariable] = leftArg.getVariables ++ rightArg.getVariables
383408
}
384409

385410
/**
@@ -397,6 +422,8 @@ case class RegexFunction(textExpr: Expression, pattern: String, modifier: Option
397422
case None =>
398423
s"""regex(${textExpr.toSparql}, "$pattern")"""
399424
}
425+
426+
override def getVariables: Set[QueryVariable] = textExpr.getVariables
400427
}
401428

402429
/**
@@ -406,6 +433,8 @@ case class RegexFunction(textExpr: Expression, pattern: String, modifier: Option
406433
*/
407434
case class LangFunction(textValueVar: QueryVariable) extends Expression {
408435
override def toSparql: String = s"""lang(${textValueVar.toSparql})"""
436+
437+
override def getVariables: Set[QueryVariable] = Set(textValueVar)
409438
}
410439

411440
/**
@@ -419,6 +448,8 @@ case class SubStrFunction(textLiteralVar: QueryVariable, startExpression: Expres
419448
extends Expression {
420449
override def toSparql: String =
421450
s"""substr(${textLiteralVar.toSparql}, ${startExpression.toSparql}, ${lengthExpression.toSparql})"""
451+
452+
override def getVariables: Set[QueryVariable] = Set(textLiteralVar)
422453
}
423454

424455
/**
@@ -428,6 +459,8 @@ case class SubStrFunction(textLiteralVar: QueryVariable, startExpression: Expres
428459
*/
429460
case class StrFunction(textLiteralVar: QueryVariable) extends Expression {
430461
override def toSparql: String = s"""str(${textLiteralVar.toSparql})"""
462+
463+
override def getVariables: Set[QueryVariable] = Set(textLiteralVar)
431464
}
432465

433466
/**
@@ -482,6 +515,8 @@ case class FunctionCallExpression(functionIri: IriRef, args: Seq[Entity]) extend
482515
}
483516

484517
}
518+
519+
override def getVariables: Set[QueryVariable] = args.toSet.flatMap((arg: Entity) => arg.getVariables)
485520
}
486521

487522
/**

‎webapi/src/main/scala/org/knora/webapi/messages/util/search/SparqlTransformer.scala‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ object SparqlTransformer {
6363
Some(FromClause(IriRef(OntologyConstants.NamedGraphs.GraphDBExplicitNamedGraph.toSmartIri)))
6464
}
6565
}
66+
67+
override def enteringUnionBlock(): Unit = {}
68+
69+
override def leavingUnionBlock(): Unit = {}
6670
}
6771

6872
/**
@@ -91,6 +95,10 @@ object SparqlTransformer {
9195
transformLuceneQueryPatternForFuseki(luceneQueryPattern)
9296

9397
override def getFromClause: Option[FromClause] = None
98+
99+
override def enteringUnionBlock(): Unit = {}
100+
101+
override def leavingUnionBlock(): Unit = {}
94102
}
95103

96104
/**
@@ -115,6 +123,10 @@ object SparqlTransformer {
115123

116124
override def transformLuceneQueryPattern(luceneQueryPattern: LuceneQueryPattern): Seq[QueryPattern] =
117125
transformLuceneQueryPatternForGraphDB(luceneQueryPattern)
126+
127+
override def enteringUnionBlock(): Unit = {}
128+
129+
override def leavingUnionBlock(): Unit = {}
118130
}
119131

120132
/**
@@ -138,6 +150,10 @@ object SparqlTransformer {
138150

139151
override def transformLuceneQueryPattern(luceneQueryPattern: LuceneQueryPattern): Seq[QueryPattern] =
140152
transformLuceneQueryPatternForFuseki(luceneQueryPattern)
153+
154+
override def enteringUnionBlock(): Unit = {}
155+
156+
override def leavingUnionBlock(): Unit = {}
141157
}
142158

143159
/**

0 commit comments

Comments
 (0)