* Add support for formatting subqueries with configurable placement and spacing. * Implement CASE expression formatting with options for wrapping and collapsing. * Introduce tests for subquery and CASE expression scenarios to ensure correctness.
1048 lines
26 KiB
Go
1048 lines
26 KiB
Go
package format
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
|
"git.warky.dev/wdevs/pgtidy/pkg/cst"
|
|
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
|
|
)
|
|
|
|
// isDMLStart reports whether toks begins with a DML statement keyword.
|
|
func isDMLStart(toks []cst.Tok) bool {
|
|
if len(toks) == 0 || toks[0].Tok.Kind != lexer.Ident {
|
|
return false
|
|
}
|
|
switch lowerASCII(toks[0].Tok.Text) {
|
|
case "select", "insert", "update", "delete", "with":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dmlSeg is one major clause of a DML statement.
|
|
type dmlSeg struct {
|
|
kw []cst.Tok // clause keyword tokens (possibly multi-word)
|
|
body []cst.Tok // remaining tokens up to the next clause boundary
|
|
}
|
|
|
|
// formatDML formats a top-level DML statement from its token slice, applying
|
|
// keyword casing, clause-per-line layout, and leading-comma column lists for
|
|
// SELECT and UPDATE SET clauses. Falls back to verbatim on comment-heavy input.
|
|
func formatDML(toks []cst.Tok, st config.Style) string {
|
|
// Strip the trailing semicolon so segments don't see it.
|
|
semi := ""
|
|
if n := len(toks); n > 0 && toks[n-1].Tok.Kind == lexer.Semicolon {
|
|
semi = ";"
|
|
toks = toks[:n-1]
|
|
}
|
|
|
|
segs := segmentDML(toks)
|
|
if len(segs) == 0 {
|
|
return verbatimSpan(toks) + semi
|
|
}
|
|
|
|
nl := st.Newline
|
|
var b strings.Builder
|
|
for i, seg := range segs {
|
|
if i > 0 {
|
|
b.WriteString(nl)
|
|
}
|
|
b.WriteString(dmlSegText(seg, st))
|
|
}
|
|
b.WriteString(semi)
|
|
return b.String()
|
|
}
|
|
|
|
// segmentDML splits toks into clause segments at depth-0 clause boundaries.
|
|
// Tokens inside parentheses (depth > 0) are never treated as clause starters,
|
|
// so subqueries and function calls are kept intact.
|
|
func segmentDML(toks []cst.Tok) []dmlSeg {
|
|
var segs []dmlSeg
|
|
depth := 0
|
|
segStart := 0
|
|
kwEnd := 0
|
|
started := false
|
|
|
|
flush := func(end int) {
|
|
if !started || end <= segStart {
|
|
return
|
|
}
|
|
segs = append(segs, dmlSeg{
|
|
kw: toks[segStart:kwEnd],
|
|
body: toks[kwEnd:end],
|
|
})
|
|
}
|
|
|
|
i := 0
|
|
for i < len(toks) {
|
|
t := toks[i]
|
|
switch t.Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
i++
|
|
continue
|
|
case lexer.RParen, lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if depth == 0 && t.Tok.Kind == lexer.Ident {
|
|
kw := lowerASCII(t.Tok.Text)
|
|
if dmlIsClauseKw(kw, toks, i) {
|
|
flush(i)
|
|
started = true
|
|
segStart = i
|
|
i = dmlConsumeKw(toks, i)
|
|
kwEnd = i
|
|
continue
|
|
}
|
|
}
|
|
i++
|
|
}
|
|
flush(len(toks))
|
|
return segs
|
|
}
|
|
|
|
// dmlIsClauseKw reports whether the keyword at toks[i] starts a new DML clause.
|
|
func dmlIsClauseKw(kw string, toks []cst.Tok, i int) bool {
|
|
switch kw {
|
|
case "select", "from", "where", "having", "limit", "offset",
|
|
"returning", "with", "into", "values", "set",
|
|
"union", "intersect", "except",
|
|
"insert", "update", "delete",
|
|
"join", "left", "right", "inner", "full", "cross", "natural":
|
|
return true
|
|
case "group", "order":
|
|
return i+1 < len(toks) && toks[i+1].Is("by")
|
|
case "on":
|
|
return i+1 < len(toks) && toks[i+1].Is("conflict")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dmlConsumeKw advances past multi-word clause keywords (e.g. GROUP BY,
|
|
// LEFT OUTER JOIN, INSERT INTO, DELETE FROM) and returns the new index.
|
|
func dmlConsumeKw(toks []cst.Tok, i int) int {
|
|
if i >= len(toks) {
|
|
return i
|
|
}
|
|
kw := lowerASCII(toks[i].Tok.Text)
|
|
i++
|
|
switch kw {
|
|
case "group", "order":
|
|
if i < len(toks) && toks[i].Is("by") {
|
|
i++
|
|
}
|
|
case "left", "right", "full":
|
|
if i < len(toks) && toks[i].Is("outer") {
|
|
i++
|
|
}
|
|
if i < len(toks) && toks[i].Is("join") {
|
|
i++
|
|
}
|
|
case "inner", "cross", "natural":
|
|
if i < len(toks) && toks[i].Is("join") {
|
|
i++
|
|
}
|
|
case "on":
|
|
if i < len(toks) && toks[i].Is("conflict") {
|
|
i++
|
|
}
|
|
case "delete":
|
|
// DELETE FROM — consume the FROM so it isn't treated as a separate clause.
|
|
if i < len(toks) && toks[i].Is("from") {
|
|
i++
|
|
}
|
|
case "insert":
|
|
// INSERT INTO — consume INTO.
|
|
if i < len(toks) && toks[i].Is("into") {
|
|
i++
|
|
}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// dmlSegText formats one DML clause segment into a text line (or lines for
|
|
// column-list clauses and WITH bodies).
|
|
func dmlSegText(seg dmlSeg, st config.Style) string {
|
|
kwText := dmlInline(seg.kw, st)
|
|
kw := ""
|
|
if len(seg.kw) > 0 {
|
|
kw = lowerASCII(seg.kw[0].Tok.Text)
|
|
}
|
|
|
|
switch kw {
|
|
case "select", "returning":
|
|
items := dmlSplitCommas(seg.body)
|
|
return dmlColListSelect(kwText, items, st)
|
|
case "set":
|
|
items := dmlSplitCommas(seg.body)
|
|
return dmlColListSet(kwText, items, st)
|
|
case "values":
|
|
return dmlValuesClause(kwText, seg.body, st)
|
|
case "where":
|
|
return dmlWhereClause(kwText, seg.body, st)
|
|
case "join", "left", "right", "inner", "full", "cross", "natural":
|
|
return dmlJoinClause(kwText, seg.body, st)
|
|
case "with":
|
|
return formatWithBody(kwText, seg.body, st)
|
|
default:
|
|
body := dmlInline(seg.body, st)
|
|
if body == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + body
|
|
}
|
|
}
|
|
|
|
// dmlJoinClause formats a JOIN clause, applying indent_join when configured.
|
|
func dmlJoinClause(kwText string, body []cst.Tok, st config.Style) string {
|
|
text := dmlInline(body, st)
|
|
line := kwText
|
|
if text != "" {
|
|
line += " " + text
|
|
}
|
|
if !st.IndentJoin {
|
|
return line
|
|
}
|
|
indent := strings.Repeat(st.Indent, st.JoinIndentSize)
|
|
nl := st.Newline
|
|
var b strings.Builder
|
|
for i, part := range strings.Split(line, nl) {
|
|
if i > 0 {
|
|
b.WriteString(nl)
|
|
}
|
|
b.WriteString(indent)
|
|
b.WriteString(part)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// dmlWhereClause formats a WHERE clause, splitting AND/OR conditions per
|
|
// the where_wrap and where_and_or_indent settings.
|
|
func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string {
|
|
if st.WhereWrap == config.WrapNever {
|
|
text := dmlInline(body, st)
|
|
if text == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + text
|
|
}
|
|
|
|
// Split at depth-0 AND/OR.
|
|
conditions := dmlSplitAndOr(body)
|
|
if len(conditions) <= 1 {
|
|
text := dmlInline(body, st)
|
|
if text == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + text
|
|
}
|
|
|
|
nl := st.Newline
|
|
var b strings.Builder
|
|
b.WriteString(kwText)
|
|
for i, cond := range conditions {
|
|
b.WriteString(nl)
|
|
text := dmlInline(cond, st)
|
|
prefix := ""
|
|
if st.WhereAndOrIndent {
|
|
prefix = st.Indent
|
|
}
|
|
if i == 0 {
|
|
prefix += " " // align with AND/OR token width
|
|
}
|
|
writeListItem(&b, prefix, prefix, text, false, nl)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// dmlSplitAndOr splits toks at depth-0 AND/OR tokens, keeping the AND/OR with
|
|
// the following condition.
|
|
func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok {
|
|
var result [][]cst.Tok
|
|
depth := 0
|
|
start := 0
|
|
for i, t := range toks {
|
|
switch t.Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
case lexer.RParen, lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
}
|
|
if depth == 0 && t.Tok.Kind == lexer.Ident {
|
|
low := lowerASCII(t.Tok.Text)
|
|
if (low == "and" || low == "or") && i > start {
|
|
result = append(result, toks[start:i])
|
|
start = i
|
|
}
|
|
}
|
|
}
|
|
result = append(result, toks[start:])
|
|
return result
|
|
}
|
|
|
|
// formatWithBody formats the body of a WITH clause by splitting CTE definitions
|
|
// at depth-0 commas and formatting the subquery inside each AS (...) block.
|
|
func formatWithBody(kwText string, body []cst.Tok, st config.Style) string {
|
|
nl := st.Newline
|
|
|
|
cteDefs := dmlSplitCommas(body)
|
|
// Filter spurious empty items.
|
|
var kept [][]cst.Tok
|
|
for _, d := range cteDefs {
|
|
if len(d) > 0 {
|
|
kept = append(kept, d)
|
|
}
|
|
}
|
|
cteDefs = kept
|
|
|
|
switch len(cteDefs) {
|
|
case 0:
|
|
return kwText
|
|
case 1:
|
|
return kwText + " " + formatCTEDef(cteDefs[0], st)
|
|
default:
|
|
// Multiple CTEs: one per line with the configured comma style.
|
|
first := st.Indent + " "
|
|
cont := st.Indent + ","
|
|
contPad := strings.Repeat(" ", len(cont)) // same width as cont, no comma
|
|
|
|
var b strings.Builder
|
|
b.WriteString(kwText)
|
|
for i, cteDef := range cteDefs {
|
|
b.WriteString(nl)
|
|
|
|
var headPfx, tailPfx string
|
|
if i == 0 || st.Commas != config.CommaLeading {
|
|
headPfx = first
|
|
tailPfx = first
|
|
} else {
|
|
headPfx = cont
|
|
tailPfx = contPad
|
|
}
|
|
|
|
cteText := formatCTEDef(cteDef, st)
|
|
cteLines := strings.Split(cteText, nl)
|
|
for j, line := range cteLines {
|
|
if j > 0 {
|
|
b.WriteString(nl)
|
|
b.WriteString(tailPfx)
|
|
} else {
|
|
b.WriteString(headPfx)
|
|
}
|
|
b.WriteString(line)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
}
|
|
|
|
// formatCTEDef formats one CTE definition of the form:
|
|
//
|
|
// name [column_list] AS [NOT] [MATERIALIZED] (subquery)
|
|
//
|
|
// The subquery is formatted as DML, indented by st.Indent inside the parentheses.
|
|
// Falls back to dmlInline if the expected structure is not found.
|
|
func formatCTEDef(toks []cst.Tok, st config.Style) string {
|
|
nl := st.Newline
|
|
|
|
// Find the AS keyword at depth 0.
|
|
asIdx := dmlKeywordIdx(toks, 0, "as")
|
|
if asIdx < 0 {
|
|
return dmlInline(toks, st)
|
|
}
|
|
|
|
// Find the opening '(' after AS (may be preceded by NOT / MATERIALIZED).
|
|
parenOpen := -1
|
|
for i := asIdx + 1; i < len(toks); i++ {
|
|
if toks[i].Tok.Kind == lexer.LParen {
|
|
parenOpen = i
|
|
break
|
|
}
|
|
if toks[i].Tok.Kind != lexer.Ident {
|
|
// Unexpected token before '(' — fall back.
|
|
break
|
|
}
|
|
}
|
|
if parenOpen < 0 {
|
|
return dmlInline(toks, st)
|
|
}
|
|
|
|
// Find the matching ')'.
|
|
parenClose := dmlMatchParen(toks, parenOpen)
|
|
if parenClose < 0 {
|
|
return dmlInline(toks, st)
|
|
}
|
|
|
|
// Format the header (name, optional column list, AS, optional MATERIALIZED).
|
|
header := dmlInline(toks[:parenOpen], st)
|
|
|
|
// Format and wrap the subquery per subquery_content/subquery_closing.
|
|
// The "AS (" space is standard CTE syntax and independent of
|
|
// subquery_space_before_paren; only subquery_opening's newline choice
|
|
// applies here.
|
|
subToks := toks[parenOpen+1 : parenClose]
|
|
sep := " "
|
|
if st.SubqueryOpening == config.PlacementNewLine {
|
|
sep = nl
|
|
}
|
|
return header + sep + dmlWrapSubquery(subToks, st)
|
|
}
|
|
|
|
// dmlKeywordIdx returns the index of the first token equal to kw at paren depth 0,
|
|
// starting from `from`. Returns -1 if not found.
|
|
func dmlKeywordIdx(toks []cst.Tok, from int, kw string) int {
|
|
depth := 0
|
|
for i := from; i < len(toks); i++ {
|
|
switch toks[i].Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
case lexer.RParen, lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
}
|
|
if depth == 0 && toks[i].Is(kw) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// dmlMatchParen returns the index of the ')' matching the '(' at toks[open].
|
|
// Returns -1 if no matching paren is found.
|
|
func dmlMatchParen(toks []cst.Tok, open int) int {
|
|
depth := 1
|
|
for i := open + 1; i < len(toks); i++ {
|
|
switch toks[i].Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
case lexer.RParen:
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
case lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// dmlIsSubqueryOpen reports whether toks[i] is a '(' immediately followed by
|
|
// SELECT or WITH — i.e. it opens a subquery (derived table, scalar subquery,
|
|
// or an IN/EXISTS/ANY/ALL/ARRAY(...) subquery), as opposed to a function-call
|
|
// argument list, a value tuple, or a grouping paren.
|
|
func dmlIsSubqueryOpen(toks []cst.Tok, i int) bool {
|
|
if toks[i].Tok.Kind != lexer.LParen {
|
|
return false
|
|
}
|
|
j := i + 1
|
|
if j >= len(toks) || toks[j].Tok.Kind != lexer.Ident {
|
|
return false
|
|
}
|
|
switch lowerASCII(toks[j].Tok.Text) {
|
|
case "select", "with":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dmlWrapSubquery formats a subquery's inner tokens (excluding the enclosing
|
|
// parens) as DML and wraps them in "(" … ")" per the subquery_content and
|
|
// subquery_closing settings. The result is rendered relative to column 0;
|
|
// callers that splice it mid-line are responsible for re-indenting any
|
|
// continuation lines to the surrounding context.
|
|
func dmlWrapSubquery(inner []cst.Tok, st config.Style) string {
|
|
nl := st.Newline
|
|
sub := strings.TrimRight(formatDML(inner, st), nl)
|
|
if sub == "" {
|
|
return "()"
|
|
}
|
|
lines := strings.Split(sub, nl)
|
|
|
|
var b strings.Builder
|
|
b.WriteString("(")
|
|
for i, line := range lines {
|
|
if i == 0 && st.SubqueryContent != config.PlacementNewLine {
|
|
b.WriteString(line)
|
|
continue
|
|
}
|
|
b.WriteString(nl)
|
|
if line != "" {
|
|
b.WriteString(st.Indent)
|
|
}
|
|
b.WriteString(line)
|
|
}
|
|
if st.SubqueryClosing == config.PlacementNewLine {
|
|
b.WriteString(nl)
|
|
}
|
|
b.WriteString(")")
|
|
return b.String()
|
|
}
|
|
|
|
// dmlInline renders toks on one line with keyword casing and proper spacing.
|
|
// If toks[1:] contains comment trivia the function falls back to verbatimSpan
|
|
// so no comment is lost. Subquery parens and CASE…END expressions embedded
|
|
// anywhere in toks are recursively formatted and spliced in.
|
|
func dmlInline(toks []cst.Tok, st config.Style) string {
|
|
if len(toks) == 0 {
|
|
return ""
|
|
}
|
|
if anyComment(toks[1:]) {
|
|
return verbatimSpan(toks)
|
|
}
|
|
nl := st.Newline
|
|
var b strings.Builder
|
|
i := 0
|
|
for i < len(toks) {
|
|
t := toks[i]
|
|
|
|
if dmlIsSubqueryOpen(toks, i) {
|
|
if closeIdx := dmlMatchParen(toks, i); closeIdx > i {
|
|
dmlWriteSubquerySep(&b, toks, i, st, nl)
|
|
b.WriteString(dmlWrapSubquery(toks[i+1:closeIdx], st))
|
|
i = closeIdx + 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
if dmlIsCaseStart(t) {
|
|
if endIdx := dmlMatchCaseEnd(toks, i); endIdx > i {
|
|
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
|
|
b.WriteByte(' ')
|
|
}
|
|
b.WriteString(dmlFormatCase(toks[i:endIdx+1], st))
|
|
i = endIdx + 1
|
|
continue
|
|
}
|
|
}
|
|
|
|
if i > 0 {
|
|
space := needSpace(toks[i-1].Tok, t.Tok) && !isPctTypeBoundary(toks, i)
|
|
if !space && st.RecordSpaceBeforeParen && t.Tok.Kind == lexer.LParen &&
|
|
toks[i-1].Tok.Kind == lexer.Ident && lowerASCII(toks[i-1].Tok.Text) == "row" {
|
|
space = true
|
|
}
|
|
if space {
|
|
b.WriteByte(' ')
|
|
}
|
|
}
|
|
// Space after comma in calls: func(a, b) vs func(a,b).
|
|
if st.SpaceAfterCommaInCalls && i > 0 && toks[i-1].Tok.Kind == lexer.Comma {
|
|
// Only inside parens (caller manages this at depth > 0, but we add space
|
|
// when the comma is not a clause-level comma — heuristic: always add).
|
|
b.WriteByte(' ')
|
|
}
|
|
var prev lexer.Token
|
|
if i > 0 {
|
|
prev = toks[i-1].Tok
|
|
}
|
|
nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen
|
|
b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, st))
|
|
i++
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// dmlWriteSubquerySep writes the separator between the token preceding a
|
|
// subquery-opening '(' at toks[i] and the '(' itself, honoring
|
|
// subquery_opening (same_line|new_line) and subquery_space_before_paren.
|
|
func dmlWriteSubquerySep(b *strings.Builder, toks []cst.Tok, i int, st config.Style, nl string) {
|
|
if i == 0 {
|
|
return
|
|
}
|
|
if st.SubqueryOpening == config.PlacementNewLine {
|
|
b.WriteString(nl)
|
|
return
|
|
}
|
|
space := needSpace(toks[i-1].Tok, toks[i].Tok) && !isPctTypeBoundary(toks, i)
|
|
if !space && st.SubquerySpaceBeforeParen {
|
|
space = true
|
|
}
|
|
if space {
|
|
b.WriteByte(' ')
|
|
}
|
|
}
|
|
|
|
// dmlSplitCommas splits toks at depth-0 commas and returns the items between
|
|
// them (the comma tokens themselves are discarded).
|
|
func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok {
|
|
var items [][]cst.Tok
|
|
depth := 0
|
|
start := 0
|
|
for i, t := range toks {
|
|
switch t.Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
case lexer.RParen, lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
case lexer.Comma:
|
|
if depth == 0 {
|
|
items = append(items, toks[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
// Remaining tokens after the last comma (or all tokens if no comma found).
|
|
items = append(items, toks[start:])
|
|
return items
|
|
}
|
|
|
|
// filterEmpty drops empty token slices (spurious items from a trailing
|
|
// comma or similar).
|
|
func filterEmpty(items [][]cst.Tok) [][]cst.Tok {
|
|
var kept [][]cst.Tok
|
|
for _, item := range items {
|
|
if len(item) > 0 {
|
|
kept = append(kept, item)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// dmlCommaList renders texts as a one-item-per-line list under kwText, using
|
|
// leading or trailing commas per st.Commas. Items whose rendered text spans
|
|
// multiple lines (e.g. an embedded subquery or wrapped CASE) have their
|
|
// continuation lines re-indented to align under the item's first line.
|
|
func dmlCommaList(kwText string, texts []string, st config.Style) string {
|
|
switch len(texts) {
|
|
case 0:
|
|
return kwText
|
|
case 1:
|
|
if texts[0] == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + texts[0]
|
|
}
|
|
|
|
nl := st.Newline
|
|
first := st.Indent + " "
|
|
cont := st.Indent + ","
|
|
contPad := strings.Repeat(" ", len(cont))
|
|
|
|
var b strings.Builder
|
|
b.WriteString(kwText)
|
|
for i, text := range texts {
|
|
b.WriteString(nl)
|
|
trailingComma := st.Commas == config.CommaTrailing && i < len(texts)-1
|
|
if i == 0 || st.Commas != config.CommaLeading {
|
|
writeListItem(&b, first, first, text, trailingComma, nl)
|
|
} else {
|
|
writeListItem(&b, cont, contPad, text, false, nl)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// writeListItem writes text prefixed with headPfx (its first line) and
|
|
// tailPfx (any continuation lines), optionally followed by a trailing comma.
|
|
func writeListItem(b *strings.Builder, headPfx, tailPfx, text string, trailingComma bool, nl string) {
|
|
for j, line := range strings.Split(text, nl) {
|
|
if j > 0 {
|
|
b.WriteString(nl)
|
|
if line != "" {
|
|
b.WriteString(tailPfx)
|
|
}
|
|
} else {
|
|
b.WriteString(headPfx)
|
|
}
|
|
b.WriteString(line)
|
|
}
|
|
if trailingComma {
|
|
b.WriteString(",")
|
|
}
|
|
}
|
|
|
|
// dmlColListSelect formats a SELECT / RETURNING column list with optional
|
|
// align_columns and select_align_as settings.
|
|
func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string {
|
|
items = filterEmpty(items)
|
|
if len(items) == 1 {
|
|
body := dmlInline(items[0], st)
|
|
if body == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + body
|
|
}
|
|
|
|
texts := make([]string, len(items))
|
|
for i, item := range items {
|
|
texts[i] = dmlInline(item, st)
|
|
}
|
|
|
|
// align_columns / select_align_as: pad expressions so AS and aliases align.
|
|
if (st.AlignColumns || st.SelectAlignAs) && len(texts) > 1 {
|
|
texts = alignSelectItems(texts, st)
|
|
}
|
|
|
|
return dmlCommaList(kwText, texts, st)
|
|
}
|
|
|
|
// dmlColListSet formats an UPDATE SET column list with optional set_align_equal.
|
|
func dmlColListSet(kwText string, items [][]cst.Tok, st config.Style) string {
|
|
items = filterEmpty(items)
|
|
if len(items) == 1 {
|
|
body := dmlInline(items[0], st)
|
|
if body == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + body
|
|
}
|
|
|
|
texts := make([]string, len(items))
|
|
for i, item := range items {
|
|
texts[i] = dmlInline(item, st)
|
|
}
|
|
|
|
// set_align_equal: pad lhs so = signs align.
|
|
if st.SetAlignEqual && len(texts) > 1 {
|
|
texts = alignSetItems(texts)
|
|
}
|
|
|
|
return dmlCommaList(kwText, texts, st)
|
|
}
|
|
|
|
// dmlValuesClause formats a VALUES clause. When insert_collapse_values is
|
|
// true (the default), multiple rows stay packed onto one line, matching the
|
|
// pre-existing flat rendering. When false, each row gets its own line.
|
|
func dmlValuesClause(kwText string, body []cst.Tok, st config.Style) string {
|
|
rows := filterEmpty(dmlSplitCommas(body))
|
|
|
|
if len(rows) <= 1 || st.InsertCollapseValues {
|
|
text := dmlInline(body, st)
|
|
if text == "" {
|
|
return kwText
|
|
}
|
|
return kwText + " " + text
|
|
}
|
|
|
|
texts := make([]string, len(rows))
|
|
for i, row := range rows {
|
|
texts[i] = dmlInline(row, st)
|
|
}
|
|
return dmlCommaList(kwText, texts, st)
|
|
}
|
|
|
|
// alignSelectItems pads SELECT list item expressions so that AS keywords and
|
|
// alias names align vertically.
|
|
func alignSelectItems(texts []string, st config.Style) []string {
|
|
// Split each text into (expr, " AS ", alias) or keep as-is.
|
|
type part struct {
|
|
expr, alias string
|
|
hasAs bool
|
|
}
|
|
parts := make([]part, len(texts))
|
|
maxExpr := 0
|
|
for i, t := range texts {
|
|
// Find " AS " or " as " (case-insensitive).
|
|
if idx := findAsIndex(t); idx >= 0 {
|
|
parts[i] = part{expr: t[:idx], alias: t[idx:], hasAs: true}
|
|
if l := len(t[:idx]); l > maxExpr {
|
|
maxExpr = l
|
|
}
|
|
} else {
|
|
parts[i] = part{expr: t}
|
|
if st.AlignColumns {
|
|
if l := len(t); l > maxExpr {
|
|
maxExpr = l
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
out := make([]string, len(texts))
|
|
for i, p := range parts {
|
|
if !p.hasAs || maxExpr == 0 {
|
|
out[i] = texts[i]
|
|
continue
|
|
}
|
|
pad := strings.Repeat(" ", maxExpr-len(p.expr))
|
|
out[i] = p.expr + pad + p.alias
|
|
}
|
|
return out
|
|
}
|
|
|
|
// findAsIndex returns the byte index of " AS " (case-insensitive) in s,
|
|
// or -1 if not present at depth 0.
|
|
func findAsIndex(s string) int {
|
|
low := lowerASCII(s)
|
|
// Look for " as " boundary.
|
|
for i := 0; i < len(low)-3; i++ {
|
|
if low[i] == ' ' && low[i+1] == 'a' && low[i+2] == 's' && low[i+3] == ' ' {
|
|
return i + 1 // index of 'a'
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// alignSetItems pads SET assignment lhs values so that = signs align.
|
|
func alignSetItems(texts []string) []string {
|
|
maxLhs := 0
|
|
lhsWidths := make([]int, len(texts))
|
|
for i, t := range texts {
|
|
idx := strings.Index(t, " = ")
|
|
if idx < 0 {
|
|
idx = strings.Index(t, "=")
|
|
}
|
|
if idx >= 0 {
|
|
lhsWidths[i] = idx
|
|
if idx > maxLhs {
|
|
maxLhs = idx
|
|
}
|
|
}
|
|
}
|
|
if maxLhs == 0 {
|
|
return texts
|
|
}
|
|
out := make([]string, len(texts))
|
|
for i, t := range texts {
|
|
if lhsWidths[i] == 0 || lhsWidths[i] == maxLhs {
|
|
out[i] = t
|
|
continue
|
|
}
|
|
idx := lhsWidths[i]
|
|
pad := strings.Repeat(" ", maxLhs-idx)
|
|
out[i] = t[:idx] + pad + t[idx:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// caseCollapseWidth is the inline-length threshold under which case_collapse
|
|
// keeps a CASE expression on one line even when case_when_wrap is set.
|
|
const caseCollapseWidth = 60
|
|
|
|
// dmlIsCaseStart reports whether t is a CASE keyword token.
|
|
func dmlIsCaseStart(t cst.Tok) bool {
|
|
return t.Tok.Kind == lexer.Ident && lowerASCII(t.Tok.Text) == "case"
|
|
}
|
|
|
|
// dmlMatchCaseEnd returns the index of the END token that closes the CASE
|
|
// token at toks[start], accounting for nested CASE…END and paren depth.
|
|
// Returns -1 if no matching END is found.
|
|
func dmlMatchCaseEnd(toks []cst.Tok, start int) int {
|
|
depth := 0
|
|
caseDepth := 1
|
|
for i := start + 1; i < len(toks); i++ {
|
|
switch toks[i].Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
continue
|
|
case lexer.RParen, lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
continue
|
|
}
|
|
if depth != 0 || toks[i].Tok.Kind != lexer.Ident {
|
|
continue
|
|
}
|
|
switch lowerASCII(toks[i].Tok.Text) {
|
|
case "case":
|
|
caseDepth++
|
|
case "end":
|
|
caseDepth--
|
|
if caseDepth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// caseSeg is one part of a CASE expression's body: the optional leading
|
|
// operand (kw == nil), or a WHEN/THEN/ELSE-led span.
|
|
type caseSeg struct {
|
|
kw *cst.Tok
|
|
toks []cst.Tok
|
|
}
|
|
|
|
// dmlSplitCase splits a CASE expression's body (the tokens strictly between
|
|
// CASE and its matching END) into operand/when/then/else segments at
|
|
// depth-0 boundaries, skipping over any nested CASE…END.
|
|
func dmlSplitCase(body []cst.Tok) []caseSeg {
|
|
var segs []caseSeg
|
|
depth := 0
|
|
caseDepth := 0
|
|
start := 0
|
|
var curKw *cst.Tok
|
|
flush := func(end int) {
|
|
if end > start {
|
|
segs = append(segs, caseSeg{kw: curKw, toks: body[start:end]})
|
|
}
|
|
}
|
|
for i := range body {
|
|
t := body[i]
|
|
switch t.Tok.Kind {
|
|
case lexer.LParen, lexer.LBracket:
|
|
depth++
|
|
continue
|
|
case lexer.RParen, lexer.RBracket:
|
|
if depth > 0 {
|
|
depth--
|
|
}
|
|
continue
|
|
}
|
|
if depth != 0 || t.Tok.Kind != lexer.Ident {
|
|
continue
|
|
}
|
|
switch lowerASCII(t.Tok.Text) {
|
|
case "case":
|
|
caseDepth++
|
|
case "end":
|
|
if caseDepth > 0 {
|
|
caseDepth--
|
|
}
|
|
case "when", "then", "else":
|
|
if caseDepth == 0 {
|
|
flush(i)
|
|
start = i + 1
|
|
kw := body[i]
|
|
curKw = &kw
|
|
}
|
|
}
|
|
}
|
|
flush(len(body))
|
|
return segs
|
|
}
|
|
|
|
// whenThen is one rendered WHEN … THEN … branch of a CASE expression.
|
|
type whenThen struct {
|
|
whenKw, cond, thenKw, then string
|
|
}
|
|
|
|
// dmlFormatCase renders a CASE…END expression honoring case_when_wrap,
|
|
// case_end, and case_collapse. toks[0] must be CASE and toks[len(toks)-1]
|
|
// its matching END.
|
|
func dmlFormatCase(toks []cst.Tok, st config.Style) string {
|
|
body := toks[1 : len(toks)-1]
|
|
segs := dmlSplitCase(body)
|
|
|
|
operand := ""
|
|
var whens []whenThen
|
|
elseKw, elseText := "", ""
|
|
haveElse := false
|
|
pendingWhenKw, pendingCond := "", ""
|
|
|
|
for _, s := range segs {
|
|
text := dmlInline(s.toks, st)
|
|
if s.kw == nil {
|
|
operand = text
|
|
continue
|
|
}
|
|
kwText := caseText(s.kw.Tok, st)
|
|
switch lowerASCII(s.kw.Tok.Text) {
|
|
case "when":
|
|
pendingWhenKw, pendingCond = kwText, text
|
|
case "then":
|
|
whens = append(whens, whenThen{whenKw: pendingWhenKw, cond: pendingCond, thenKw: kwText, then: text})
|
|
case "else":
|
|
elseKw, elseText, haveElse = kwText, text, true
|
|
}
|
|
}
|
|
|
|
caseKw := caseText(toks[0].Tok, st)
|
|
endKw := caseText(toks[len(toks)-1].Tok, st)
|
|
|
|
inline := dmlCaseInline(caseKw, operand, whens, elseKw, elseText, haveElse, endKw)
|
|
if !st.CaseWhenWrap {
|
|
return inline
|
|
}
|
|
if st.CaseCollapse && len(inline) <= caseCollapseWidth {
|
|
return inline
|
|
}
|
|
return dmlCaseWrapped(caseKw, operand, whens, elseKw, elseText, haveElse, endKw, st)
|
|
}
|
|
|
|
// dmlCaseInline renders a CASE expression on a single line.
|
|
func dmlCaseInline(caseKw, operand string, whens []whenThen, elseKw, elseText string, haveElse bool, endKw string) string {
|
|
var b strings.Builder
|
|
b.WriteString(caseKw)
|
|
if operand != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(operand)
|
|
}
|
|
for _, w := range whens {
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.whenKw)
|
|
if w.cond != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.cond)
|
|
}
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.thenKw)
|
|
if w.then != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.then)
|
|
}
|
|
}
|
|
if haveElse {
|
|
b.WriteByte(' ')
|
|
b.WriteString(elseKw)
|
|
if elseText != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(elseText)
|
|
}
|
|
}
|
|
b.WriteByte(' ')
|
|
b.WriteString(endKw)
|
|
return b.String()
|
|
}
|
|
|
|
// dmlCaseWrapped renders a CASE expression with each WHEN … THEN branch (and
|
|
// ELSE) on its own line, per case_end for the closing END's placement.
|
|
func dmlCaseWrapped(caseKw, operand string, whens []whenThen, elseKw, elseText string, haveElse bool, endKw string, st config.Style) string {
|
|
nl := st.Newline
|
|
indent := st.Indent
|
|
|
|
var b strings.Builder
|
|
b.WriteString(caseKw)
|
|
if operand != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(operand)
|
|
}
|
|
for _, w := range whens {
|
|
b.WriteString(nl)
|
|
b.WriteString(indent)
|
|
b.WriteString(w.whenKw)
|
|
if w.cond != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.cond)
|
|
}
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.thenKw)
|
|
if w.then != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(w.then)
|
|
}
|
|
}
|
|
if haveElse {
|
|
b.WriteString(nl)
|
|
b.WriteString(indent)
|
|
b.WriteString(elseKw)
|
|
if elseText != "" {
|
|
b.WriteByte(' ')
|
|
b.WriteString(elseText)
|
|
}
|
|
}
|
|
if st.CaseEnd == config.PlacementNewLine {
|
|
b.WriteString(nl)
|
|
b.WriteString(endKw)
|
|
} else {
|
|
b.WriteByte(' ')
|
|
b.WriteString(endKw)
|
|
}
|
|
return b.String()
|
|
}
|