SQLParameterParser.java
package org.codefilarete.stalactite.sql.statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.codefilarete.tool.StringAppender;
import org.codefilarete.tool.collection.Arrays;
/**
* Parser for SQL String with named parameters.
* Named parameters must begin with a colon (:) followed by any word Character (upper or lower alphabetical letter, or
* a digit, or an underscore) [a-Z0-9_].
* <p>
* Implementation is quite naive.
*
* @author Guillaume Mary
*/
public class SQLParameterParser {
/** Pattern to detect 'in' keywords and created CollectionParameter instead of Parameter */
public static final Pattern IN_PATTERN = Pattern.compile("\\s+in\\s*\\(\\s*");
/** Allowed characters for a parameter, quite arbitrary */
private static final Set<Character> PARAMETER_NAME_ALLOWED_CHARACTERS = Collections.unmodifiableSet(Arrays.asHashSet(
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'_', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
));
/** Marker of a delimited text */
private static final char SIMPLE_QUOTE = '\'';
/** Marker of a delimited text */
private static final char DOUBLE_QUOTE = '\"';
/** Marker of a parameter */
private static final char SEMI_COLON = ':';
private static final Set<Character> SPECIAL_SYMBOLS = Collections.unmodifiableSet(Arrays.asHashSet(SEMI_COLON, SIMPLE_QUOTE, DOUBLE_QUOTE));
private final ParsedSQL parsedSQL;
private final int sqlLength;
private final String sql;
private int currentPos;
private char currentChar;
/** Current sql snippet: either a sql portion or a parameter. Is "closed" (reset) on next sql snippet */
private StringBuilder sqlSnippet;
public SQLParameterParser(String sql) {
this.sql = sql;
this.sqlLength = sql.length();
this.currentPos = -1;
this.parsedSQL = new ParsedSQL();
}
public String getSql() {
return sql;
}
/**
* Main entry point: will parse SQL given at constructor
*
* @return the resulting parse (not null)
*/
public ParsedSQL parse() {
this.sqlSnippet = new StringBuilder(50);
while (currentPos < sqlLength) {
doUntil(SPECIAL_SYMBOLS, new ParsingListener() {
@Override
public void onRead() {
sqlSnippet.append(currentChar);
}
@Override
public void onConsumptionEnd() {
// sql snippet end detected => good for "parsed sql"
parsedSQL.addSqlSnippet(sqlSnippet.toString());
// according to detected end, we'll pursue with adhoc consumption
switch (currentChar) {
case SIMPLE_QUOTE:
case DOUBLE_QUOTE:
appendUntilCharacter(currentChar);
break;
case SEMI_COLON:
readParam();
break;
default:
// suspicious case: symbol consumption is not implemented (developer forgetting)
throw new RuntimeException("Symbol '" + currentChar + "' was set as blocker but its consumption is not implemented");
}
// closing sql snippet so next consumers can append chars to it without collision
sqlSnippet.setLength(0);
}
});
// onConsumptionEnd() is not called on String end: we consume remaining chars
if (currentPos == sqlLength && sqlSnippet.length() != 0) {
parsedSQL.addSqlSnippet(sqlSnippet.toString());
}
}
return parsedSQL;
}
/**
* Consumes a named parameter
*/
private void readParam() {
StringBuilder paramName = new StringBuilder();
doWhile(PARAMETER_NAME_ALLOWED_CHARACTERS, new ParsingListener() {
@Override
public void onRead() {
paramName.append(currentChar);
}
@Override
public void onConsumptionEnd() {
// 'in' detection to have a dedicated Collection parameter (expandable '?')
Matcher inMatcher = IN_PATTERN.matcher(sqlSnippet.toString());
if (inMatcher.find()) {
parsedSQL.addCollectionParam(paramName.toString());
} else {
parsedSQL.addParam(paramName.toString());
}
// we read an extra Character, so we give it back for caller
unread();
}
});
// onConsumptionEnd() is not called on String end: we must end what we began
if (currentPos == sqlLength) {
if (paramName.length() == 0) {
throw new IllegalArgumentException("Parameter name can't be empty at position " + currentPos);
} else {
parsedSQL.addParam(paramName.toString());
}
}
}
/**
* Used to consume quotes
*/
private void appendUntilCharacter(char stoppingChar) {
StringBuilder quotes = new StringBuilder();
char prefix = currentChar;
doUntil(stoppingChar, new ParsingListener() {
@Override
public void onRead() {
quotes.append(currentChar);
}
@Override
public void onConsumptionEnd() {
// nothing to do
}
});
parsedSQL.addSqlSnippet(prefix + quotes.toString() + prefix);
}
/**
* Goes forward until read character is in given characters set (stoppers). Calls {@link ParsingListener} methods during consumption.
*
* @param stoppingChars characters that stops consumption
* @param parsingListener listener called during characters consumption
*/
private void doUntil(Set<Character> stoppingChars, ParsingListener parsingListener) {
boolean stoppedByTerminator = false;
char previousChar = 0;
while (read()) {
if (stoppingChars.contains(currentChar)
// we ignore escaped stopping characters because stopping chars are quotes and they may contain internal quotes escaped by '\'
&& previousChar != '\\') {
stoppedByTerminator = true;
break;
}
parsingListener.onRead();
previousChar = currentChar;
}
if (stoppedByTerminator) {
parsingListener.onConsumptionEnd();
}
}
private void doUntil(char stoppingChars, ParsingListener parsingListener) {
doUntil(Arrays.asHashSet(stoppingChars), parsingListener);
}
/**
* Goes forward while read character is in given characters set (continuers). Calls {@link ParsingListener} methods during consumption.
*
* @param continuingChars characters that must be consumed
* @param parsingListener listener called during characters consumption
*/
private void doWhile(Set<Character> continuingChars, ParsingListener parsingListener) {
boolean charFound = true;
while (charFound && read()) {
charFound = continuingChars.contains(currentChar);
if (charFound) {
parsingListener.onRead();
}
}
if (!charFound) {
parsingListener.onConsumptionEnd();
}
}
/**
* Consume next Character from sql. Put it in currentChar field.
*
* @return true if a Character was read, false if end of sql is reached
*/
private boolean read() {
if (++currentPos < sqlLength) {
currentChar = sql.charAt(currentPos);
return true;
} else {
return false;
}
}
/**
* Read previous Character from sql. Put it in currentChar field.
*/
private void unread() {
if (--currentPos >= 0) {
currentChar = sql.charAt(currentPos);
}
}
/**
* Allows actions to be done during parsing phases.
*
* @see #doUntil(Set, ParsingListener)
* @see #doWhile(Set, ParsingListener)
*/
private interface ParsingListener {
void onRead();
void onConsumptionEnd();
}
/**
* Returned object by the {@link SQLParameterParser#parse()} method. Represents a list of SQL code and named
* parameters.
*/
public static class ParsedSQL {
/** SQL elements: sql code (String) and named parameters (Parameter) */
private List<Object /* String or Parameter */> sqlSnippets = new ArrayList<>(10);
/** Parameters mapped on their names */
private Map<String, Parameter> parametersMap = new HashMap<>();
public ParsedSQL() {
}
public ParsedSQL(List<Object /* sql or Parameter */> sqlSnippets, Map<String, Parameter> parametersMap) {
this.sqlSnippets = sqlSnippets;
this.parametersMap = parametersMap;
}
/**
* Gives found SQL elements: mix of Strings and Parameters. Expected to be in found order.
*
* @return a mix of Strings and Parameters, in order of finding
*/
public List<Object> getSqlSnippets() {
return sqlSnippets;
}
public Map<String, Parameter> getParametersMap() {
return parametersMap;
}
public void addParam(String paramName) {
Parameter parameter = this.parametersMap.computeIfAbsent(paramName, Parameter::new);
// we must put the same instance into the Map as into the List for the expansion algorithm
this.sqlSnippets.add(parameter);
}
public void addCollectionParam(String paramName) {
Parameter parameter = this.parametersMap.computeIfAbsent(paramName, Parameter::new);
if (!(parameter instanceof CollectionParameter)) {
parameter = new CollectionParameter(paramName);
this.parametersMap.put(paramName, parameter);
}
// we must put the same instance into the Map as into the List the for expansion algorithm
this.sqlSnippets.add(parameter);
}
public void addSqlSnippet(CharSequence sqlSnippet) {
this.sqlSnippets.add(sqlSnippet);
}
/**
* Implementation for simple tracing
* @return ":" + parameter name
*/
@Override
public String toString() {
return new StringAppender() {
@Override
public StringAppender cat(Object s) {
if (s instanceof Parameter) {
// NB: don't merge next statements to super.cat(...) because it will call us back
super.cat(":");
super.cat(((Parameter) s).getName());
} else {
super.cat(s);
}
return this;
}
}.cat(getSqlSnippets()).toString();
}
}
/**
* Named parameter representation
*/
public static class Parameter {
private final String name;
public Parameter(String name) {
this.name = name;
}
public String getName() {
return name;
}
/** Implemented for unit tests, unnecessary for production use since never put into Map nor Set */
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Parameter parameter = (Parameter) o;
return Objects.equals(name, parameter.name);
}
/** Implemented for unit tests, unnecessary for production use since never put into Map nor Set */
@Override
public int hashCode() {
return this.name.hashCode();
}
}
/**
* Specialized Parameter to mark one for Collection (preceded by 'in' keyword)
*/
public static class CollectionParameter extends Parameter {
public CollectionParameter(String name) {
super(name);
}
}
}