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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
benchmarks that publish diagnostics no longer time a growing buffer.

### Performance
- **gosqlx**: errors returned by `Parse`, `Validate`, `Lint` and the tree helpers now wrap the sentinel and the underlying error in an unexported `wrapErr` that builds its message lazily, instead of `fmt.Errorf("%w: %w", ...)` on every failure. Error text, `errors.Is` and `errors.As` behaviour are unchanged; only the unexported concrete error type differs. The saving applies to callers that only check `errors.Is`/`errors.As` or drop the message: when the message is built (`err.Error()`, as the LSP and CLI do) the gain is about 2 allocs/op and 3-4% B/op per error (syntax_mid 21 -> 19 allocs). Because the message is now built on demand, `Error()` reflects any later mutation of the inner `*errors.Error` reached through `errors.As`; previously the text was fixed at creation. [W1-03]
- **Parser joins slice sized from a JOIN lookahead [W4-03]**: when the first JOIN of a FROM clause is appended, `parseJoinClauses` counts the JOIN tokens that follow at the same paren depth (within 256 tokens, stopping at `;`, UNION/EXCEPT/INTERSECT or the closing `)`) and allocates the slice with capacity 1 + that count (max 8) instead of a fixed 2. Join-free statements pay nothing. `ParseE2E/join` and `complex` B/op drop about 20%; no API or AST change. A full slice handed in by a parenthesized join group or a comma table is grown by the same hint. Known edge cost: shapes the lookahead cannot see (`(a JOIN b) JOIN c`, `a JOIN b, c`) incur one extra allocation per statement; the gated suite is unaffected.
- **`ast.ReleaseAST` is O(1) for parser-built trees that hold no pooled nodes**: the parser counts the nodes it draws from the AST pools and tells
`ReleaseAST` through an internal hook (`pkg/sql/internal/astrelease`). When
Expand Down
15 changes: 15 additions & 0 deletions pkg/gosqlx/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ package gosqlx

import "errors"

// wrapErr is the lazy equivalent of fmt.Errorf("%w: %w", sentinel, err).
// Both errors must be non-nil. Keeping the pair inline lets errors.Is and
// errors.As traverse it without allocating a slice on every Unwrap call.
type wrapErr struct {
errs [2]error
}

func newWrap(sentinel, err error) *wrapErr {
return &wrapErr{errs: [2]error{sentinel, err}}
}

func (w *wrapErr) Error() string { return w.errs[0].Error() + ": " + w.errs[1].Error() }

func (w *wrapErr) Unwrap() []error { return w.errs[:] }

// Sentinel error values returned (wrapped) by the new ParseTree / ParseReader
// entry points. Callers can test specific failure classes with errors.Is:
//
Expand Down
30 changes: 15 additions & 15 deletions pkg/gosqlx/gosqlx.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func Parse(sql string) (*ast.AST, error) {
// copies the tokens while preprocessing and they are not used after.
tokens, err := tokscratch.String(tkz, sql)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTokenize, err)
return nil, newWrap(ErrTokenize, err)
}

// Step 3: Parse to AST directly from model tokens
Expand All @@ -125,7 +125,7 @@ func Parse(sql string) (*ast.AST, error) {

astNode, err := p.ParseFromModelTokens(tokens)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrSyntax, err)
return nil, newWrap(ErrSyntax, err)
}

return astNode, nil
Expand Down Expand Up @@ -193,7 +193,7 @@ func Parse(sql string) (*ast.AST, error) {
func ParseWithContext(ctx context.Context, sql string) (*ast.AST, error) {
// Check context before starting
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("%w: %w", ErrTimeout, err)
return nil, newWrap(ErrTimeout, err)
}

// Step 1: Get tokenizer from pool
Expand All @@ -204,9 +204,9 @@ func ParseWithContext(ctx context.Context, sql string) (*ast.AST, error) {
tokens, err := tokscratch.StringContext(ctx, tkz, sql)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("%w: %w", ErrTimeout, ctxErr)
return nil, newWrap(ErrTimeout, ctxErr)
}
return nil, fmt.Errorf("%w: %w", ErrTokenize, err)
return nil, newWrap(ErrTokenize, err)
}

// Step 3: Parse to AST with context support
Expand All @@ -216,9 +216,9 @@ func ParseWithContext(ctx context.Context, sql string) (*ast.AST, error) {
astNode, err := p.ParseContextFromModelTokens(ctx, tokens)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("%w: %w", ErrTimeout, ctxErr)
return nil, newWrap(ErrTimeout, ctxErr)
}
return nil, fmt.Errorf("%w: %w", ErrSyntax, err)
return nil, newWrap(ErrSyntax, err)
}

