Skip to content

Commit 06476bf

Browse files
notSumit25claude
andcommitted
fix: trim the generated normalized_match to match Java's normalizeForMatching
Found in hands-on QA of the previous commit, not by reading it. SlowQueryAnalyticsService.normalizeForMatching ends with .trim(); the SQL expression behind the generated column did not. A lineage row stored with leading whitespace therefore normalized to " select ..." in the column and "select ..." on the Java side, so the prefix LIKE never matched and recoverFullText silently returned the truncated sample instead of the full SQL. Verified against the local install: inserting ' SELECT x FROM t WHERE y = 1 ' produced "[ select x from t where y = 1 ]" where Java produces "[select x from t where y = 1]"; 4 of 1,174 real rows carried such whitespace. The flaw was equally present in the inline expression this column replaced, so it is pre-existing rather than a regression — but it is silent either way, which is why it survived. Two parts to the fix: * btrim(...) added to the expression in both the initializer and V118. * The initializer now detects a stale column and rebuilds it. A generated column's expression cannot be altered in place and ADD COLUMN IF NOT EXISTS silently keeps whatever is already there, so an install that ran the earlier build would have kept the untrimmed expression forever. It compares pg_get_expr against the expected shape and only drops/re-adds when they differ, so a normal restart does not rewrite the table. Verified on the running stack: restart logged "Rebuilding query_lineage.normalized_match: stored expression is out of date", the stored expression now carries btrim, the index survived the rebuild, and rows-with-untrimmed-normalization went from 4 to 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4cf9014 commit 06476bf

2 files changed

Lines changed: 61 additions & 6 deletions

File tree

backend/src/main/java/com/dbaagent/config/QueryLineageMatchIndexInitializer.java

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,21 @@ public class QueryLineageMatchIndexInitializer {
6060
private static final String INDEX = "idx_query_lineage_norm_match";
6161

6262
/**
63-
* Must match {@code SlowQueryAnalyticsService.normalizeForMatching} exactly, and the
64-
* expression already inlined in the repository query. If one changes, all three do.
63+
* Must match {@code SlowQueryAnalyticsService.normalizeForMatching} exactly. If one
64+
* changes, both do.
65+
*
66+
* <p>The {@code btrim} is load-bearing and was missing from the expression this
67+
* replaces: Java's {@code normalizeForMatching} ends with {@code .trim()}, so a
68+
* lineage row stored with leading whitespace normalized to {@code " select ..."} on
69+
* the SQL side and {@code "select ..."} on the Java side. The prefix {@code LIKE}
70+
* then never matched and recovery silently returned the truncated sample. 4 of 1,174
71+
* rows on the local install carry such whitespace.
6572
*/
6673
private static final String NORMALIZE_EXPR =
67-
"lower(regexp_replace(regexp_replace("
74+
"btrim(lower(regexp_replace(regexp_replace("
6875
+ "replace(query_text, '`', ''), "
6976
+ "'\\s*([.,();])\\s*', '\\1', 'g'), "
70-
+ "'\\s+', ' ', 'g'))";
77+
+ "'\\s+', ' ', 'g')))";
7178

7279
@Bean("queryLineageMatchIndexBootstrap")
7380
@DependsOn("entityManagerFactory")
@@ -78,6 +85,20 @@ public Object queryLineageMatchIndexBootstrap(DataSource dataSource) {
7885
return new Object();
7986
}
8087

88+
// A generated column's expression cannot be altered in place, and ADD COLUMN IF
89+
// NOT EXISTS silently keeps whatever definition is already there. An install that
90+
// ran an earlier build of this initializer therefore keeps the untrimmed
91+
// expression forever unless the column is dropped first. Only drop when the
92+
// definition actually differs, so a normal restart does not rewrite the table.
93+
if (columnDefinitionDiffers(jdbc)) {
94+
log.info("Rebuilding {}.{}: stored expression is out of date", TABLE, COLUMN);
95+
try {
96+
jdbc.execute("ALTER TABLE " + TABLE + " DROP COLUMN " + COLUMN);
97+
} catch (RuntimeException e) {
98+
log.warn("Could not drop stale {}.{}: {}", TABLE, COLUMN, e.getMessage());
99+
}
100+
}
101+
81102
try {
82103
jdbc.execute("ALTER TABLE " + TABLE + " ADD COLUMN IF NOT EXISTS " + COLUMN
83104
+ " text GENERATED ALWAYS AS (" + NORMALIZE_EXPR + ") STORED");
@@ -101,6 +122,35 @@ public Object queryLineageMatchIndexBootstrap(DataSource dataSource) {
101122
return new Object();
102123
}
103124

125+
/**
126+
* True when {@code normalized_match} exists but was generated by a different
127+
* expression than {@link #NORMALIZE_EXPR}. Compared on the normalized form Postgres
128+
* stores in {@code pg_get_expr}, with whitespace collapsed, since the server rewrites
129+
* the text it was given (adds casts, reorders parens) and a literal comparison would
130+
* report a difference on every start and rewrite the table each time.
131+
*/
132+
private static boolean columnDefinitionDiffers(JdbcTemplate jdbc) {
133+
try {
134+
String stored = jdbc.query(
135+
"SELECT pg_get_expr(d.adbin, d.adrelid) "
136+
+ "FROM pg_attrdef d "
137+
+ "JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum "
138+
+ "WHERE d.adrelid = ?::regclass AND a.attname = ?",
139+
rs -> rs.next() ? rs.getString(1) : null, TABLE, COLUMN);
140+
if (stored == null) {
141+
return false; // column not present yet — nothing stale to drop
142+
}
143+
return !squash(stored).contains("btrim");
144+
} catch (RuntimeException e) {
145+
log.warn("Could not inspect {}.{} definition: {}", TABLE, COLUMN, e.getMessage());
146+
return false;
147+
}
148+
}
149+
150+
private static String squash(String s) {
151+
return s.replaceAll("\\s+", "").toLowerCase();
152+
}
153+
104154
private static boolean tableExists(JdbcTemplate jdbc, String table) {
105155
try {
106156
Integer found = jdbc.queryForObject(

backend/src/main/resources/db/migration/V118__add_query_lineage_norm_match.sql

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,21 @@
1717
-- at write time. On the same 34,976-row table, with the ~120-character prefix the caller
1818
-- actually sends: Index Scan, 0.428 ms.
1919
--
20+
-- The btrim matches Java's normalizeForMatching, which ends with .trim(). Without it a
21+
-- row stored with leading whitespace normalizes to " select ..." here but "select ..." in
22+
-- Java, so the prefix LIKE never matches and recovery silently returns the truncated
23+
-- sample. That flaw was present in the inline expression this replaces.
24+
--
2025
-- NOTE: this repo has no Flyway runtime (see CLAUDE.md). QueryLineageMatchIndexInitializer
2126
-- is what actually applies these statements at startup; this file is the changelog record.
2227

2328
ALTER TABLE query_lineage
2429
ADD COLUMN IF NOT EXISTS normalized_match text
2530
GENERATED ALWAYS AS (
26-
lower(regexp_replace(regexp_replace(
31+
btrim(lower(regexp_replace(regexp_replace(
2732
replace(query_text, '`', ''),
2833
'\s*([.,();])\s*', '\1', 'g'),
29-
'\s+', ' ', 'g'))
34+
'\s+', ' ', 'g')))
3035
) STORED;
3136

3237
-- text_pattern_ops so LIKE 'prefix%' can use the index under any collation; the default

0 commit comments

Comments
 (0)