Skip to content

Commit 4077e33

Browse files
committed
feat: implement SqlQueryFilterService for dynamic SQL query filtering
1 parent 2b4ba75 commit 4077e33

File tree

2 files changed

+381
-1
lines changed

2 files changed

+381
-1
lines changed
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
package com.park.utmstack.service.elasticsearch.sql;
2+
3+
import com.park.utmstack.domain.chart_builder.types.query.FilterType;
4+
import com.park.utmstack.domain.chart_builder.types.query.OperatorType;
5+
import org.springframework.stereotype.Service;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.stream.Collectors;
10+
11+
@Service
12+
public class SqlQueryFilterService {
13+
14+
/**
15+
* Applies the given filters to the base SQL query by generating a dynamic WHERE clause.
16+
* - Handles all FilterType operators.
17+
* - Treats @timestamp specially (relative and absolute ranges).
18+
* - Merges the generated WHERE with an existing WHERE if present.
19+
*/
20+
public String applyFilters(String baseSql, List<FilterType> filters) {
21+
if (filters == null || filters.isEmpty()) {
22+
return baseSql;
23+
}
24+
25+
List<String> andConditions = new ArrayList<>();
26+
List<String> orConditions = new ArrayList<>();
27+
28+
for (FilterType filter : filters) {
29+
30+
// Special handling for @timestamp: relative/absolute time logic
31+
if ("@timestamp".equals(filter.getField())) {
32+
andConditions.add(buildTimestampCondition(filter));
33+
continue;
34+
}
35+
36+
String sqlCondition = toSqlCondition(filter);
37+
38+
// IS_ONE_OF_TERMS_OR is explicitly an OR-group operator
39+
if (filter.getOperator() == OperatorType.IS_ONE_OF_TERMS_OR) {
40+
orConditions.add(sqlCondition);
41+
} else {
42+
andConditions.add(sqlCondition);
43+
}
44+
}
45+
46+
String whereClause = combineConditions(andConditions, orConditions);
47+
return mergeSql(baseSql, whereClause);
48+
}
49+
50+
// -------------------------------------------------------------------------
51+
// TIMESTAMP HANDLING
52+
// -------------------------------------------------------------------------
53+
54+
/**
55+
* Builds the SQL condition for @timestamp.
56+
* The value may be:
57+
* - A List<?> of two elements (for IS_BETWEEN)
58+
* - A single String (for > or <=)
59+
*/
60+
private String buildTimestampCondition(FilterType f) {
61+
62+
Object rawValue = f.getValue();
63+
64+
switch (f.getOperator()) {
65+
66+
case IS_BETWEEN:
67+
if (!(rawValue instanceof List<?> list) || list.size() != 2) {
68+
throw new IllegalArgumentException("@timestamp IS_BETWEEN requires a list of two values");
69+
}
70+
71+
String from = String.valueOf(list.get(0));
72+
String to = String.valueOf(list.get(1));
73+
74+
return "@timestamp BETWEEN " + toSqlTime(from) + " AND " + toSqlTime(to);
75+
76+
case IS_GREATER_THAN:
77+
if (!(rawValue instanceof String singleGt)) {
78+
throw new IllegalArgumentException("@timestamp IS_GREATER_THAN requires a single value");
79+
}
80+
81+
return "@timestamp > " + toSqlTime(singleGt);
82+
83+
case IS_LESS_THAN_OR_EQUALS:
84+
if (!(rawValue instanceof String singleLe)) {
85+
throw new IllegalArgumentException("@timestamp IS_LESS_THAN_OR_EQUALS requires a single value");
86+
}
87+
88+
return "@timestamp <= " + toSqlTime(singleLe);
89+
90+
91+
default:
92+
throw new IllegalArgumentException("Unsupported timestamp operator: " + f.getOperator());
93+
}
94+
}
95+
96+
97+
/**
98+
* Converts a logical time value into a SQL expression:
99+
* - "now" -> NOW()
100+
* - "now-24h" -> DATE_SUB(NOW(), INTERVAL 24 HOUR)
101+
* - "now-15m" -> DATE_SUB(NOW(), INTERVAL 15 MINUTE)
102+
* - "now-7d" -> DATE_SUB(NOW(), INTERVAL 7 DAY)
103+
* - any other -> quoted literal (absolute timestamp)
104+
*/
105+
private String toSqlTime(String value) {
106+
if ("now".equals(value)) {
107+
return "NOW()";
108+
}
109+
110+
if (value.startsWith("now-")) {
111+
// Example: now-24h, now-15m, now-7d
112+
String number = value.substring(4, value.length() - 1);
113+
char unit = value.toLowerCase().charAt(value.length() - 1);
114+
115+
String sqlUnit = switch (unit) {
116+
case 'm' -> "MINUTE";
117+
case 'h' -> "HOUR";
118+
case 'd' -> "DAY";
119+
default -> throw new IllegalArgumentException("Invalid time unit in value: " + value);
120+
};
121+
122+
return "DATE_SUB(NOW(), INTERVAL " + number + " " + sqlUnit + ")";
123+
}
124+
125+
// Absolute timestamp value
126+
return "'" + value + "'";
127+
}
128+
129+
// -------------------------------------------------------------------------
130+
// GENERAL OPERATORS
131+
// -------------------------------------------------------------------------
132+
133+
/**
134+
* Maps a FilterType to a SQL condition string.
135+
* All non-@timestamp operators are handled here.
136+
*/
137+
private String toSqlCondition(FilterType f) {
138+
139+
String field = f.getField();
140+
Object rawValue = f.getValue();
141+
List<String> list = asList(rawValue); // safe conversion
142+
143+
return switch (f.getOperator()) {
144+
145+
// ---------------------------------------------------------------------
146+
// Equality
147+
// ---------------------------------------------------------------------
148+
case IS -> field + " = '" + list.get(0) + "'";
149+
150+
case IS_NOT -> field + " <> '" + list.get(0) + "'";
151+
152+
// ---------------------------------------------------------------------
153+
// Text contains
154+
// ---------------------------------------------------------------------
155+
case CONTAIN -> field + " LIKE '%" + list.get(0) + "%'";
156+
157+
case DOES_NOT_CONTAIN -> field + " NOT LIKE '%" + list.get(0) + "%'";
158+
159+
case CONTAIN_ONE_OF ->
160+
"(" + list.stream()
161+
.map(v -> field + " LIKE '%" + v + "%'")
162+
.collect(Collectors.joining(" OR ")) + ")";
163+
164+
case DOES_NOT_CONTAIN_ONE_OF ->
165+
"(" + list.stream()
166+
.map(v -> field + " NOT LIKE '%" + v + "%'")
167+
.collect(Collectors.joining(" AND ")) + ")";
168+
169+
// ---------------------------------------------------------------------
170+
// List membership
171+
// ---------------------------------------------------------------------
172+
case IS_ONE_OF ->
173+
field + " IN (" + joinQuoted(list) + ")";
174+
175+
case IS_NOT_ONE_OF ->
176+
field + " NOT IN (" + joinQuoted(list) + ")";
177+
178+
case IS_ONE_OF_TERMS ->
179+
field + " IN (" + joinQuoted(list) + ")";
180+
181+
case IS_ONE_OF_TERMS_OR ->
182+
"(" + list.stream()
183+
.map(v -> field + " = '" + v + "'")
184+
.collect(Collectors.joining(" OR ")) + ")";
185+
186+
// ---------------------------------------------------------------------
187+
// Existence
188+
// ---------------------------------------------------------------------
189+
case EXIST -> field + " IS NOT NULL";
190+
191+
case DOES_NOT_EXIST -> field + " IS NULL";
192+
193+
// ---------------------------------------------------------------------
194+
// Ranges (non-timestamp fields)
195+
// ---------------------------------------------------------------------
196+
case IS_BETWEEN -> {
197+
if (list.size() != 2) {
198+
throw new IllegalArgumentException("IS_BETWEEN requires exactly 2 values");
199+
}
200+
yield field + " BETWEEN '" + list.get(0) + "' AND '" + list.get(1) + "'";
201+
}
202+
203+
case IS_NOT_BETWEEN -> {
204+
if (list.size() != 2) {
205+
throw new IllegalArgumentException("IS_NOT_BETWEEN requires exactly 2 values");
206+
}
207+
yield field + " NOT BETWEEN '" + list.get(0) + "' AND '" + list.get(1) + "'";
208+
}
209+
210+
case IS_GREATER_THAN ->
211+
field + " > '" + list.get(0) + "'";
212+
213+
case IS_LESS_THAN_OR_EQUALS ->
214+
field + " <= '" + list.get(0) + "'";
215+
216+
// ---------------------------------------------------------------------
217+
// Starts / ends with
218+
// ---------------------------------------------------------------------
219+
case START_WITH ->
220+
field + " LIKE '" + list.get(0) + "%'";
221+
222+
case NOT_START_WITH ->
223+
field + " NOT LIKE '" + list.get(0) + "%'";
224+
225+
case ENDS_WITH ->
226+
field + " LIKE '%" + list.get(0) + "'";
227+
228+
case NOT_ENDS_WITH ->
229+
field + " NOT LIKE '%" + list.get(0) + "'";
230+
231+
// ---------------------------------------------------------------------
232+
// Value in multiple fields
233+
// ---------------------------------------------------------------------
234+
case IS_IN_FIELDS ->
235+
"'" + list.get(0) + "' IN (" + String.join(", ", list) + ")";
236+
237+
case IS_NOT_IN_FIELDS ->
238+
"'" + list.get(0) + "' NOT IN (" + String.join(", ", list) + ")";
239+
240+
// ---------------------------------------------------------------------
241+
default -> throw new IllegalArgumentException("Unsupported operator: " + f.getOperator());
242+
};
243+
}
244+
245+
/**
246+
* Joins a list of values into a comma-separated list of quoted literals.
247+
* Example: ["a","b"] -> 'a', 'b'
248+
*/
249+
private String joinQuoted(List<String> values) {
250+
return values.stream()
251+
.map(v -> "'" + v + "'")
252+
.collect(Collectors.joining(", "));
253+
}
254+
255+
// -------------------------------------------------------------------------
256+
// AND / OR COMBINATION
257+
// -------------------------------------------------------------------------
258+
259+
/**
260+
* Combines AND and OR condition lists into a single SQL expression.
261+
* - AND conditions are grouped in parentheses.
262+
* - OR conditions are grouped in parentheses.
263+
* - If both exist: (AND...) AND (OR...)
264+
*/
265+
private String combineConditions(List<String> ands, List<String> ors) {
266+
267+
String andPart = ands.isEmpty()
268+
? ""
269+
: "(" + String.join(" AND ", ands) + ")";
270+
271+
String orPart = ors.isEmpty()
272+
? ""
273+
: "(" + String.join(" OR ", ors) + ")";
274+
275+
if (!andPart.isEmpty() && !orPart.isEmpty()) {
276+
return andPart + " AND " + orPart;
277+
}
278+
279+
return andPart + orPart;
280+
}
281+
282+
// -------------------------------------------------------------------------
283+
// MERGING WITH BASE SQL
284+
// -------------------------------------------------------------------------
285+
286+
/**
287+
* Merges the generated WHERE clause into the base SQL.
288+
* - If base SQL already has WHERE, appends "AND <whereClause>".
289+
* - Otherwise, appends "WHERE <whereClause>".
290+
*/
291+
private String mergeSql(String sql, String whereClause) {
292+
if (whereClause == null || whereClause.isBlank()) {
293+
return sql;
294+
}
295+
296+
String normalized = normalizeForSearch(sql);
297+
298+
// Case 1: SQL already contains WHERE → append AND
299+
int whereIndex = normalized.indexOf(" where ");
300+
if (whereIndex != -1) {
301+
// Find where the WHERE clause ends (before GROUP BY / ORDER BY / LIMIT)
302+
int endOfWhere = findEndOfWhereClause(normalized, whereIndex + 7);
303+
return sql.substring(0, endOfWhere)
304+
+ " AND " + whereClause + " "
305+
+ sql.substring(endOfWhere);
306+
}
307+
308+
// Case 2: Insert WHERE after FROM <target>
309+
int fromIndex = normalized.indexOf(" from ");
310+
if (fromIndex != -1) {
311+
int insertPos = findEndOfFromTarget(sql, normalized, fromIndex + 6);
312+
return sql.substring(0, insertPos)
313+
+ " WHERE " + whereClause + " "
314+
+ sql.substring(insertPos);
315+
}
316+
317+
// Case 3: fallback
318+
return sql + " WHERE " + whereClause;
319+
}
320+
321+
private int findEndOfFromTarget(String originalSql, String normalizedSql, int start) {
322+
323+
List<String> keywords = List.of(" group by ", " order by ", " limit ", " having ", " where ");
324+
325+
int nextKeywordPos = normalizedSql.length();
326+
327+
for (String kw : keywords) {
328+
int idx = normalizedSql.indexOf(kw, start);
329+
if (idx != -1 && idx < nextKeywordPos) {
330+
nextKeywordPos = idx;
331+
}
332+
}
333+
334+
// Convert normalized index back to original index
335+
String before = normalizedSql.substring(0, nextKeywordPos);
336+
String lastToken = before.trim().substring(before.trim().lastIndexOf(" ") + 1);
337+
338+
int originalIndex = originalSql.toLowerCase().indexOf(lastToken);
339+
340+
return originalIndex == -1 ? originalSql.length() : originalIndex + lastToken.length();
341+
}
342+
343+
private int findEndOfWhereClause(String normalized, int start) {
344+
List<String> keywords = List.of(" group by ", " order by ", " limit ", " having ");
345+
346+
int nextKeywordPos = normalized.length();
347+
348+
for (String kw : keywords) {
349+
int idx = normalized.indexOf(kw, start);
350+
if (idx != -1 && idx < nextKeywordPos) {
351+
nextKeywordPos = idx;
352+
}
353+
}
354+
355+
return nextKeywordPos;
356+
}
357+
358+
private List<String> asList(Object value) {
359+
if (value == null) {
360+
return List.of();
361+
}
362+
if (value instanceof List<?> list) {
363+
return list.stream().map(String::valueOf).toList();
364+
}
365+
return List.of(String.valueOf(value)); // single value → list of one
366+
}
367+
368+
private String normalizeForSearch(String sql) {
369+
return sql
370+
.replace("\n", " ")
371+
.replace("\r", " ")
372+
.replace("\t", " ")
373+
.replaceAll(" +", " ")
374+
.toLowerCase();
375+
}
376+
}
377+

0 commit comments

Comments
 (0)