return astNode, nil
Expand All @@ -245,7 +245,7 @@ func ParseWithTimeout(sql string, timeout time.Duration) (*ast.AST, error) {
// (e.g., parsing completed just before the deadline on fast
// machines), wrap with ErrTimeout so callers can rely on errors.Is.
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("%w: %w", ErrTimeout, ctxErr)
return nil, newWrap(ErrTimeout, ctxErr)
}
return nil, err
}
Expand All @@ -255,7 +255,7 @@ func ParseWithTimeout(sql string, timeout time.Duration) (*ast.AST, error) {
// completes. Callers relying on errors.Is(err, ErrTimeout) must see the
// timeout in that case.
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("%w: %w", ErrTimeout, ctxErr)
return nil, newWrap(ErrTimeout, ctxErr)
}
return astNode, nil
}
Expand All @@ -281,7 +281,7 @@ func Validate(sql string) error {
// Use the dedicated validation fast-path that avoids building a full AST
err := parser.Validate(sql)
if err != nil {
return fmt.Errorf("%w: %w", ErrSyntax, err)
return newWrap(ErrSyntax, err)
}

return nil
Expand All @@ -307,15 +307,15 @@ func ParseBytes(sql []byte) (*ast.AST, error) {

tokens, err := tokscratch.Bytes(tkz, sql)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrTokenize, err)
return nil, newWrap(ErrTokenize, err)
}

p := parser.GetParser()
defer parser.PutParser(p)

astNode, err := p.ParseFromModelTokens(tokens)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrSyntax, err)
return nil, newWrap(ErrSyntax, err)
}

return astNode, nil
Expand Down Expand Up @@ -732,7 +732,7 @@ func ParseWithRecovery(sql string) ([]ast.Statement, []error) {

tokens, err := tokscratch.String(tkz, sql)
if err != nil {
return nil, []error{fmt.Errorf("%w: %w", ErrTokenize, err)}
return nil, []error{newWrap(ErrTokenize, err)}
}

p := parser.GetParser()
Expand All @@ -742,7 +742,7 @@ func ParseWithRecovery(sql string) ([]ast.Statement, []error) {
if len(recoveryErrs) > 0 {
wrapped := make([]error, len(recoveryErrs))
for i, e := range recoveryErrs {
wrapped[i] = fmt.Errorf("%w: %w", ErrSyntax, e)
wrapped[i] = newWrap(ErrSyntax, e)
}
return stmts, wrapped
}
Expand Down Expand Up @@ -787,7 +787,7 @@ func ParseWithDialect(sql string, dialect keywords.SQLDialect) (*ast.AST, error)
}
astNode, err := parser.ParseWithDialect(sql, dialect)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrSyntax, err)
return nil, newWrap(ErrSyntax, err)
}
return astNode, nil
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/gosqlx/lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ func lintResult(fileResult linter.FileResult) (*LintResult, error) {
res := &LintResult{Violations: fileResult.Violations}
switch {
case fileResult.TokenizeError != nil:
return res, fmt.Errorf("%w: %w", ErrTokenize, fileResult.TokenizeError)
return res, newWrap(ErrTokenize, fileResult.TokenizeError)
case fileResult.ParseError != nil:
return res, fmt.Errorf("%w: %w", ErrSyntax, fileResult.ParseError)
return res, newWrap(ErrSyntax, fileResult.ParseError)
}
return res, nil
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/gosqlx/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ func parseWithConfig(ctx context.Context, sql string, cfg parseOptions) (*ast.AS
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, wrapContextErr(ctxErr)
}
return nil, fmt.Errorf("%w: %w", ErrTokenize, tokErr)
return nil, newWrap(ErrTokenize, tokErr)
}

p := parser.GetParser()
Expand All @@ -350,7 +350,7 @@ func parseWithConfig(ctx context.Context, sql string, cfg parseOptions) (*ast.AS
// Join all diagnostics but surface them under ErrSyntax so
// errors.Is(err, ErrSyntax) still matches.
joined := errors.Join(errs...)
return &ast.AST{Statements: stmts}, fmt.Errorf("%w: %w", ErrSyntax, joined)
return &ast.AST{Statements: stmts}, newWrap(ErrSyntax, joined)
}
recovered := &ast.AST{Statements: stmts}
if cfg.comments {
Expand All @@ -364,7 +364,7 @@ func parseWithConfig(ctx context.Context, sql string, cfg parseOptions) (*ast.AS
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, wrapContextErr(ctxErr)
}
return nil, fmt.Errorf("%w: %w", ErrSyntax, err)
return nil, newWrap(ErrSyntax, err)
}
if cfg.comments {
attachComments(astNode, tkz.Comments)
Expand All @@ -388,7 +388,7 @@ func wrapContextErr(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%w: %w", ErrTimeout, err)
return newWrap(ErrTimeout, err)
}

// ─── Format options on the new top-level API ──────────────────────────────
Expand Down
Loading
Loading