=== modified file 'src/main/org/h2/expression/condition/CompareLike.java'
--- old/src/main/org/h2/expression/condition/CompareLike.java	2023-03-22 09:26:01 +0000
+++ new/src/main/org/h2/expression/condition/CompareLike.java	2026-09-17 07:55:20 +0000
@@ -5,6 +5,7 @@
  */
 package org.h2.expression.condition;
 
+import java.util.Objects;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
 import org.h2.api.ErrorCode;
@@ -60,6 +61,13 @@
     /** indicates that we can shortcut the comparison and use contains */
     private boolean shortcutToContains;
 
+    /** pattern the compiled state above was built from, valid only if patternCached */
+    private String cachedPattern;
+    /** escape character the compiled state above was built from */
+    private Character cachedEscape;
+    /** indicates that initPattern completed, so the two fields above describe its input */
+    private boolean patternCached;
+
     public CompareLike(Database db, Expression left, Expression right,
             Expression escape, boolean regexp) {
         this(db.getCompareMode(), db.getSettings().defaultEscape, left, right,
@@ -128,7 +136,7 @@
                 return TypedValueExpression.getUnknown();
             }
             String p = r.getString();
-            initPattern(p, getEscapeChar(e));
+            initPatternIfChanged(p, getEscapeChar(e));
             if (invalidPattern) {
                 return TypedValueExpression.getUnknown();
             }
@@ -136,7 +144,11 @@
                 // optimization for X LIKE '%': convert to X IS NOT NULL
                 return new NullPredicate(left, true).optimize(session);
             }
