diff --git a/src/main/java/com/aladinsws/plugins/MybatisLogDialog.java b/src/main/java/com/aladinsws/plugins/MybatisLogDialog.java index bce3582..f6b8555 100644 --- a/src/main/java/com/aladinsws/plugins/MybatisLogDialog.java +++ b/src/main/java/com/aladinsws/plugins/MybatisLogDialog.java @@ -25,7 +25,7 @@ public MybatisLogDialog(@Nullable Project project, @NotNull String formattedSql) super(project); this.formattedSql = formattedSql; setTitle("MyBatis Log — Formatted SQL"); - setOKButtonText("Copy & Close"); + setOKButtonText("Copy && Close"); setCancelButtonText("Close"); init(); } diff --git a/src/main/java/com/aladinsws/plugins/SqlFormatter.java b/src/main/java/com/aladinsws/plugins/SqlFormatter.java index 18db1ce..55b6491 100644 --- a/src/main/java/com/aladinsws/plugins/SqlFormatter.java +++ b/src/main/java/com/aladinsws/plugins/SqlFormatter.java @@ -5,10 +5,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Arrays; import java.util.Deque; -import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.regex.Matcher; @@ -21,7 +18,7 @@ public final class SqlFormatter { /** * Clause keywords that force a new line (at SQL level, not inside function calls). */ - private static final Set CLAUSE_STARTERS = new LinkedHashSet<>(List.of( + private static final Set CLAUSE_STARTERS = Set.of( "WITH", "SELECT", "FROM", "FULL OUTER JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "INNER JOIN", "CROSS JOIN", "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", "JOIN", @@ -29,7 +26,7 @@ public final class SqlFormatter { "LIMIT", "OFFSET", "UNION ALL", "UNION", "INTERSECT ALL", "INTERSECT", "EXCEPT ALL", "EXCEPT", "SET" - )); + ); /** * Only these keywords before "(" open a SQL block (subquery / CTE body). @@ -38,6 +35,17 @@ public final class SqlFormatter { */ private static final Set BLOCK_OPENING_KEYWORDS = Set.of("AS", "FROM", "EXISTS"); + /** + * JOIN clause keywords — when ON follows one of these the AND/OR conditions + * should be aligned under the first condition rather than at the generic indent. + */ + private static final Set JOIN_KEYWORDS = Set.of( + "JOIN", "INNER JOIN", + "LEFT JOIN", "RIGHT JOIN", "FULL JOIN", + "LEFT OUTER JOIN", "RIGHT OUTER JOIN", "FULL OUTER JOIN", + "CROSS JOIN" + ); + /** * Inline-paren keywords that should still have a space before "(" for * readability: {@code OVER (…)}, {@code IN (…)}, etc. @@ -49,14 +57,14 @@ public final class SqlFormatter { /** * All single-word SQL keywords (used to distinguish keywords from identifiers). */ - private static final Set KEYWORDS = new HashSet<>(Arrays.asList( + private static final Set KEYWORDS = Set.of( "SELECT", "FROM", "WHERE", "JOIN", "ON", "AND", "OR", "NOT", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", "CROSS", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "BY", "UNION", "INTERSECT", "EXCEPT", "ALL", "DISTINCT", "TOP", "WITH", "AS", "SET", "CASE", "WHEN", "THEN", "ELSE", "END", - "IN", "IS", "NULL", "BETWEEN", "LIKE", "EXISTS", "NOT", + "IN", "IS", "NULL", "BETWEEN", "LIKE", "EXISTS", "OVER", "PARTITION", "ROWS", "RANGE", "PRECEDING", "FOLLOWING", "CURRENT", "ROW", "UNBOUNDED", "INSERT", "INTO", "VALUES", "UPDATE", "DELETE", @@ -66,7 +74,7 @@ public final class SqlFormatter { "FIRST", "LAST", "INTERVAL", "EXTRACT", "CAST", "COALESCE", "NULLIF", "GREATEST", "LEAST", "FILTER", "WITHIN", "LATERAL", "TRUE", "FALSE", "UNKNOWN", "DEFAULT", "USING" - )); + ); /** * Multi-word keywords to merge (longest first). @@ -200,17 +208,21 @@ private static void mergeTokensAt(@NotNull List list, int index, @NotNull // ── Block context ───────────────────────────────────────────────────────── /** - * Tracks state for each SQL block (top-level query or CTE/subquery body). + * Tracks state for each SQL block (top-level query or CTE body). */ private static class BlockCtx { - final int indent; // indentation of clause keywords in this block - String clause; // last clause keyword seen (SELECT, FROM, WHERE, …) - int inlineDepth; // depth of INLINE parens nested inside this block + final int indent; // indentation of clause keywords in this block + String clause; // last clause keyword seen (SELECT, FROM, WHERE, …) + int inlineDepth; // depth of INLINE parens nested inside this block + boolean betweenPending; // true after BETWEEN — the next AND belongs to BETWEEN … AND, not a logical AND + int joinOnConditionIndent; // >=0 when inside a JOIN's ON clause: column where first condition starts BlockCtx(int indent) { this.indent = indent; this.clause = null; this.inlineDepth = 0; + this.betweenPending = false; + this.joinOnConditionIndent = -1; } boolean atSqlLevel() { @@ -289,6 +301,7 @@ private static void handleSqlLevelKeyword(StringBuilder sb, String upper, BlockC Deque caseWhenIndentStack, boolean[] needSpace) { if (CLAUSE_STARTERS.contains(upper)) { + cur.betweenPending = false; handleClauseKeyword(sb, cur, upper); needSpace[0] = !upper.equals("SELECT"); } else if (upper.equals("CASE")) { @@ -301,6 +314,19 @@ private static void handleSqlLevelKeyword(StringBuilder sb, String upper, BlockC handleThenKeyword(sb, upper, caseWhenIndentStack, needSpace); } else if (upper.equals("END")) { handleEndKeyword(sb, upper, caseWhenIndentStack, needSpace); + } else if (upper.equals("AND") || upper.equals("OR")) { + handleAndOrKeyword(sb, upper, cur, needSpace); + } else if (upper.equals("BETWEEN") || upper.equals("NOT BETWEEN")) { + cur.betweenPending = true; + emit(sb, upper, needSpace[0]); + needSpace[0] = true; + } else if (upper.equals("ON") && JOIN_KEYWORDS.contains(cur.clause)) { + // ON after a JOIN: emit it then record where the first condition starts + // so subsequent AND/OR can be aligned to that column. + emit(sb, "ON", needSpace[0]); + // +1 for the space that will precede the next token + cur.joinOnConditionIndent = getCurrentLineLength(sb) + 1; + needSpace[0] = true; } else { emit(sb, upper, needSpace[0]); needSpace[0] = true; @@ -348,6 +374,35 @@ private static void handleEndKeyword(StringBuilder sb, String upper, needSpace[0] = true; } + /** + * Emits AND / OR on a new indented line so each WHERE/HAVING/ON condition + * is easy to read at a glance. + * + *
    + *
  • The AND that belongs to {@code BETWEEN x AND y} stays inline.
  • + *
  • Inside a JOIN's ON clause, AND/OR are aligned to the column where + * the first condition started (right after {@code ON }).
  • + *
  • Everywhere else, the standard {@code cur.indent + INDENT} is used.
  • + *
+ */ + private static void handleAndOrKeyword(StringBuilder sb, String upper, BlockCtx cur, + boolean[] needSpace) { + if (cur.betweenPending && upper.equals("AND")) { + // This AND closes a BETWEEN expression — keep it inline + cur.betweenPending = false; + emit(sb, upper, needSpace[0]); + } else if (cur.joinOnConditionIndent >= 0) { + // Inside a JOIN ON clause — align with the first condition + emitNewlineIndent(sb, cur.joinOnConditionIndent); + sb.append(upper); + } else { + // Logical AND / OR — put it on its own indented line + emitNewlineIndent(sb, cur.indent + INDENT); + sb.append(upper); + } + needSpace[0] = true; + } + private static boolean hasCaseWhenContext(Deque caseWhenIndentStack) { if (caseWhenIndentStack.isEmpty()) { return false; @@ -457,6 +512,7 @@ private static void handleClauseKeyword(StringBuilder sb, BlockCtx cur, String u emitNewlineIndent(sb, cur.indent); } cur.clause = upper; + cur.joinOnConditionIndent = -1; // reset whenever a new clause starts sb.append(upper); if (upper.equals("SELECT")) { // Column list follows — each on its own line @@ -464,12 +520,23 @@ private static void handleClauseKeyword(StringBuilder sb, BlockCtx cur, String u } } + /** + * Returns the number of characters on the current (last) line of {@code sb}, + * i.e. characters since the most-recent {@code '\n'} (or from the start if + * there is no newline yet). Used to compute column-aligned indentation for + * JOIN ON conditions. + */ + private static int getCurrentLineLength(StringBuilder sb) { + int lastNewline = sb.lastIndexOf("\n"); + return lastNewline < 0 ? sb.length() : sb.length() - lastNewline - 1; + } + /** * Appends a newline followed by {@code indent} spaces. */ private static void emitNewlineIndent(StringBuilder sb, int indent) { sb.append('\n'); - sb.append(" ".repeat(Math.max(0, indent))); + sb.repeat(" ", Math.max(0, indent)); } /** diff --git a/src/test/java/com/aladinsws/plugins/SqlFormatterTest.java b/src/test/java/com/aladinsws/plugins/SqlFormatterTest.java index 2f15be8..136f193 100644 --- a/src/test/java/com/aladinsws/plugins/SqlFormatterTest.java +++ b/src/test/java/com/aladinsws/plugins/SqlFormatterTest.java @@ -18,6 +18,15 @@ private static String fmt(String sql) { return SqlFormatter.format(sql); } + /** Returns the leading-whitespace count of the first line that starts with {@code prefix} (stripped). */ + private static int indentOf(String result, String prefix) { + return result.lines() + .filter(l -> l.stripLeading().startsWith(prefix)) + .mapToInt(l -> l.length() - l.stripLeading().length()) + .findFirst() + .orElse(-1); + } + // ----------------------------------------------------------------------- // Edge cases: empty / blank input // ----------------------------------------------------------------------- @@ -444,5 +453,256 @@ WITH tiers AS (SELECT id, \ result.lines().anyMatch(l -> l.trim().startsWith("END"))); } + // ----------------------------------------------------------------------- + // JOIN ON with multiple AND / OR conditions + // ----------------------------------------------------------------------- + + @Test + public void testJoinOnSingleConditionNoWrapping() { + // Baseline: single ON condition — no AND/OR, so no extra line should appear + String result = fmt("SELECT * FROM a LEFT JOIN b ON a.id = b.id"); + assertTrue("single-condition JOIN should stay on one line", + result.lines().anyMatch(l -> l.contains("LEFT JOIN b ON a.id = b.id"))); + } + + @Test + public void testJoinOnTwoAndConditionsEachOnNewLine() { + String sql = "SELECT * FROM user u LEFT JOIN address a ON u.adr_id = a.adr_id AND a.city = 'Tunis'"; + String result = fmt(sql); + + // AND must appear on its own line + assertTrue("AND must be on its own line", + result.lines().anyMatch(l -> l.stripLeading().startsWith("AND a.city"))); + + // AND must be indented deeper than the JOIN keyword itself + int andIndent = indentOf(result, "AND a.city"); + int joinIndent = indentOf(result, "LEFT JOIN"); + assertTrue("AND should be indented deeper than LEFT JOIN", andIndent > joinIndent + 5); + } + + @Test + public void testJoinOnAndAlignedWithFirstCondition() { + // Column of every AND in a JOIN ON must equal the column of the first condition + // (i.e., the position right after "ON "). + String sql = "SELECT * FROM a LEFT JOIN b ON a.id = b.id AND a.type = b.type AND a.status = 1"; + String result = fmt(sql); + + String joinLine = result.lines() + .filter(l -> l.contains("LEFT JOIN")) + .findFirst().orElseThrow(); + + // Compute where the first condition starts: right after " ON " + int firstConditionCol = joinLine.indexOf(" ON ") + " ON ".length(); + + result.lines() + .filter(l -> l.stripLeading().startsWith("AND")) + .forEach(l -> assertEquals( + "AND line must be indented to column " + firstConditionCol, + firstConditionCol, l.length() - l.stripLeading().length())); + } + + @Test + public void testJoinOnThreeAndConditionsExactLayout() { + // "LEFT JOIN address a ON" = 22 chars ➜ first condition at column 23 + // Each subsequent AND must be indented to column 23 (= 39 spaces in the + // text block after stripping the 16-space common indent). + String sql = "SELECT u.name, u.phone FROM user u " + + "LEFT JOIN address a ON u.adr_id = a.adr_id AND a.city = 'Tunis' AND u.name = '%a'"; + String expected = """ + SELECT + u.name, + u.phone + FROM user u + LEFT JOIN address a ON u.adr_id = a.adr_id + AND a.city = 'Tunis' + AND u.name = '%a'"""; + assertEquals(expected, fmt(sql)); + } + + @Test + public void testJoinOnOrConditionAligned() { + // OR inside a JOIN ON clause must also appear on its own line, + // aligned with the first condition. + String sql = "SELECT u.id FROM user u LEFT JOIN address a ON u.adr_id = a.id OR u.billing_id = a.id"; + String result = fmt(sql); + + assertTrue("OR in JOIN ON must be on its own line", + result.lines().anyMatch(l -> l.stripLeading().startsWith("OR u.billing_id"))); + + String joinLine = result.lines().filter(l -> l.contains("LEFT JOIN")).findFirst().orElseThrow(); + int firstConditionCol = joinLine.indexOf(" ON ") + " ON ".length(); + int orIndent = indentOf(result, "OR u.billing_id"); + assertEquals("OR must align with first condition after ON", firstConditionCol, orIndent); + } + + @Test + public void testJoinOnBetweenAndStaysInlineLogicalAndWraps() { + // The AND that belongs to BETWEEN … AND must stay on the JOIN line. + // Only the subsequent logical AND should move to a new line (aligned). + String sql = "SELECT * FROM t t1 " + + "INNER JOIN t t2 ON t1.age BETWEEN t2.min_age AND t2.max_age AND t1.type = 'A'"; + String result = fmt(sql); + + // BETWEEN … AND stays inline on the JOIN line + assertTrue("BETWEEN … AND must stay inline on JOIN line", + result.lines().anyMatch(l -> l.contains("BETWEEN t2.min_age AND t2.max_age"))); + + // The outer logical AND must be on its own line + assertTrue("Logical AND after BETWEEN must be on its own line", + result.lines().anyMatch(l -> l.stripLeading().startsWith("AND t1.type"))); + + // The outer AND must be deeper than INNER JOIN keyword + int andIndent = indentOf(result, "AND t1.type"); + int joinIndent = indentOf(result, "INNER JOIN"); + assertTrue("AND must be indented deeper than INNER JOIN", andIndent > joinIndent + 5); + } + + @Test + public void testMultipleJoinsEachWithMultipleAndConditions() { + // Two JOINs, each with multiple AND conditions, followed by a WHERE. + // JOIN ANDs must be column-aligned under their respective ON clause. + // WHERE ANDs must use the standard 2-space indent. + String sql = "SELECT u.name FROM user u " + + "INNER JOIN orders o ON u.id = o.user_id AND o.status = 'active' AND o.year = 2024 " + + "LEFT JOIN address a ON u.adr_id = a.adr_id AND a.country = 'TN' " + + "WHERE u.active = 1 AND u.age > 18"; + String result = fmt(sql); + + // INNER JOIN conditions on their own lines + assertTrue("INNER JOIN AND 1 on new line", + result.lines().anyMatch(l -> l.stripLeading().startsWith("AND o.status"))); + assertTrue("INNER JOIN AND 2 on new line", + result.lines().anyMatch(l -> l.stripLeading().startsWith("AND o.year"))); + + // LEFT JOIN condition on its own line + assertTrue("LEFT JOIN AND on new line", + result.lines().anyMatch(l -> l.stripLeading().startsWith("AND a.country"))); + + // WHERE AND uses standard 2-space indent + assertEquals("WHERE AND must use 2-space indent", 2, indentOf(result, "AND u.age")); + + // JOIN ANDs must be deeper than WHERE ANDs + int joinAndIndent = indentOf(result, "AND o.status"); + assertTrue("JOIN AND must be deeper than WHERE AND", joinAndIndent > 2); + } + + @Test + public void testJoinOnAndConditionsAndWhereAndHaveDifferentIndents() { + // JOIN ON AND is column-aligned (deep); WHERE AND is always 2-space. + String sql = "SELECT * FROM a " + + "LEFT JOIN b ON a.id = b.id AND a.type = 'x' " + + "WHERE a.active = 1 AND a.age > 0"; + String result = fmt(sql); + + int joinAndIndent = indentOf(result, "AND a.type"); + int whereAndIndent = indentOf(result, "AND a.age"); + + assertEquals("WHERE AND must be at 2-space indent", 2, whereAndIndent); + assertTrue("JOIN ON AND must be indented deeper than WHERE AND", + joinAndIndent > whereAndIndent); + } + + @Test + public void testInnerJoinMultipleAndExactLayout() { + // "INNER JOIN orders o ON" = 22 chars ➜ same first-condition column as LEFT JOIN above + String sql = "SELECT u.name FROM user u " + + "INNER JOIN orders o ON u.id = o.user_id AND o.status = 'active' AND o.year = 2024"; + String expected = """ + SELECT + u.name + FROM user u + INNER JOIN orders o ON u.id = o.user_id + AND o.status = 'active' + AND o.year = 2024"""; + assertEquals(expected, fmt(sql)); + } + + // ----------------------------------------------------------------------- + // Long WHERE clause with multiple AND / OR conditions + // ----------------------------------------------------------------------- + + @Test + public void testWhereFourAndConditions() { + String sql = "SELECT u.name FROM user u " + + "WHERE u.active = 1 AND u.age > 18 AND u.city = 'Tunis' AND u.role = 'admin'"; + String expected = """ + SELECT + u.name + FROM user u + WHERE u.active = 1 + AND u.age > 18 + AND u.city = 'Tunis' + AND u.role = 'admin'"""; + assertEquals(expected, fmt(sql)); + } + + @Test + public void testWhereAndOrMixed() { + String sql = "SELECT u.name FROM user u " + + "WHERE u.active = 1 AND u.age > 18 OR u.vip = 1 AND u.city = 'Tunis'"; + String result = fmt(sql); + + // Every logical operator must appear on its own line + assertTrue("AND u.age on new line", result.lines().anyMatch(l -> l.trim().equals("AND u.age > 18"))); + assertTrue("OR u.vip on new line", result.lines().anyMatch(l -> l.trim().equals("OR u.vip = 1"))); + assertTrue("AND u.city on new line", result.lines().anyMatch(l -> l.trim().equals("AND u.city = 'Tunis'"))); + + // All three must share the same 2-space indent + assertEquals(2, indentOf(result, "AND u.age")); + assertEquals(2, indentOf(result, "OR u.vip")); + assertEquals(2, indentOf(result, "AND u.city")); + } + + @Test + public void testWhereBetweenAndStaysInlineLogicalAndWraps() { + // BETWEEN … AND must remain on the same line as the WHERE condition; + // any surrounding logical ANDs must still be placed on their own lines. + String sql = "SELECT * FROM t WHERE age BETWEEN 18 AND 65 AND name = 'test'"; + String expected = """ + SELECT + * + FROM t + WHERE age BETWEEN 18 AND 65 + AND name = 'test'"""; + assertEquals(expected, fmt(sql)); + } + + @Test + public void testWhereMultipleOrConditions() { + String sql = "SELECT * FROM t WHERE status = 'A' OR status = 'B' OR status = 'C'"; + String result = fmt(sql); + + long orCount = result.lines() + .filter(l -> l.stripLeading().startsWith("OR status")) + .count(); + assertEquals("Expected 2 OR lines", 2, orCount); + + result.lines() + .filter(l -> l.stripLeading().startsWith("OR")) + .forEach(l -> assertEquals("OR must be at 2-space indent", + 2, l.length() - l.stripLeading().length())); + } + + @Test + public void testWhereLongAndOrWithHaving() { + // AND/OR in WHERE, then HAVING — both must use 2-space indent, + // neither should be confused with JOIN alignment. + String sql = "SELECT dept, COUNT(*) FROM emp " + + "WHERE active = 1 AND age > 18 OR manager = 1 " + + "GROUP BY dept " + + "HAVING COUNT(*) > 5 AND COUNT(*) < 100"; + String result = fmt(sql); + + // WHERE conditions + assertEquals(2, indentOf(result, "AND age")); + assertEquals(2, indentOf(result, "OR manager")); + // HAVING conditions + assertEquals(2, indentOf(result, "AND COUNT(*)")); + + // Clause keywords on their own lines + assertTrue(result.lines().anyMatch(l -> l.startsWith("GROUP BY"))); + assertTrue(result.lines().anyMatch(l -> l.startsWith("HAVING"))); + } + }