blob: cf6df351631b51a514392917b3a3fa2c9dc0207c [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.marmotta.kiwi.sparql.builder.eval;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import org.apache.commons.lang3.StringUtils;
import org.apache.marmotta.commons.util.DateUtils;
import org.apache.marmotta.kiwi.model.rdf.KiWiNode;
import org.apache.marmotta.kiwi.sparql.builder.SQLBuilder;
import org.apache.marmotta.kiwi.sparql.builder.ValueType;
import org.apache.marmotta.kiwi.sparql.builder.collect.OPTypeFinder;
import org.apache.marmotta.kiwi.sparql.builder.model.SQLVariable;
import org.apache.marmotta.kiwi.sparql.function.NativeFunction;
import org.apache.marmotta.kiwi.sparql.function.NativeFunctionRegistry;
import org.openrdf.model.BNode;
import org.openrdf.model.Literal;
import org.openrdf.model.URI;
import org.openrdf.model.vocabulary.FN;
import org.openrdf.model.vocabulary.XMLSchema;
import org.openrdf.query.algebra.*;
import org.openrdf.query.algebra.helpers.QueryModelVisitorBase;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Pattern;
/**
* Evaluate a SPARQL ValueExpr by translating it into a SQL expression.
*
* @author Sebastian Schaffert (sschaffert@apache.org)
*/
public class ValueExpressionEvaluator extends QueryModelVisitorBase<RuntimeException> {
private static Logger log = LoggerFactory.getLogger(ValueExpressionEvaluator.class);
/**
* Date format used for SQL timestamps.
*/
private static final DateFormat sqlDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.S");
/**
* Reference to the registry of natively supported functions with parameter and return types as well as SQL translation
*/
private static NativeFunctionRegistry functionRegistry = NativeFunctionRegistry.getInstance();
// used by BNodeGenerator
private static Random anonIdGenerator = new Random();
private StringBuilder builder = new StringBuilder();
private Deque<ValueType> optypes = new ArrayDeque<>();
private SQLBuilder parent;
public ValueExpressionEvaluator(ValueExpr expr, SQLBuilder parent) {
this(expr,parent, ValueType.NODE);
}
public ValueExpressionEvaluator(ValueExpr expr, SQLBuilder parent, ValueType optype) {
this.parent = parent;
optypes.push(optype);
if(log.isTraceEnabled()) {
long start = System.currentTimeMillis();
expr.visit(this);
log.trace("expression evaluated in {} ms", (System.currentTimeMillis()-start));
} else {
expr.visit(this);
}
}
/**
* Create the actual SQL string generated by this evaluator.
*
* @return
*/
public String build() {
return builder.toString();
}
@Override
public void meet(And node) throws RuntimeException {
builder.append("(");
node.getLeftArg().visit(this);
builder.append(" AND ");
node.getRightArg().visit(this);
builder.append(")");
}
@Override
public void meet(Or node) throws RuntimeException {
builder.append("(");
node.getLeftArg().visit(this);
builder.append(" OR ");
node.getRightArg().visit(this);
builder.append(")");
}
@Override
public void meet(Not node) throws RuntimeException {
builder.append("NOT (");
node.getArg().visit(this);
builder.append(")");
}
@Override
public void meet(Exists node) throws RuntimeException {
// TODO: need to make sure that variables of the parent are visible in the subquery
// - pattern names need to be unique even in subqueries
// - variable lookup for expressions in the subquery need to refer to the parent
SQLBuilder sq_builder = new SQLBuilder(node.getSubQuery(), parent.getBindings(), parent.getDataset(), parent.getConverter(), parent.getDialect(), "_", Collections.EMPTY_SET, copyVariables(parent.getVariables()));
builder.append("EXISTS (").append(sq_builder.build()).append(")");
}
@Override
public void meet(FunctionCall fc) throws RuntimeException {
// special optimizations for frequent cases with variables
if((XMLSchema.DOUBLE.toString().equals(fc.getURI()) || XMLSchema.FLOAT.toString().equals(fc.getURI()) ) && fc.getArgs().size() == 1) {
optypes.push(ValueType.DOUBLE);
fc.getArgs().get(0).visit(this);
optypes.pop();
} else if((XMLSchema.INTEGER.toString().equals(fc.getURI()) || XMLSchema.INT.toString().equals(fc.getURI())) && fc.getArgs().size() == 1) {
optypes.push(ValueType.INT);
fc.getArgs().get(0).visit(this);
optypes.pop();
} else if(XMLSchema.BOOLEAN.toString().equals(fc.getURI()) && fc.getArgs().size() == 1) {
optypes.push(ValueType.BOOL);
fc.getArgs().get(0).visit(this);
optypes.pop();
} else if(XMLSchema.DATE.toString().equals(fc.getURI()) && fc.getArgs().size() == 1) {
optypes.push(ValueType.DATE);
fc.getArgs().get(0).visit(this);
optypes.pop();
} else {
String fnUri = fc.getURI();
String[] args = new String[fc.getArgs().size()];
NativeFunction nf = functionRegistry.get(fnUri);
if (nf != null && nf.isSupported(parent.getDialect())) {
for (int i = 0; i < args.length; i++) {
args[i] = new ValueExpressionEvaluator(fc.getArgs().get(i), parent, nf.getArgumentType(i)).build();
}
if (optypes.peek() != nf.getReturnType()) {
builder.append(castExpression(nf.getNative(parent.getDialect(), args), optypes.peek()));
} else {
builder.append(nf.getNative(parent.getDialect(), args));
}
} else {
throw new IllegalArgumentException("the function " + fnUri + " is not supported by the SQL translation");
}
}
}
@Override
public void meet(Avg node) throws RuntimeException {
builder.append("AVG(");
optypes.push(ValueType.DOUBLE);
node.getArg().visit(this);
optypes.pop();
builder.append(")");
}
@Override
public void meet(BNodeGenerator gen) throws RuntimeException {
if(gen.getNodeIdExpr() != null) {
// get value of argument and express it as string
optypes.push(ValueType.STRING);
gen.getNodeIdExpr().visit(this);
optypes.pop();
} else {
builder.append("'").append(Long.toHexString(System.currentTimeMillis())).append(Integer.toHexString(anonIdGenerator.nextInt(1000))).append("'");
}
}
@Override
public void meet(Bound node) throws RuntimeException {
ValueExpr arg = node.getArg();
builder.append("(");
optypes.push(ValueType.NODE);
arg.visit(this);
optypes.pop();
builder.append(" IS NOT NULL)");
}
@Override
public void meet(Coalesce node) throws RuntimeException {
builder.append("COALESCE(");
for(Iterator<ValueExpr> it = node.getArguments().iterator(); it.hasNext(); ) {
it.next().visit(this);
if(it.hasNext()) {
builder.append(", ");
}
}
builder.append(")");
}
@Override
public void meet(Compare cmp) throws RuntimeException {
optypes.push(new OPTypeFinder(cmp).coerce());
cmp.getLeftArg().visit(this);
builder.append(getSQLOperator(cmp.getOperator()));
cmp.getRightArg().visit(this);
optypes.pop();
}
@Override
public void meet(Count node) throws RuntimeException {
builder.append("COUNT(");
if(node.isDistinct()) {
builder.append("DISTINCT ");
}
if(node.getArg() == null) {
// this is a weird special case where we need to expand to all variables selected in the query wrapped
// by the group; we cannot simply use "*" because the concept of variables is a different one in SQL,
// so instead we construct an ARRAY of the bindings of all variables
List<String> countVariables = new ArrayList<>();
for(SQLVariable v : parent.getVariables().values()) {
if(v.getProjectionType() == ValueType.NONE) {
Preconditions.checkState(v.getExpressions().size() > 0, "no expressions available for variable");
countVariables.add(v.getExpressions().get(0));
}
}
builder.append("ARRAY[");
Joiner.on(',').appendTo(builder, countVariables);
builder.append("]");
} else {
optypes.push(ValueType.NODE);
node.getArg().visit(this);
optypes.pop();
}
builder.append(")");
}
@Override
public void meet(GroupConcat node) throws RuntimeException {
if(node.getSeparator() == null) {
builder.append(parent.getDialect().getGroupConcat(new ValueExpressionEvaluator(node.getArg(), parent, ValueType.STRING).build(), null, node.isDistinct()));
} else {
builder.append(parent.getDialect().getGroupConcat(
new ValueExpressionEvaluator(node.getArg(), parent, ValueType.STRING).build(),
new ValueExpressionEvaluator(node.getSeparator(), parent, ValueType.STRING).build(),
node.isDistinct()
));
}
}
@Override
public void meet(If node) throws RuntimeException {
builder.append("CASE WHEN ");
optypes.push(ValueType.BOOL);
node.getCondition().visit(this);
optypes.pop();
optypes.push(new OPTypeFinder(node).coerce());
builder.append(" THEN ");
node.getResult().visit(this);
builder.append(" ELSE ");
node.getAlternative().visit(this);
builder.append(" END");
optypes.pop();
}
@Override
public void meet(IsBNode node) throws RuntimeException {
ValueExpr arg = node.getArg();
// operator must be a variable or a constant
if(arg instanceof ValueConstant) {
builder.append(Boolean.toString(((ValueConstant) arg).getValue() instanceof BNode));
} else if(arg instanceof Var) {
String var = getVariableAlias((Var) arg);
builder.append(var).append(".ntype = 'bnode'");
}
}
@Override
public void meet(IsLiteral node) throws RuntimeException {
ValueExpr arg = node.getArg();
// operator must be a variable or a constant
if (arg instanceof ValueConstant) {
builder.append(Boolean.toString(((ValueConstant) arg).getValue() instanceof Literal));
} else if(arg instanceof Var) {
String var = getVariableAlias((Var) arg);
Preconditions.checkState(var != null, "no alias available for variable");
builder.append("(")
.append(var)
.append(".ntype = 'string' OR ")
.append(var)
.append(".ntype = 'int' OR ")
.append(var)
.append(".ntype = 'double' OR ")
.append(var)
.append(".ntype = 'date' OR ")
.append(var)
.append(".ntype = 'boolean')");
}
}
@Override
public void meet(IsNumeric node) throws RuntimeException {
ValueExpr arg = node.getArg();
// operator must be a variable or a constant
if (arg instanceof ValueConstant) {
try {
Double.parseDouble(((ValueConstant) arg).getValue().stringValue());
builder.append(Boolean.toString(true));
} catch (NumberFormatException ex) {
builder.append(Boolean.toString(false));
}
} else if(arg instanceof Var) {
String var = getVariableAlias((Var) arg);
Preconditions.checkState(var != null, "no alias available for variable");
builder.append("(")
.append(var)
.append(".ntype = 'int' OR ")
.append(var)
.append(".ntype = 'double')");
}
}
@Override
public void meet(IsResource node) throws RuntimeException {
ValueExpr arg = node.getArg();
// operator must be a variable or a constant
if(arg instanceof ValueConstant) {
builder.append(Boolean.toString(((ValueConstant) arg).getValue() instanceof URI || ((ValueConstant) arg).getValue() instanceof BNode));
} else if(arg instanceof Var) {
String var = getVariableAlias((Var) arg);
Preconditions.checkState(var != null, "no alias available for variable");
builder .append("(")
.append(var)
.append(".ntype = 'uri' OR ")
.append(var)
.append(".ntype = 'bnode')");
}
}
@Override
public void meet(IsURI node) throws RuntimeException {
ValueExpr arg = node.getArg();
// operator must be a variable or a constant
if(arg instanceof ValueConstant) {
builder.append(Boolean.toString(((ValueConstant) arg).getValue() instanceof URI));
} else if(arg instanceof Var) {
String var = getVariableAlias((Var) arg);
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".ntype = 'uri'");
}
}
@Override
public void meet(IRIFunction fun) throws RuntimeException {
if(fun.getBaseURI() != null) {
String ex = new ValueExpressionEvaluator(fun.getArg(), parent, ValueType.STRING).build();
builder
.append("CASE WHEN position(':' IN ").append(ex).append(") > 0 THEN ").append(ex)
.append(" ELSE ").append(functionRegistry.get(FN.CONCAT.stringValue()).getNative(parent.getDialect(), "'" + fun.getBaseURI() + "'", ex))
.append(" END ");
} else {
// get value of argument and express it as string
optypes.push(ValueType.STRING);
fun.getArg().visit(this);
optypes.pop();
}
}
@Override
public void meet(Label node) throws RuntimeException {
optypes.push(ValueType.STRING);
node.getArg().visit(this);
optypes.pop();
}
@Override
public void meet(Lang lang) throws RuntimeException {
if(lang.getArg() instanceof Var) {
String var = getVariableAlias((Var) lang.getArg());
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var);
builder.append(".lang");
}
}
@Override
public void meet(LangMatches lm) throws RuntimeException {
ValueConstant pattern = (ValueConstant) lm.getRightArg();
if(pattern.getValue().stringValue().equals("*")) {
lm.getLeftArg().visit(this);
builder.append(" LIKE '%'");
} else if(pattern.getValue().stringValue().equals("")) {
lm.getLeftArg().visit(this);
builder.append(" IS NULL");
} else {
builder.append("(");
lm.getLeftArg().visit(this);
builder.append(" = '");
builder.append(pattern.getValue().stringValue().toLowerCase());
builder.append("' OR ");
lm.getLeftArg().visit(this);
builder.append(" LIKE '");
builder.append(pattern.getValue().stringValue().toLowerCase());
builder.append("-%' )");
}
}
@Override
public void meet(Like node) throws RuntimeException {
if(node.isCaseSensitive()) {
optypes.push(ValueType.STRING);
node.getArg().visit(this);
optypes.pop();
builder.append(" LIKE ");
node.getPattern();
} else {
builder.append(parent.getDialect().getILike(new ValueExpressionEvaluator(node.getArg(),parent, ValueType.STRING).build(), node.getOpPattern()));
}
}
@Override
public void meet(LocalName node) throws RuntimeException {
super.meet(node);
}
@Override
public void meet(MathExpr expr) throws RuntimeException {
ValueType ot = new OPTypeFinder(expr).coerce();
if(ot == ValueType.STRING) {
if(expr.getOperator() == MathExpr.MathOp.PLUS) {
builder.append(functionRegistry.get(FN.CONCAT.stringValue()).getNative(parent.getDialect(),new ValueExpressionEvaluator(expr.getLeftArg(), parent, ot).build(), new ValueExpressionEvaluator(expr.getRightArg(), parent, ot).build()));
} else {
throw new IllegalArgumentException("operation "+expr.getOperator()+" is not supported on strings");
}
} else {
if(ot == ValueType.NODE || ot == ValueType.TERM) {
ot = ValueType.DOUBLE;
}
optypes.push(ot);
builder.append("(");
expr.getLeftArg().visit(this);
builder.append(")");
builder.append(getSQLOperator(expr.getOperator()));
builder.append("(");
expr.getRightArg().visit(this);
builder.append(")");
optypes.pop();
}
}
@Override
public void meet(Max node) throws RuntimeException {
builder.append("MAX(");
optypes.push(ValueType.DOUBLE);
node.getArg().visit(this);
optypes.pop();
builder.append(")");
}
@Override
public void meet(Min node) throws RuntimeException {
builder.append("MIN(");
optypes.push(ValueType.DOUBLE);
node.getArg().visit(this);
optypes.pop();
builder.append(")");
}
@Override
public void meet(Regex re) throws RuntimeException {
builder.append(optimizeRegexp(
new ValueExpressionEvaluator(re.getArg(), parent, ValueType.STRING).build(),
new ValueExpressionEvaluator(re.getPatternArg(), parent, ValueType.STRING).build(),
re.getFlagsArg()
));
}
@Override
public void meet(SameTerm cmp) throws RuntimeException {
// covered by value binding in variables
optypes.push(ValueType.TERM);
cmp.getLeftArg().visit(this);
builder.append(" = ");
cmp.getRightArg().visit(this);
optypes.pop();
}
@Override
public void meet(Str node) throws RuntimeException {
optypes.push(ValueType.STRING);
node.getArg().visit(this);
optypes.pop();
}
@Override
public void meet(Sum node) throws RuntimeException {
builder.append("SUM(");
optypes.push(ValueType.DOUBLE);
node.getArg().visit(this);
optypes.pop();
builder.append(")");
}
@Override
public void meet(Var node) throws RuntimeException {
// distinguish between the case where the variable is plain and the variable is bound
SQLVariable sv = parent.getVariables().get(node.getName());
if(sv == null) {
builder.append("NULL");
} else if(sv.getBindings().size() > 0) {
// in case the variable is actually an alias for an expression, we evaluate that expression instead, effectively replacing the
// variable occurrence with its value
sv.getBindings().get(0).visit(this);
} else {
String var = sv.getAlias();
if(sv.getProjectionType() != ValueType.NODE && sv.getProjectionType() != ValueType.NONE) {
// in case the variable represents a constructed or bound value instead of a node, we need to
// use the SQL expression as value; SQL should take care of proper casting...
// TODO: explicit casting needed?
builder.append(sv.getExpressions().get(0));
} else {
// in case the variable represents an entry from the NODES table (i.e. has been bound to a node
// in the database, we take the NODES alias and resolve to the correct column according to the
// operator type
switch (optypes.peek()) {
case STRING:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".svalue");
break;
case INT:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".ivalue");
break;
case DECIMAL:
case DOUBLE:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".dvalue");
break;
case BOOL:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".bvalue");
break;
case DATE:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".tvalue");
break;
case TZDATE:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(parent.getDialect().getDateTimeTZ(var));
break;
case URI:
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".svalue");
break;
case TERM:
case NODE:
if(sv.getExpressions().size() > 0) {
// this allows us to avoid joins with the nodes table for simple expressions that only need the ID
builder.append(sv.getExpressions().get(0));
} else {
Preconditions.checkState(var != null, "no alias available for variable");
builder.append(var).append(".id");
}
break;
}
}
}
}
@Override
public void meet(ValueConstant node) throws RuntimeException {
String val = node.getValue().stringValue();
switch (optypes.peek()) {
case STRING:
case URI:
builder.append("'").append(val).append("'");
break;
case INT:
builder.append(Integer.parseInt(val));
break;
case DECIMAL:
case DOUBLE:
builder.append(Double.parseDouble(val));
break;
case BOOL:
builder.append(Boolean.parseBoolean(val));
break;
case DATE:
builder.append("'").append(sqlDateFormat.format(DateUtils.parseDate(val))).append("'");
break;
// in this case we should return a node ID and also need to make sure it actually exists
case TERM:
case NODE:
KiWiNode n = parent.getConverter().convert(node.getValue());
builder.append(n.getId());
break;
default: throw new IllegalArgumentException("unsupported value type: " + optypes.peek());
}
}
private String getVariableAlias(Var var) {
return parent.getVariables().get(var.getName()).getAlias();
}
private String getVariableAlias(String varName) {
return parent.getVariables().get(varName).getAlias();
}
/**
* Copy variables from the set to a new set suitable for a subquery; this allows passing over variable expressions
* from parent queries to subqueries without the subquery adding expressions that are then not visible outside
* @param variables
* @return
*/
private static Map<String, SQLVariable> copyVariables(Map<String, SQLVariable> variables) {
Map<String,SQLVariable> copy = new HashMap<>();
try {
for(Map.Entry<String,SQLVariable> entry : variables.entrySet()) {
copy.put(entry.getKey(), (SQLVariable) entry.getValue().clone());
}
} catch (CloneNotSupportedException e) {
log.error("could not clone SQL variable:",e);
}
return copy;
}
private String castExpression(String arg, ValueType type) {
if(type == null) {
return arg;
}
switch (type) {
case DECIMAL:
return functionRegistry.get(XMLSchema.DECIMAL).getNative(parent.getDialect(), arg);
case DOUBLE:
return functionRegistry.get(XMLSchema.DOUBLE).getNative(parent.getDialect(), arg);
case INT:
return functionRegistry.get(XMLSchema.INTEGER).getNative(parent.getDialect(), arg);
case BOOL:
return functionRegistry.get(XMLSchema.BOOLEAN).getNative(parent.getDialect(), arg);
case DATE:
return functionRegistry.get(XMLSchema.DATETIME).getNative(parent.getDialect(), arg);
case STRING:
return arg;
case NODE:
return arg;
default:
return arg;
}
}
private static String getSQLOperator(Compare.CompareOp op) {
switch (op) {
case EQ: return " = ";
case GE: return " >= ";
case GT: return " > ";
case LE: return " <= ";
case LT: return " < ";
case NE: return " <> ";
}
throw new IllegalArgumentException("unsupported operator type for comparison: "+op);
}
private static String getSQLOperator(MathExpr.MathOp op) {
switch (op) {
case PLUS: return " + ";
case MINUS: return " - ";
case DIVIDE: return " / ";
case MULTIPLY: return " * ";
}
throw new IllegalArgumentException("unsupported operator type for math expression: "+op);
}
/**
* Test if the regular expression given in the pattern can be simplified to a LIKE SQL statement; these are
* considerably more efficient to evaluate in most databases, so in case we can simplify, we return a LIKE.
*
* @param value
* @param pattern
* @return
*/
private String optimizeRegexp(String value, String pattern, ValueExpr flags) {
String _flags = flags != null && flags instanceof ValueConstant ? ((ValueConstant)flags).getValue().stringValue() : null;
String simplified = pattern;
// apply simplifications
// remove SQL quotes at beginning and end
simplified = simplified.replaceFirst("^'","");
simplified = simplified.replaceFirst("'$","");
// remove .* at beginning and end, they are the default anyways
simplified = simplified.replaceFirst("^\\.\\*","");
simplified = simplified.replaceFirst("\\.\\*$","");
// replace all occurrences of % with \% and _ with \_, as they are special characters in SQL
simplified = simplified.replaceAll("%","\\%");
simplified = simplified.replaceAll("_","\\_");
// if pattern now does not start with a ^, we put a "%" in front
if(!simplified.startsWith("^")) {
simplified = "%" + simplified;
} else {
simplified = simplified.substring(1);
}
// if pattern does not end with a "$", we put a "%" at the end
if(!simplified.endsWith("$")) {
simplified = simplified + "%";
} else {
simplified = simplified.substring(0,simplified.length()-1);
}
// replace all non-escaped occurrences of .* with %
simplified = simplified.replaceAll("(?<!\\\\)\\.\\*","%");
// replace all non-escaped occurrences of .+ with _%
simplified = simplified.replaceAll("(?<!\\\\)\\.\\+","_%");
// the pattern is not simplifiable if the simplification still contains unescaped regular expression constructs
Pattern notSimplifiable = Pattern.compile("(?<!\\\\)[\\.\\*\\+\\{\\}\\[\\]\\|]");
if(notSimplifiable.matcher(simplified).find()) {
return parent.getDialect().getRegexp(value, pattern, _flags);
} else {
if(!simplified.startsWith("%") && !simplified.endsWith("%")) {
if(StringUtils.containsIgnoreCase(_flags, "i")) {
return String.format("lower(%s) = lower('%s')", value, simplified);
} else {
return String.format("%s = '%s'", value, simplified);
}
} else {
if(StringUtils.containsIgnoreCase(_flags,"i")) {
return parent.getDialect().getILike(value, "'" + simplified + "'");
} else {
return value + " LIKE '"+simplified+"'";
}
}
}
}
}