-            if (isFullMatch()) {
+            // A right-trimming compare mode makes '=' ignore trailing blanks, which LIKE never
+            // does, so the rewrite below stops preserving the answer: 'abc ' = 'abc' is then true
+            // where 'abc ' LIKE 'abc' is false. Matching the pattern out is the only correct
+            // route in that mode.
+            if (isFullMatch() && !compareMode.isRtrim()) {
                 // optimization for X LIKE 'Hello': convert to X = 'Hello'
                 Value value = ValueString.get(patternString);
                 Expression expr = ValueExpression.get(value);
@@ -197,7 +209,7 @@
                 // should already be optimized
                 DbException.throwInternalError();
             }
-            initPattern(p, getEscapeChar(e));
+            initPatternIfChanged(p, getEscapeChar(e));
         }
         if (invalidPattern) {
             return;
@@ -261,7 +273,7 @@
             if (e == ValueNull.INSTANCE) {
                 return ValueNull.INSTANCE;
             }
-            initPattern(p, getEscapeChar(e));
+            initPatternIfChanged(p, getEscapeChar(e));
         }
         if (invalidPattern) {
             return ValueNull.INSTANCE;
@@ -360,13 +372,34 @@
      * @return true if the value matches
      */
     public boolean test(String testPattern, String value, char escapeChar) {
-        initPattern(testPattern, escapeChar);
+        initPatternIfChanged(testPattern, escapeChar);
         if (invalidPattern) {
             return false;
         }
         return compareAt(value, 0, 0, value.length(), patternChars, patternTypes);
     }
 
+    /**
+     * Compile the pattern unless the compiled state already describes it.  A pattern bound as a
+     * parameter is never folded to a constant by optimize(), so without this guard the callers
+     * below recompile the same pattern for every row they evaluate.
+     *
+     * @param p the pattern
+     * @param escapeChar the escape character, or null for none
+     */
+    private void initPatternIfChanged(String p, Character escapeChar) {
+        if (patternCached
+                && Objects.equals(p, cachedPattern)
+                && Objects.equals(escapeChar, cachedEscape)) {
+            return;
+        }
+        // recorded only once initPattern returns, so a pattern which throws caches nothing
+        initPattern(p, escapeChar);
+        cachedPattern = p;
+        cachedEscape = escapeChar;
+        patternCached = true;
+    }
+
     private void initPattern(String p, Character escapeChar) {
         if (compareMode.getName().equals(CompareMode.OFF) && !ignoreCase) {
             fastCompare = true;
@@ -385,6 +418,13 @@
             return;
         }
         patternLength = 0;
+        // cleared per call:  a pattern which sets it below must not condemn the next one
+        invalidPattern = false;
+        // likewise cleared up front, so the early returns below cannot leave a shortcut armed
+        // against the previous pattern's patternString
+        shortcutToStartsWith = false;
+        shortcutToEndsWith = false;
+        shortcutToContains = false;
         if (p == null) {
             patternTypes = null;
             patternChars = null;
@@ -428,11 +468,6 @@
         }
         patternString = new String(patternChars, 0, patternLength);
 
-        // Clear optimizations
-        shortcutToStartsWith = false;
-        shortcutToEndsWith = false;
-        shortcutToContains = false;
-
         // optimizes the common case of LIKE 'foo%'
         if (compareMode.getName().equals(CompareMode.OFF) && patternLength > 1) {
             int maxMatch = 0;

=== modified file 'src/main/org/h2/value/CompareMode.java'
--- old/src/main/org/h2/value/CompareMode.java	2025-01-22 11:30:21 +0000
+++ new/src/main/org/h2/value/CompareMode.java	2026-09-15 14:22:15 +0000
@@ -365,6 +365,10 @@
         return uuidUnsigned;
     }
 
+    public boolean isRtrim() {
+        return shouldRtrim;
+    }
+
     @Override
     public boolean equals(Object obj) {
         if (obj == this) {

=== modified file 'src/test/org/h2/test/TestAll.java'
--- old/src/test/org/h2/test/TestAll.java	2024-03-19 22:55:13 +0000
+++ new/src/test/org/h2/test/TestAll.java	2026-09-17 08:40:35 +0000
@@ -183,6 +183,7 @@
 import org.h2.test.unit.TestCharsetCollator;
 import org.h2.test.unit.TestClearReferences;
 import org.h2.test.unit.TestCollation;
+import org.h2.test.unit.TestCompareLike;
 import org.h2.test.unit.TestCompress;
 import org.h2.test.unit.TestConnectionInfo;
 import org.h2.test.unit.TestDataPage;
@@ -768,6 +769,7 @@
         addTest(new TestBigResult());
         addTest(new TestCases());
         addTest(new TestCheckpoint());
+        addTest(new TestCompareLike());
         addTest(new TestCompatibility());
         addTest(new TestCompatibilityOracle());
         addTest(new TestCompatibilitySQLServer());

=== added file 'src/test/org/h2/test/unit/TestCompareLike.java'
--- old/src/test/org/h2/test/unit/TestCompareLike.java	1970-01-01 00:00:00 +0000
+++ new/src/test/org/h2/test/unit/TestCompareLike.java	2026-09-17 07:40:11 +0000
@@ -0,0 +1,267 @@
+/*
+ * Copyright 2004-2019 H2 Group. Multiple-Licensed under the MPL 2.0,
+ * and the EPL 1.0 (https://h2database.com/html/license.html).
+ * Initial Developer: H2 Group
+ */
+package org.h2.test.unit;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Random;
+import org.h2.expression.condition.CompareLike;
+import org.h2.test.TestBase;
+import org.h2.value.CompareMode;
+
+/**
+ * Tests that a reused CompareLike instance never answers from state left behind by an earlier
+ * pattern. One instance serves every row of a query, and when the pattern is a column
+ * (WHERE v LIKE p) it is recompiled per row, so anything initPattern() forgets to reset shows
+ * up as a wrong answer on a later row rather than as an obvious failure.
+ */
+public class TestCompareLike extends TestBase {
+
+    private static final int MATCH = 0, ONE = 1, ANY = 2;
+
+    private static final char ESC = '\\';
+
+    /**
+     * Run just this test.
+     *
+     * @param a ignored
+     */
+    public static void main(String... a) throws Exception {
+        TestBase.createCaller().init().test();
+    }
+
+    @Override
+    public void test() throws Exception {
+        testInvalidPatternIsNotSticky();
+        testEscapeCharIsPartOfTheKey();
+        testPatternLengthShrinks();
+        testRandomPatternSequence();
+        testColumnPattern();
+        testConstantPatternShortcuts();
+        testRtrimDoesNotLeakIntoLike();
+    }
+
+    private static CompareLike newLike() {
+        return new CompareLike(CompareMode.getInstance(null, 0, false), "\\", null, null, null, false);
+    }
+
+    /**
+     * A pattern ending in a bare escape character is invalid, and before the invalidPattern reset
+     * it stayed invalid for the life of the instance - poisoning every later row.
+     */
+    private void testInvalidPatternIsNotSticky() {
+        CompareLike comp = newLike();
+        assertFalse(comp.test("ab\\", "ab", ESC));
+        assertTrue(comp.test("abc", "abc", ESC));
+        assertTrue(comp.test("a%", "abc", ESC));
+        // and the other way round, so a valid pattern does not mask a following invalid one
+        assertTrue(comp.test("abc", "abc", ESC));
+        assertFalse(comp.test("ab\\", "ab", ESC));
+        assertTrue(comp.test("ab_", "abc", ESC));
+    }
+
+    /**
+     * Same pattern text, different escape character: the compiled state must be rebuilt, because
+     * the escape decides whether the next character is a wildcard or a literal.
+     */
+    private void testEscapeCharIsPartOfTheKey() {
+        CompareLike comp = newLike();
+        // '#' escapes the '_', so the pattern is the literal two characters "a_"
+        assertTrue(comp.test("a#_", "a_", '#'));
+        assertFalse(comp.test("a#_", "ab", '#'));
+        // with '\' as the escape the '#' is a literal and the '_' is the ONE wildcard
+        assertFalse(comp.test("a#_", "a_", ESC));
+        assertTrue(comp.test("a#_", "a#x", ESC));
+    }
+
+    /**
+     * Feeds patterns of decreasing length through one instance. The compiled arrays are sized
+     * from the raw pattern, and escapes make the used prefix shorter than the array, so a reader
+     * that trusts the array length rather than patternLength sees the previous pattern's tail.
+     */
+    private void testPatternLengthShrinks() {
+        CompareLike comp = newLike();
+        assertTrue(comp.test("abcdef", "abcdef", ESC));
+        assertTrue(comp.test("ab", "ab", ESC));
+        assertFalse(comp.test("ab", "abcdef", ESC));
+        assertTrue(comp.test("a%", "abcdef", ESC));
+        // escapes collapse two pattern characters into one compiled position
+        assertTrue(comp.test("a\\%b", "a%b", ESC));
+        assertFalse(comp.test("a\\%b", "axb", ESC));
+        assertTrue(comp.test("a\\%b", "a%b", ESC));
+        // consecutive wildcards collapse too
+        assertTrue(comp.test("a%%%b", "axyzb", ESC));
+        assertTrue(comp.test("a", "a", ESC));
+    }
+
+    /**
+     * Drives one instance through a long sequence of unrelated patterns - the shape of
+     * WHERE v LIKE p - and cross-checks every answer against an independent matcher.
+     */
+    private void testRandomPatternSequence() {
+        CompareLike comp = newLike();
+        Random random = new Random(1);
+        String alphabet = "ab%_\\";
+        for (int i = 0; i < 20000; i++) {
+            String pattern = randomString(random, alphabet, 5);
+            String value = randomString(random, "ab%_\\", 6);
+            Boolean expected = referenceMatch(pattern, value);
+            boolean actual = comp.test(pattern, value, ESC);
+            // an invalid pattern is reported as "no match" by CompareLike.test()
+            boolean expectedFlag = expected != null && expected;
+            if (expectedFlag != actual) {
+                fail("pattern=<" + pattern + "> value=<" + value + "> expected=" + expectedFlag
+                        + " actual=" + actual);
+            }
+        }
+    }
+
+    private static String randomString(Random random, String alphabet, int maxLength) {
+        int len = random.nextInt(maxLength + 1);
+        StringBuilder buff = new StringBuilder(len);
+        for (int i = 0; i < len; i++) {
+            buff.append(alphabet.charAt(random.nextInt(alphabet.length())));
+        }
+        return buff.toString();
+    }
+
+    /**
+     * The column-pattern case end to end: one CompareLike, a different pattern per row, and an
+     * invalid pattern in the middle of the scan.
+     */
+    private void testColumnPattern() throws SQLException {
+        try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:testCompareLikeColumn")) {
+            Statement stat = conn.createStatement();
+            stat.execute("CREATE TABLE t(id INT PRIMARY KEY, v VARCHAR, p VARCHAR)");
+            stat.execute("INSERT INTO t VALUES"
+                    + "(1, 'abc', 'ab\\')," // invalid: trailing escape, yields NULL
+                    + "(2, 'abc', 'abc')," // full match
+                    + "(3, 'abc', 'a%')," // startsWith shortcut
+                    + "(4, 'abc', '%bc')," // endsWith shortcut
+                    + "(5, 'abcdef', '%cd%')," // contains shortcut
+                    + "(6, 'abc', 'zzz')," // no match
+                    + "(7, 'abc', 'a_c')," // single character wildcard
+                    + "(8, 'abc', NULL)," // NULL pattern, yields NULL
+                    + "(9, 'a%c', 'a\\%c')," // escaped wildcard, literal percent
+                    + "(10, 'abc', 'a\\%c')"); // same pattern, must not match
+            assertEquals("2,3,4,5,7,9", queryIds(stat, "SELECT id FROM t WHERE v LIKE p ORDER BY id"));
+            // the same rows, evaluated in the opposite order, to rule out order-dependent state
+            assertEquals("9,7,5,4,3,2",
+                    queryIds(stat, "SELECT id FROM t WHERE v LIKE p ORDER BY id DESC"));
+        }
+    }
+
+    /**
+     * Constant patterns take the compile-once path and the startsWith / endsWith / contains
+     * shortcuts in getValue(). Each shortcut is checked against the same pattern spelled so that
+     * it cannot take a shortcut, so the two paths have to agree.
+     */
+    private void testConstantPatternShortcuts() throws SQLException {
+        try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:testCompareLikeConst")) {
+            Statement stat = conn.createStatement();
+            stat.execute("CREATE TABLE t(id INT PRIMARY KEY, v VARCHAR)");
+            stat.execute("INSERT INTO t VALUES(1, 'abcdef'), (2, 'abc'), (3, 'cdef'), (4, 'xyz'),"
+                    + "(5, ''), (6, 'cd'), (7, 'ABCDEF')");
+            // contains
+            assertEquals("1,3,6", queryIds(stat, "SELECT id FROM t WHERE v LIKE '%cd%' ORDER BY id"));
+            assertEquals("1,3,6", queryIds(stat, "SELECT id FROM t WHERE v LIKE '%c_%' ORDER BY id"));
+            // startsWith
+            assertEquals("1,2", queryIds(stat, "SELECT id FROM t WHERE v LIKE 'abc%' ORDER BY id"));
+            // endsWith
+            assertEquals("1,3", queryIds(stat, "SELECT id FROM t WHERE v LIKE '%def' ORDER BY id"));
+            // full match, rewritten to an equality comparison
+            assertEquals("2", queryIds(stat, "SELECT id FROM t WHERE v LIKE 'abc' ORDER BY id"));
+            // a pattern that matches everything becomes IS NOT NULL
+            assertEquals("1,2,3,4,5,6,7",
+                    queryIds(stat, "SELECT id FROM t WHERE v LIKE '%' ORDER BY id"));
+        }
+    }
+
+    /**
+     * Under RTRIM the '=' comparison ignores trailing blanks but LIKE never does, so the
+     * LIKE 'literal' to '= literal' rewrite must not fire.
+     */
+    private void testRtrimDoesNotLeakIntoLike() throws SQLException {
+        try (Connection conn = DriverManager.getConnection(
+                "jdbc:h2:mem:testCompareLikeRtrim;RTRIM=TRUE")) {
+            Statement stat = conn.createStatement();
+            stat.execute("CREATE TABLE t(id INT PRIMARY KEY, v VARCHAR)");
+            stat.execute("INSERT INTO t VALUES(1, 'abc'), (2, 'abc '), (3, 'abc  ')");
+            // '=' ignores the trailing blanks under RTRIM
+            assertEquals("1,2,3", queryIds(stat, "SELECT id FROM t WHERE v = 'abc' ORDER BY id"));
+            // LIKE does not, so only the exact value matches
+            assertEquals("1", queryIds(stat, "SELECT id FROM t WHERE v LIKE 'abc' ORDER BY id"));
+            assertEquals("2", queryIds(stat, "SELECT id FROM t WHERE v LIKE 'abc ' ORDER BY id"));
+            assertEquals("1,2,3", queryIds(stat, "SELECT id FROM t WHERE v LIKE 'abc%' ORDER BY id"));
+            // and the same holds when the pattern arrives as a column instead of a literal
+            assertEquals("1", queryIds(stat, "SELECT id FROM (SELECT id, v, 'abc' AS p FROM t) "
+                    + "WHERE v LIKE p ORDER BY id"));
+        }
+    }
+
+    private String queryIds(Statement stat, String sql) throws SQLException {
+        StringBuilder buff = new StringBuilder();
+        try (ResultSet rs = stat.executeQuery(sql)) {
+            while (rs.next()) {
+                if (buff.length() > 0) {
+                    buff.append(',');
+                }
+                buff.append(rs.getInt(1));
+            }
+        }
+        return buff.toString();
+    }
+
+    /**
+     * An independent, deliberately naive LIKE matcher for the default compare mode. Returns null
+     * when the pattern is invalid.
+     */
+    private static Boolean referenceMatch(String p, String value) {
+        ArrayList<int[]> tokens = new ArrayList<>();
+        for (int i = 0; i < p.length(); i++) {
+            char c = p.charAt(i);
+            if (c == ESC) {
+                if (i == p.length() - 1) {
+                    return null;
+                }
+                tokens.add(new int[] { MATCH, p.charAt(++i) });
+            } else if (c == '%') {
+                tokens.add(new int[] { ANY, 0 });
+            } else if (c == '_') {
+                tokens.add(new int[] { ONE, 0 });
+            } else {
+                tokens.add(new int[] { MATCH, c });
+            }
+        }
+        return referenceMatch(tokens, 0, value, 0);
+    }
+
+    private static boolean referenceMatch(ArrayList<int[]> tokens, int ti, String v, int vi) {
+        if (ti == tokens.size()) {
+            return vi == v.length();
+        }
+        int[] token = tokens.get(ti);
+        switch (token[0]) {
+        case MATCH:
+            return vi < v.length() && v.charAt(vi) == token[1]
+                    && referenceMatch(tokens, ti + 1, v, vi + 1);
+        case ONE:
+            return vi < v.length() && referenceMatch(tokens, ti + 1, v, vi + 1);
+        default:
+            for (int k = vi; k <= v.length(); k++) {
+                if (referenceMatch(tokens, ti + 1, v, k)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+}

