Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,12 @@ Legend: `[ ]` open · `[x]` resolved · `[~]` won't fix / by design.

## P4 — Display & docs

- [ ] **E1 · `format_sql` splits `LEFT/RIGHT/FULL OUTER JOIN` across two lines.**
- [x] **E1 · `format_sql` splits `LEFT/RIGHT/FULL OUTER JOIN` across two lines.**
The keyword list processes bare `JOIN` before the multi-word forms, so the
newline is inserted mid-keyword. Cosmetic but mangles a teaching artifact.
*Fix:* order the keyword list longest-first (or special-case `… OUTER JOIN`).

- [ ] **E2 · README overstates SQLite zero-setup for outer joins.** `RIGHT`/`FULL
- [x] **E2 · README overstates SQLite zero-setup for outer joins.** `RIGHT`/`FULL
OUTER JOIN` require SQLite ≥ 3.39 (2022); older platform Pythons raise
`OperationalError`. *Fix:* add a one-line version caveat near the outer-join
docs / backend-coverage note.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ s.outer_join(sp, how="right") # all shipments, even without suppliers
s.outer_join(sp, how="full") # both
```

> **Note:** `how="right"` and `how="full"` require **SQLite 3.39+** (2022),
> when SQLite added `RIGHT`/`FULL OUTER JOIN`. `how="left"` works on all
> supported versions. PostgreSQL and MySQL support all three.

### Set Operations

These require both relations to have *identical schemas* (same attribute
Expand Down Expand Up @@ -409,6 +413,9 @@ identifier quoting, and introspects table schemas from the database.
> schema introspection branches all exist — but are not currently run
> against live databases in CI. If you use coddpiece on PG or MySQL and
> spot a regression in those paths, please open an issue.
>
> Note that `RIGHT`/`FULL OUTER JOIN` require **SQLite 3.39+** (2022);
> older SQLite builds will reject those queries.

### Complete Operation Reference

Expand Down
23 changes: 21 additions & 2 deletions coddpiece/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,28 @@ def format_sql(sql: str) -> str:
for kw in ["FROM", "WHERE", "JOIN", "LEFT OUTER JOIN", "RIGHT OUTER JOIN",
"FULL OUTER JOIN", "GROUP BY", "HAVING", "ORDER BY",
"UNION", "INTERSECT", "EXCEPT"]:
# Only break before top-level clauses (not inside subqueries)
# Only break before top-level clauses (not inside subqueries).
#
# The bare "JOIN" keyword is a space-delimited substring of every
# multi-word form ("LEFT OUTER JOIN", etc.). Without the guard below,
# this loop's bare-JOIN pass matches the space *inside* an already
# emitted "LEFT OUTER JOIN" and splits it across two lines
# ("LEFT OUTER\nJOIN ..."). Crucially, reordering the list so the
# multi-word forms run first does NOT help: the later bare-JOIN pass
# re-splits them regardless. The negative lookbehind makes bare JOIN
# decline to match a JOIN that belongs to a compound keyword, leaving
# the dedicated multi-word rules to break those clauses as one unit.
if kw == "JOIN":
pattern = (
r'\s+'
r'(?<!OUTER )(?<!INNER )(?<!LEFT )'
r'(?<!RIGHT )(?<!FULL )(?<!CROSS )'
r'(JOIN)\b'
)
else:
pattern = rf'\s+({kw})\b'
formatted = re.sub(
rf'\s+({kw})\b',
pattern,
r'\n\1',
formatted,
)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -1130,3 +1130,54 @@ def test_compound_and_not_sql_still_compiles(self, sp_data):
not_sql = s.select(~(s.city == "London")).sql()
assert "NOT" in not_sql
assert "params:" in not_sql


class TestOuterJoinFormatting:
# format_sql() used to split multi-word OUTER JOIN keywords across two
# lines, because the bare-JOIN rewrite matched the space inside
# "LEFT OUTER JOIN". A negative lookbehind now keeps compound keywords
# intact while bare JOIN (natural/theta/equijoin) still breaks correctly.
def test_outer_join_keyword_not_split(self, sp_data):
s, p, sp, engine = sp_data
cases = {
"left": "LEFT OUTER JOIN",
"right": "RIGHT OUTER JOIN",
"full": "FULL OUTER JOIN",
}
for how, keyword in cases.items():
sql = s.outer_join(sp, how=how).sql()
# Contiguous one-line substring (the headline assertion).
assert keyword in sql, (how, sql)
# Never split as "... OUTER\nJOIN ...".
assert "OUTER\nJOIN" not in sql, (how, sql)
# Exactly one line begins with the compound keyword.
keyword_lines = [
ln for ln in sql.split("\n") if ln.strip().startswith(keyword)
]
assert len(keyword_lines) == 1, (how, sql)

def test_bare_join_still_breaks(self, sp_data):
# The lookbehind that protects compound keywords must NOT suppress the
# bare JOIN that natural/theta/equijoin emit — it should still start
# its own line.
s, p, sp, engine = sp_data
sql = sp.join(s).sql()
join_lines = [
ln for ln in sql.split("\n") if ln.strip().startswith("JOIN")
]
assert len(join_lines) == 1, sql

def test_division_subquery_indentation_preserved(self, sp_data):
# The fix touches only the bare-JOIN rule; the paren-counting indenter
# that lays out division's nested subqueries must be untouched.
s, p, sp, engine = sp_data
red_parts = p.select(p.color == "Red").project("pno")
sql = sp.project("sno", "pno").divide(red_parts).sql()
assert "NOT EXISTS" in sql
assert "EXCEPT" in sql
assert "OUTER\nJOIN" not in sql
# At least one nested FROM is indented (subquery layout intact).
assert any(
ln.startswith(" ") and ln.strip().startswith("FROM")
for ln in sql.split("\n")
), sql
Loading