diff --git a/CLAUDE.md b/CLAUDE.md index 0252732d..7a97dad8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,9 @@ linter → sql/parser, sql/tokenizer # rule sub-packages additionally import: linter, models, sql/ast highlight → models, errors, sql/keywords, sql/tokenizer lsp → errors, models, gosqlx, sql/keywords, sql/parser, sql/tokenizer -cbinding → gosqlx, sql/ast (requires CGO; excluded from task test:race) +cbinding → gosqlx, errors, formatter, linter (+ rule sub-packages), sql/ast, + sql/keywords, sql/parser, sql/security + (requires CGO; excluded from task test:race) # High-level wrapper gosqlx → all of the above (top-level convenience API) diff --git a/pkg/cbinding/build.sh b/pkg/cbinding/build.sh index f463211d..60d715df 100755 --- a/pkg/cbinding/build.sh +++ b/pkg/cbinding/build.sh @@ -40,4 +40,4 @@ echo " Header: $OUTPUT_DIR/libgosqlx.h" echo "" echo "To use with Python:" echo " export GOSQLX_LIB_PATH=$OUTPUT_DIR/libgosqlx.${EXT}" -echo " python -c 'import pygosqlx; print(pygosqlx.version())'" +echo " python -c 'import pygosqlx; print(pygosqlx.version(), pygosqlx.dialects())'" diff --git a/pkg/cbinding/cbinding.go b/pkg/cbinding/cbinding.go index ce7c3825..633de085 100644 --- a/pkg/cbinding/cbinding.go +++ b/pkg/cbinding/cbinding.go @@ -37,6 +37,33 @@ // gosqlx_extract_metadata(sql) - extract tables, columns, functions with schema qualification // gosqlx_version() - return the library version string (e.g. "1.10.0") // gosqlx_free(ptr) - free a string previously returned by any gosqlx_* function +// +// Dialect-aware and analysis exports (added for pygosqlx 0.3.0; see cbinding_ext.go): +// +// gosqlx_parse_dialect(sql, dialect) - gosqlx_parse with a SQL dialect +// gosqlx_validate_dialect(sql, dialect) - gosqlx_validate with a SQL dialect +// gosqlx_format_dialect(sql, dialect) - gosqlx_format with a SQL dialect +// gosqlx_extract_tables_dialect(sql, dialect) - gosqlx_extract_tables with a SQL dialect +// gosqlx_extract_columns_dialect(sql, dialect) - gosqlx_extract_columns with a SQL dialect +// gosqlx_extract_functions_dialect(sql, dialect) - gosqlx_extract_functions with a SQL dialect +// gosqlx_extract_metadata_dialect(sql, dialect) - gosqlx_extract_metadata with a SQL dialect +// gosqlx_lint(sql, options_json) - run the `gosqlx lint` rule set (L001-L010) +// gosqlx_security_scan(sql) - scan SQL for injection patterns +// gosqlx_transpile(sql, from, to) - convert SQL between dialects +// gosqlx_dialects() - list the accepted dialect names +// +// The *_dialect variants take the dialect as a plain C string rather than an +// options JSON object: a dialect is the only option those operations accept, so +// a second string argument keeps the ABI as simple as the original exports. A +// NULL or empty dialect behaves exactly like the original export. An unknown +// dialect yields the operation's normal error shape with "error_code" set to +// "E4002" (pkg/errors.ErrCodeUnsupportedDialect) and the error message listing +// the valid names from gosqlx_dialects. Exports that genuinely take several +// options (gosqlx_lint) accept an options JSON object instead. +// +// The original exports are ABI-stable: their names, signatures and JSON output +// are unchanged. New fields are only ever added with omitempty so existing +// consumers see byte-identical results. package main // #include @@ -66,6 +93,8 @@ type ParseResult struct { ErrorColumn int `json:"error_column,omitempty"` StmtCount int `json:"statement_count"` StmtTypes []string `json:"statement_types"` + // ErrorCode is set only by the *_dialect exports for an unknown dialect. + ErrorCode string `json:"error_code,omitempty"` } // ValidationResult represents the result of validating SQL. @@ -74,6 +103,8 @@ type ValidationResult struct { Error string `json:"error,omitempty"` ErrorLine int `json:"error_line,omitempty"` ErrorColumn int `json:"error_column,omitempty"` + // ErrorCode is set only by the *_dialect exports for an unknown dialect. + ErrorCode string `json:"error_code,omitempty"` } // FormatResult represents the result of formatting SQL. @@ -81,6 +112,8 @@ type FormatResult struct { Success bool `json:"success"` Formatted string `json:"formatted,omitempty"` Error string `json:"error,omitempty"` + // ErrorCode is set only by the *_dialect exports for an unknown dialect. + ErrorCode string `json:"error_code,omitempty"` } // QualifiedNameJSON represents a qualified name for JSON serialization. @@ -98,6 +131,8 @@ type MetadataResult struct { ColumnsQualified []QualifiedNameJSON `json:"columns_qualified"` Functions []string `json:"functions"` Error string `json:"error,omitempty"` + // ErrorCode is set only by the *_dialect exports for an unknown dialect. + ErrorCode string `json:"error_code,omitempty"` } // extractErrorPosition extracts line and column numbers from an error message string. @@ -173,31 +208,7 @@ func statementTypeName(stmt ast.Statement) string { // //export gosqlx_parse func gosqlx_parse(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - tree, err := gosqlx.Parse(goSQL) - if err != nil { - line, col := extractErrorPosition(err.Error()) - result := ParseResult{ - Success: false, - Error: err.Error(), - ErrorLine: line, - ErrorColumn: col, - } - return toJSON(result) - } - - stmtTypes := make([]string, 0, len(tree.Statements)) - for _, stmt := range tree.Statements { - stmtTypes = append(stmtTypes, statementTypeName(stmt)) - } - - result := ParseResult{ - Success: true, - StmtCount: len(tree.Statements), - StmtTypes: stmtTypes, - } - return toJSON(result) + return toJSON(parseResultFor(C.GoString(sql), "")) } // gosqlx_validate validates a SQL string for syntactic correctness and returns @@ -212,22 +223,7 @@ func gosqlx_parse(sql *C.char) *C.char { // //export gosqlx_validate func gosqlx_validate(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - err := gosqlx.Validate(goSQL) - if err != nil { - line, col := extractErrorPosition(err.Error()) - result := ValidationResult{ - Valid: false, - Error: err.Error(), - ErrorLine: line, - ErrorColumn: col, - } - return toJSON(result) - } - - result := ValidationResult{Valid: true} - return toJSON(result) + return toJSON(validationResultFor(C.GoString(sql), "")) } // gosqlx_format formats a SQL string using default formatting options and @@ -242,23 +238,7 @@ func gosqlx_validate(sql *C.char) *C.char { // //export gosqlx_format func gosqlx_format(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - opts := gosqlx.DefaultFormatOptions() - formatted, err := gosqlx.Format(goSQL, opts) - if err != nil { - result := FormatResult{ - Success: false, - Error: err.Error(), - } - return toJSON(result) - } - - result := FormatResult{ - Success: true, - Formatted: formatted, - } - return toJSON(result) + return toJSON(formatResultFor(C.GoString(sql), "")) } // gosqlx_extract_tables parses a SQL string and extracts all referenced table @@ -273,18 +253,7 @@ func gosqlx_format(sql *C.char) *C.char { // //export gosqlx_extract_tables func gosqlx_extract_tables(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - tree, err := gosqlx.Parse(goSQL) - if err != nil { - return toJSON(map[string]interface{}{"error": err.Error()}) - } - - tables := gosqlx.ExtractTables(tree) - if tables == nil { - tables = []string{} - } - return toJSON(map[string]interface{}{"tables": tables}) + return toJSON(extractListFor(C.GoString(sql), "", "tables", gosqlx.ExtractTables)) } // gosqlx_extract_columns parses a SQL string and extracts all referenced column @@ -299,18 +268,7 @@ func gosqlx_extract_tables(sql *C.char) *C.char { // //export gosqlx_extract_columns func gosqlx_extract_columns(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - tree, err := gosqlx.Parse(goSQL) - if err != nil { - return toJSON(map[string]interface{}{"error": err.Error()}) - } - - columns := gosqlx.ExtractColumns(tree) - if columns == nil { - columns = []string{} - } - return toJSON(map[string]interface{}{"columns": columns}) + return toJSON(extractListFor(C.GoString(sql), "", "columns", gosqlx.ExtractColumns)) } // gosqlx_extract_functions parses a SQL string and extracts all referenced @@ -325,18 +283,7 @@ func gosqlx_extract_columns(sql *C.char) *C.char { // //export gosqlx_extract_functions func gosqlx_extract_functions(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - tree, err := gosqlx.Parse(goSQL) - if err != nil { - return toJSON(map[string]interface{}{"error": err.Error()}) - } - - functions := gosqlx.ExtractFunctions(tree) - if functions == nil { - functions = []string{} - } - return toJSON(map[string]interface{}{"functions": functions}) + return toJSON(extractListFor(C.GoString(sql), "", "functions", gosqlx.ExtractFunctions)) } // gosqlx_extract_metadata parses a SQL string and extracts comprehensive @@ -351,65 +298,16 @@ func gosqlx_extract_functions(sql *C.char) *C.char { // //export gosqlx_extract_metadata func gosqlx_extract_metadata(sql *C.char) *C.char { - goSQL := C.GoString(sql) - - tree, err := gosqlx.Parse(goSQL) - if err != nil { - return toJSON(map[string]interface{}{"error": err.Error()}) - } - - metadata := gosqlx.ExtractMetadata(tree) - - // Convert qualified tables - tablesQualified := make([]QualifiedNameJSON, 0, len(metadata.TablesQualified)) - for _, tq := range metadata.TablesQualified { - tablesQualified = append(tablesQualified, QualifiedNameJSON{ - Schema: tq.Schema, - Table: tq.Table, - Name: tq.Name, - }) - } - - // Convert qualified columns - columnsQualified := make([]QualifiedNameJSON, 0, len(metadata.ColumnsQualified)) - for _, cq := range metadata.ColumnsQualified { - columnsQualified = append(columnsQualified, QualifiedNameJSON{ - Schema: cq.Schema, - Table: cq.Table, - Name: cq.Name, - }) - } - - // Ensure non-nil slices for consistent JSON output - tables := metadata.Tables - if tables == nil { - tables = []string{} - } - columns := metadata.Columns - if columns == nil { - columns = []string{} - } - functions := metadata.Functions - if functions == nil { - functions = []string{} - } - - result := MetadataResult{ - Tables: tables, - TablesQualified: tablesQualified, - Columns: columns, - ColumnsQualified: columnsQualified, - Functions: functions, - } - return toJSON(result) + return toJSON(metadataFor(C.GoString(sql), "")) } // gosqlx_free releases memory allocated by the C binding functions. // Passing NULL is a no-op (free(NULL) is defined by the C standard). -// Every C string returned by gosqlx_parse, gosqlx_validate, gosqlx_format, -// gosqlx_extract_tables, gosqlx_extract_columns, gosqlx_extract_functions, -// gosqlx_extract_metadata, and gosqlx_version must be freed with this function -// to avoid memory leaks. +// Every C string returned by any other gosqlx_* export (gosqlx_parse, +// gosqlx_validate, gosqlx_format, the gosqlx_extract_* functions, every +// *_dialect variant, gosqlx_lint, gosqlx_security_scan, gosqlx_transpile, +// gosqlx_dialects and gosqlx_version) must be freed exactly once with this +// function to avoid memory leaks. // // Parameters: // - ptr: a C string pointer previously returned by a gosqlx_* function. diff --git a/pkg/cbinding/cbinding_ext.go b/pkg/cbinding/cbinding_ext.go new file mode 100644 index 00000000..1a913e6e --- /dev/null +++ b/pkg/cbinding/cbinding_ext.go @@ -0,0 +1,655 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This file holds the dialect-aware variants of the original exports and the +// analysis exports (lint, security scan, transpile, dialect list). It also +// holds the shared helpers both the original and the new exports go through, +// so a dialect-less call of a *_dialect export and the original export always +// produce the same JSON. + +package main + +// #include +import "C" +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors" + "github.com/ajitpratap0/GoSQLX/pkg/formatter" + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/linter" + lintkeywords "github.com/ajitpratap0/GoSQLX/pkg/linter/rules/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/linter/rules/style" + "github.com/ajitpratap0/GoSQLX/pkg/linter/rules/whitespace" + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" + "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" + "github.com/ajitpratap0/GoSQLX/pkg/sql/security" +) + +// errCodeUnsupportedDialect is the "error_code" every export reports for a +// dialect name that keywords.IsValidDialect rejects. +var errCodeUnsupportedDialect = string(goerrors.ErrCodeUnsupportedDialect) + +// errCodeInvalidOptions is the "error_code" gosqlx_lint reports for an +// options_json argument that is not a valid options object. +const errCodeInvalidOptions = "INVALID_OPTIONS" + +// formatLineWidth is the line width gosqlx.DefaultFormatOptions sets. It is +// spelled out because the FormatOptions field that carries it is deprecated. +const formatLineWidth = 80 + +// defaultLintMaxLineLength mirrors the default of `gosqlx lint --max-length`. +const defaultLintMaxLineLength = 100 + +// unsupportedDialectError is the error dialectError returns, so callers can +// tell an unknown dialect apart from a parse failure. +type unsupportedDialectError struct{ dialect string } + +func (e *unsupportedDialectError) Error() string { + return fmt.Sprintf("unknown SQL dialect %q; valid dialects: %s", + e.dialect, strings.Join(keywords.DialectNames(), ", ")) +} + +// dialectError returns nil when dialect is empty or a name accepted by +// keywords.IsValidDialect, and otherwise an error naming the valid dialects in +// the same words the CLI uses. +func dialectError(dialect string) error { + if keywords.IsValidDialect(dialect) { + return nil + } + return &unsupportedDialectError{dialect: dialect} +} + +// errorCodeFor returns the "error_code" value for err: E4002 for an unknown +// dialect, empty otherwise (parse errors keep reporting only the message and +// position, exactly as the original exports always have). +func errorCodeFor(err error) string { + var de *unsupportedDialectError + if errors.As(err, &de) { + return errCodeUnsupportedDialect + } + return "" +} + +// parseFor parses sql with the given dialect. An empty dialect goes through +// gosqlx.Parse, the path the original exports have always used. +func parseFor(sql, dialect string) (*ast.AST, error) { + if err := dialectError(dialect); err != nil { + return nil, err + } + if dialect == "" { + return gosqlx.Parse(sql) + } + return gosqlx.ParseWithDialect(sql, keywords.SQLDialect(dialect)) +} + +// parseResultFor builds the gosqlx_parse / gosqlx_parse_dialect result. +func parseResultFor(sql, dialect string) ParseResult { + tree, err := parseFor(sql, dialect) + if err != nil { + line, col := extractErrorPosition(err.Error()) + return ParseResult{ + Success: false, + Error: err.Error(), + ErrorLine: line, + ErrorColumn: col, + ErrorCode: errorCodeFor(err), + } + } + + stmtTypes := make([]string, 0, len(tree.Statements)) + for _, stmt := range tree.Statements { + stmtTypes = append(stmtTypes, statementTypeName(stmt)) + } + return ParseResult{ + Success: true, + StmtCount: len(tree.Statements), + StmtTypes: stmtTypes, + } +} + +// validateFor validates sql with the given dialect. An empty dialect goes +// through gosqlx.Validate; otherwise the dialect-aware validation fast path is +// used with the same empty-input rule and error wrapping as gosqlx.Validate. +func validateFor(sql, dialect string) error { + if err := dialectError(dialect); err != nil { + return err + } + if dialect == "" { + return gosqlx.Validate(sql) + } + if strings.TrimSpace(sql) == "" { + return fmt.Errorf("%w: empty input", gosqlx.ErrSyntax) + } + if err := parser.ValidateWithDialect(sql, keywords.SQLDialect(dialect)); err != nil { + return fmt.Errorf("%w: %w", gosqlx.ErrSyntax, err) + } + return nil +} + +// validationResultFor builds the gosqlx_validate / gosqlx_validate_dialect result. +func validationResultFor(sql, dialect string) ValidationResult { + if err := validateFor(sql, dialect); err != nil { + line, col := extractErrorPosition(err.Error()) + return ValidationResult{ + Valid: false, + Error: err.Error(), + ErrorLine: line, + ErrorColumn: col, + ErrorCode: errorCodeFor(err), + } + } + return ValidationResult{Valid: true} +} + +// formatFor formats sql with gosqlx.DefaultFormatOptions. An empty dialect goes +// through gosqlx.Format; otherwise the SQL is parsed with the dialect and +// rendered with the same option mapping gosqlx.Format uses, plus the dialect so +// dialect-specific constructs are rendered for that dialect. +func formatFor(sql, dialect string) (string, error) { + if err := dialectError(dialect); err != nil { + return "", err + } + opts := gosqlx.DefaultFormatOptions() + if dialect == "" { + return gosqlx.Format(sql, opts) + } + tree, err := gosqlx.ParseWithDialect(sql, keywords.SQLDialect(dialect)) + if err != nil { + return "", fmt.Errorf("cannot format invalid SQL: %w", err) + } + kwCase := ast.KeywordPreserve + if opts.UppercaseKeywords { + kwCase = ast.KeywordUpper + } + return formatter.FormatAST(tree, ast.FormatOptions{ + IndentStyle: ast.IndentSpaces, + IndentWidth: opts.IndentSize, + KeywordCase: kwCase, + LineWidth: formatLineWidth, + NewlinePerClause: opts.IndentSize > 0, + AddSemicolon: opts.AddSemicolon, + Dialect: dialect, + }), nil +} + +// formatResultFor builds the gosqlx_format / gosqlx_format_dialect result. +func formatResultFor(sql, dialect string) FormatResult { + formatted, err := formatFor(sql, dialect) + if err != nil { + return FormatResult{ + Success: false, + Error: err.Error(), + ErrorCode: errorCodeFor(err), + } + } + return FormatResult{Success: true, Formatted: formatted} +} + +// extractListFor builds the JSON object of the gosqlx_extract_{tables,columns, +// functions}[_dialect] exports: {key: [...]} on success, {"error": ...} (plus +// "error_code" for an unknown dialect) on failure. +func extractListFor(sql, dialect, key string, extract func(*ast.AST) []string) map[string]interface{} { + tree, err := parseFor(sql, dialect) + if err != nil { + out := map[string]interface{}{"error": err.Error()} + if code := errorCodeFor(err); code != "" { + out["error_code"] = code + } + return out + } + items := extract(tree) + if items == nil { + items = []string{} + } + return map[string]interface{}{key: items} +} + +// metadataFor builds the gosqlx_extract_metadata[_dialect] result. +func metadataFor(sql, dialect string) MetadataResult { + tree, err := parseFor(sql, dialect) + if err != nil { + return MetadataResult{Error: err.Error(), ErrorCode: errorCodeFor(err)} + } + + metadata := gosqlx.ExtractMetadata(tree) + + tablesQualified := make([]QualifiedNameJSON, 0, len(metadata.TablesQualified)) + for _, tq := range metadata.TablesQualified { + tablesQualified = append(tablesQualified, QualifiedNameJSON{ + Schema: tq.Schema, + Table: tq.Table, + Name: tq.Name, + }) + } + + columnsQualified := make([]QualifiedNameJSON, 0, len(metadata.ColumnsQualified)) + for _, cq := range metadata.ColumnsQualified { + columnsQualified = append(columnsQualified, QualifiedNameJSON{ + Schema: cq.Schema, + Table: cq.Table, + Name: cq.Name, + }) + } + + // Ensure non-nil slices for consistent JSON output. + tables := metadata.Tables + if tables == nil { + tables = []string{} + } + columns := metadata.Columns + if columns == nil { + columns = []string{} + } + functions := metadata.Functions + if functions == nil { + functions = []string{} + } + + return MetadataResult{ + Tables: tables, + TablesQualified: tablesQualified, + Columns: columns, + ColumnsQualified: columnsQualified, + Functions: functions, + } +} + +// --------------------------------------------------------------------------- +// Dialect-aware variants of the original exports +// --------------------------------------------------------------------------- + +// gosqlx_parse_dialect is gosqlx_parse with a SQL dialect (one of the names +// returned by gosqlx_dialects). A NULL or empty dialect behaves exactly like +// gosqlx_parse. An unknown dialect returns {"success": false, "error": ..., +// "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_parse_dialect +func gosqlx_parse_dialect(sql, dialect *C.char) *C.char { + return toJSON(parseResultFor(C.GoString(sql), C.GoString(dialect))) +} + +// gosqlx_validate_dialect is gosqlx_validate with a SQL dialect. A NULL or +// empty dialect behaves exactly like gosqlx_validate. An unknown dialect +// returns {"valid": false, "error": ..., "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_validate_dialect +func gosqlx_validate_dialect(sql, dialect *C.char) *C.char { + return toJSON(validationResultFor(C.GoString(sql), C.GoString(dialect))) +} + +// gosqlx_format_dialect is gosqlx_format with a SQL dialect: the SQL is parsed +// with the dialect and rendered for it. A NULL or empty dialect behaves exactly +// like gosqlx_format. An unknown dialect returns {"success": false, "error": +// ..., "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_format_dialect +func gosqlx_format_dialect(sql, dialect *C.char) *C.char { + return toJSON(formatResultFor(C.GoString(sql), C.GoString(dialect))) +} + +// gosqlx_extract_tables_dialect is gosqlx_extract_tables with a SQL dialect. +// A NULL or empty dialect behaves exactly like gosqlx_extract_tables. An +// unknown dialect returns {"error": ..., "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_extract_tables_dialect +func gosqlx_extract_tables_dialect(sql, dialect *C.char) *C.char { + return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "tables", gosqlx.ExtractTables)) +} + +// gosqlx_extract_columns_dialect is gosqlx_extract_columns with a SQL dialect. +// A NULL or empty dialect behaves exactly like gosqlx_extract_columns. An +// unknown dialect returns {"error": ..., "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_extract_columns_dialect +func gosqlx_extract_columns_dialect(sql, dialect *C.char) *C.char { + return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "columns", gosqlx.ExtractColumns)) +} + +// gosqlx_extract_functions_dialect is gosqlx_extract_functions with a SQL +// dialect. A NULL or empty dialect behaves exactly like +// gosqlx_extract_functions. An unknown dialect returns {"error": ..., +// "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_extract_functions_dialect +func gosqlx_extract_functions_dialect(sql, dialect *C.char) *C.char { + return toJSON(extractListFor(C.GoString(sql), C.GoString(dialect), "functions", gosqlx.ExtractFunctions)) +} + +// gosqlx_extract_metadata_dialect is gosqlx_extract_metadata with a SQL +// dialect. A NULL or empty dialect behaves exactly like +// gosqlx_extract_metadata. An unknown dialect returns {"error": ..., +// "error_code": "E4002"}. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_extract_metadata_dialect +func gosqlx_extract_metadata_dialect(sql, dialect *C.char) *C.char { + return toJSON(metadataFor(C.GoString(sql), C.GoString(dialect))) +} + +// --------------------------------------------------------------------------- +// gosqlx_lint +// --------------------------------------------------------------------------- + +// LintOptions is the options_json object accepted by gosqlx_lint. Unknown +// fields are rejected so a misspelt option is an error, not a silent no-op. +type LintOptions struct { + // Dialect selects the tokenizer/parser dialect (empty = default). + Dialect string `json:"dialect"` + // MaxLineLength is the L005 limit; omitted or null means 100, the + // `gosqlx lint --max-length` default. It must be positive when set. + MaxLineLength *int `json:"max_line_length"` +} + +// LintViolationJSON is one lint violation in the gosqlx_lint result. +type LintViolationJSON struct { + Rule string `json:"rule"` + RuleName string `json:"rule_name"` + Severity string `json:"severity"` + Message string `json:"message"` + Line int `json:"line"` + Column int `json:"column"` + Suggestion string `json:"suggestion,omitempty"` + CanAutoFix bool `json:"can_auto_fix"` +} + +// LintResult is the JSON result of gosqlx_lint. +type LintResult struct { + Success bool `json:"success"` + ViolationCount int `json:"violation_count"` + Violations []LintViolationJSON `json:"violations"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// parseLintOptions decodes options_json. An empty string (or NULL) means all +// defaults. +func parseLintOptions(raw string) (LintOptions, error) { + var opts LintOptions + if strings.TrimSpace(raw) == "" { + return opts, nil + } + dec := json.NewDecoder(strings.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&opts); err != nil { + return opts, fmt.Errorf("invalid options_json: %w", err) + } + // dec.More() reports false before a stray ']' or '}', so require EOF. + if _, err := dec.Token(); !errors.Is(err, io.EOF) { + return opts, errors.New("invalid options_json: trailing data after the options object") + } + if opts.MaxLineLength != nil && *opts.MaxLineLength <= 0 { + return opts, fmt.Errorf("invalid options_json: max_line_length must be positive, got %d", *opts.MaxLineLength) + } + return opts, nil +} + +// newLinter builds the rule set of the `gosqlx lint` CLI command +// (createLinter in cmd/gosqlx/cmd/lint.go, also mirrored by pkg/mcp's +// newFullLinter): L001-L010 with the CLI's rule configuration. Keep the three +// in sync when the CLI's default rule set changes. +func newLinter(maxLineLength int, dialect string) *linter.Linter { + l := linter.New( + // Whitespace rules (L001, L002, L003, L004, L005, L010) + whitespace.NewTrailingWhitespaceRule(), // L001 + whitespace.NewMixedIndentationRule(), // L002 + whitespace.NewConsecutiveBlankLinesRule(1), // L003 + whitespace.NewIndentationDepthRule(4, 4), // L004 + whitespace.NewLongLinesRule(maxLineLength), // L005 + whitespace.NewRedundantWhitespaceRule(), // L010 + + // Style rules (L006, L008, L009) + style.NewColumnAlignmentRule(), // L006 + style.NewCommaPlacementRule(style.CommaTrailing), // L008 + style.NewAliasingConsistencyRule(true), // L009 + + // Keyword rules (L007) + lintkeywords.NewKeywordCaseRule(lintkeywords.CaseUpper), // L007 + ) + if dialect != "" { + l.SetDialect(keywords.SQLDialect(dialect)) + } + return l +} + +// lintFor runs the CLI lint rule set over sql. +func lintFor(sql, optionsJSON string) LintResult { + fail := func(err error, code string) LintResult { + return LintResult{ + Success: false, + Violations: []LintViolationJSON{}, + Error: err.Error(), + ErrorCode: code, + } + } + + opts, err := parseLintOptions(optionsJSON) + if err != nil { + return fail(err, errCodeInvalidOptions) + } + if err := dialectError(opts.Dialect); err != nil { + return fail(err, errorCodeFor(err)) + } + maxLen := defaultLintMaxLineLength + if opts.MaxLineLength != nil { + maxLen = *opts.MaxLineLength + } + + fileResult := newLinter(maxLen, opts.Dialect).LintString(sql, "") + if fileResult.Error != nil { + return fail(fileResult.Error, "") + } + + violations := make([]LintViolationJSON, 0, len(fileResult.Violations)) + for _, v := range fileResult.Violations { + violations = append(violations, LintViolationJSON{ + Rule: v.Rule, + RuleName: v.RuleName, + Severity: string(v.Severity), + Message: v.Message, + Line: v.Location.Line, + Column: v.Location.Column, + Suggestion: v.Suggestion, + CanAutoFix: v.CanAutoFix, + }) + } + return LintResult{ + Success: true, + ViolationCount: len(violations), + Violations: violations, + } +} + +// gosqlx_lint lints a SQL string with the rule set of the `gosqlx lint` CLI +// command (L001-L010, L005 limit 100 by default). +// +// Parameters: +// - sql: a C string containing the SQL to lint. +// - options_json: NULL, "" or a JSON object with the optional fields +// "dialect" (a name from gosqlx_dialects) and "max_line_length" (positive +// integer, default 100). Unknown fields are rejected. +// +// The result is {"success": true, "violation_count": N, "violations": [...]} +// where each violation has rule, rule_name, severity, message, line, column, +// suggestion and can_auto_fix. Invalid options return {"success": false, +// "error": ..., "error_code": "INVALID_OPTIONS"}; an unknown dialect uses +// error_code "E4002". SQL that does not parse is still linted: the text and +// token rules run and no error is reported. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_lint +func gosqlx_lint(sql, options_json *C.char) *C.char { + return toJSON(lintFor(C.GoString(sql), C.GoString(options_json))) +} + +// --------------------------------------------------------------------------- +// gosqlx_security_scan +// --------------------------------------------------------------------------- + +// SecurityScanResult is the JSON result of gosqlx_security_scan. +type SecurityScanResult struct { + Success bool `json:"success"` + IsClean bool `json:"is_clean"` + HasCritical bool `json:"has_critical"` + HasHigh bool `json:"has_high"` + TotalCount int `json:"total_count"` + CriticalCount int `json:"critical_count"` + HighCount int `json:"high_count"` + MediumCount int `json:"medium_count"` + LowCount int `json:"low_count"` + Findings []security.Finding `json:"findings"` +} + +// securityScanFor scans sql with security.Scanner.ScanSQL, the raw-text scan +// the MCP security_scan tool uses. It needs no parse, so injection payloads +// that are not valid SQL are still scanned. +func securityScanFor(sql string) SecurityScanResult { + result := security.NewScanner().ScanSQL(sql) + findings := result.Findings + if findings == nil { + findings = []security.Finding{} + } + return SecurityScanResult{ + Success: true, + IsClean: result.IsClean(), + HasCritical: result.HasCritical(), + HasHigh: result.HasHighOrAbove(), + TotalCount: result.TotalCount, + CriticalCount: result.CriticalCount, + HighCount: result.HighCount, + MediumCount: result.MediumCount, + LowCount: result.LowCount, + Findings: findings, + } +} + +// gosqlx_security_scan scans a SQL string for SQL injection patterns +// (tautologies, comment bypass, stacked queries, UNION injection, time-based +// and out-of-band techniques, dangerous functions) using pkg/sql/security. +// +// The result is {"success": true, "is_clean": ..., "has_critical": ..., +// "has_high": ..., "total_count": N, "critical_count": ..., "high_count": ..., +// "medium_count": ..., "low_count": ..., "findings": [...]} where each finding +// has severity, pattern, description, risk and optional line, column, sql and +// suggestion. Empty input is clean. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_security_scan +func gosqlx_security_scan(sql *C.char) *C.char { + return toJSON(securityScanFor(C.GoString(sql))) +} + +// --------------------------------------------------------------------------- +// gosqlx_transpile +// --------------------------------------------------------------------------- + +// TranspileResult is the JSON result of gosqlx_transpile. +type TranspileResult struct { + Success bool `json:"success"` + SQL string `json:"sql,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` +} + +// transpileFor converts sql between two dialects with gosqlx.Transpile. Both +// dialects are required and must be names from keywords.DialectNames. +func transpileFor(sql, from, to string) TranspileResult { + for _, d := range [...]struct{ role, name string }{{"source", from}, {"target", to}} { + if d.name == "" { + return TranspileResult{ + Error: fmt.Sprintf("%s dialect is required; valid dialects: %s", + d.role, strings.Join(keywords.DialectNames(), ", ")), + ErrorCode: errCodeUnsupportedDialect, + } + } + if err := dialectError(d.name); err != nil { + return TranspileResult{Error: err.Error(), ErrorCode: errorCodeFor(err)} + } + } + out, err := gosqlx.Transpile(sql, keywords.SQLDialect(from), keywords.SQLDialect(to)) + if err != nil { + return TranspileResult{Error: err.Error()} + } + return TranspileResult{Success: true, SQL: out} +} + +// gosqlx_transpile converts SQL from one dialect to another with +// gosqlx.Transpile. Dialect pairs without dedicated rewrite rules are parsed +// with the source dialect and re-rendered unchanged. +// +// Parameters: +// - sql: a C string containing the SQL to convert. +// - from: the source dialect (a name from gosqlx_dialects; required). +// - to: the target dialect (a name from gosqlx_dialects; required). +// +// The result is {"success": true, "sql": "..."} or {"success": false, +// "error": ...}; a missing or unknown dialect sets "error_code": "E4002". +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_transpile +func gosqlx_transpile(sql, from, to *C.char) *C.char { + return toJSON(transpileFor(C.GoString(sql), C.GoString(from), C.GoString(to))) +} + +// --------------------------------------------------------------------------- +// gosqlx_dialects +// --------------------------------------------------------------------------- + +// DialectsResult is the JSON result of gosqlx_dialects. +type DialectsResult struct { + Dialects []string `json:"dialects"` +} + +// gosqlx_dialects returns {"dialects": [...]}, the dialect names accepted by +// every *_dialect export, gosqlx_lint and gosqlx_transpile, in the stable order +// of keywords.DialectNames. +// +// Returns a C string with the JSON result. The caller must free the returned +// pointer with gosqlx_free. +// +//export gosqlx_dialects +func gosqlx_dialects() *C.char { + return toJSON(DialectsResult{Dialects: keywords.DialectNames()}) +} diff --git a/pkg/cbinding/cbinding_ext_test.go b/pkg/cbinding/cbinding_ext_test.go new file mode 100644 index 00000000..72463f5d --- /dev/null +++ b/pkg/cbinding/cbinding_ext_test.go @@ -0,0 +1,503 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "sync" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func strPtr(s string) *string { return &s } + +func decodeMap(t *testing.T, raw string) map[string]interface{} { + t.Helper() + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("result is not a JSON object: %v\n%s", err, raw) + } + return m +} + +// errorOf returns the error message of a decoded result, or "" when none. +func errorOf(m map[string]interface{}) string { + s, _ := m["error"].(string) + return s +} + +// succeeded reports whether a decoded result of export e describes success. +func succeeded(e export, m map[string]interface{}) bool { + switch e { + case exportParse, exportFormat: + ok, _ := m["success"].(bool) + return ok + case exportValidate: + ok, _ := m["valid"].(bool) + return ok + default: + return errorOf(m) == "" + } +} + +// TestDialectVariants_MatchLegacyWithoutDialect pins ABI stability: a NULL or +// empty dialect must produce byte-identical JSON to the original export. +func TestDialectVariants_MatchLegacyWithoutDialect(t *testing.T) { + inputs := []*string{ + nil, + strPtr(""), + strPtr(" "), + strPtr("SELECT id, UPPER(name) FROM public.users u WHERE u.id = 1"), + strPtr("INSERT INTO t (a) VALUES (1); DELETE FROM t WHERE a = 2"), + strPtr("SELECT * FROM"), + strPtr("SELEC 1"), + } + for _, e := range allExports { + for _, in := range inputs { + name := "NULL" + if in != nil { + name = fmt.Sprintf("%q", *in) + } + t.Run(e.String()+"/"+name, func(t *testing.T) { + legacy := callLegacyRaw(e, in) + if got := callDialectRaw(e, in, nil); got != legacy { + t.Errorf("NULL dialect differs from legacy export:\n got: %s\nwant: %s", got, legacy) + } + if got := callDialectRaw(e, in, strPtr("")); got != legacy { + t.Errorf("empty dialect differs from legacy export:\n got: %s\nwant: %s", got, legacy) + } + if strings.Contains(legacy, "error_code") { + t.Errorf("legacy export must not emit error_code: %s", legacy) + } + }) + } + } +} + +func TestDialectVariants_EveryDialectParsesPortableSQL(t *testing.T) { + const sql = "SELECT id, COUNT(*) FROM orders WHERE total > 10 GROUP BY id" + for _, d := range keywords.DialectNames() { + for _, e := range allExports { + t.Run(d+"/"+e.String(), func(t *testing.T) { + m := decodeMap(t, callDialectRaw(e, strPtr(sql), strPtr(d))) + if !succeeded(e, m) { + t.Fatalf("dialect %s rejected portable SQL: %v", d, m) + } + }) + } + } +} + +func TestDialectVariants_DialectChangesParsing(t *testing.T) { + // SQL Server TOP and bracketed identifiers need the sqlserver dialect. + const sql = "SELECT TOP 5 [id] FROM [dbo].[users]" + m := decodeMap(t, callDialectRaw(exportParse, strPtr(sql), strPtr("sqlserver"))) + if ok, _ := m["success"].(bool); !ok { + t.Fatalf("sqlserver parse failed: %v", m) + } + if n, _ := m["statement_count"].(float64); n != 1 { + t.Errorf("statement_count = %v, want 1", m["statement_count"]) + } + + tables := decodeMap(t, callDialectRaw(exportExtractTables, strPtr(sql), strPtr("sqlserver"))) + list, _ := tables["tables"].([]interface{}) + if len(list) != 1 || !strings.Contains(fmt.Sprint(list[0]), "users") { + t.Errorf("sqlserver tables = %v, want [users]", tables["tables"]) + } + + v := decodeMap(t, callDialectRaw(exportValidate, strPtr(sql), strPtr("sqlserver"))) + if ok, _ := v["valid"].(bool); !ok { + t.Errorf("sqlserver validate = %v, want valid", v) + } + + f := decodeMap(t, callDialectRaw(exportFormat, strPtr(sql), strPtr("sqlserver"))) + if ok, _ := f["success"].(bool); !ok { + t.Fatalf("sqlserver format failed: %v", f) + } + if out, _ := f["formatted"].(string); !strings.Contains(strings.ToUpper(out), "TOP") { + t.Errorf("formatted output lost TOP: %q", out) + } +} + +// TestFormatDialect_GenericMatchesLegacy guards formatFor's copy of the +// gosqlx.Format option mapping: the generic dialect must render portable SQL +// exactly like gosqlx_format. +func TestFormatDialect_GenericMatchesLegacy(t *testing.T) { + for _, sql := range []string{ + "select id, name from users where id = 1 order by name", + "WITH c AS (SELECT a FROM t) SELECT a, COUNT(*) FROM c GROUP BY a HAVING COUNT(*) > 1", + "insert into t (a, b) values (1, 'x'); update t set a = 2 where b = 'x'", + } { + legacy := callLegacyRaw(exportFormat, strPtr(sql)) + if got := callDialectRaw(exportFormat, strPtr(sql), strPtr("generic")); got != legacy { + t.Errorf("generic format differs from gosqlx_format for %q:\n got: %s\nwant: %s", sql, got, legacy) + } + } +} + +func TestDialectVariants_UnknownDialect(t *testing.T) { + for _, e := range allExports { + for _, d := range []string{"fakesql", "MySQL", " postgresql"} { + t.Run(e.String()+"/"+d, func(t *testing.T) { + m := decodeMap(t, callDialectRaw(e, strPtr("SELECT 1"), strPtr(d))) + if succeeded(e, m) { + t.Fatalf("unknown dialect %q accepted: %v", d, m) + } + if m["error_code"] != "E4002" { + t.Errorf("error_code = %v, want E4002", m["error_code"]) + } + msg := errorOf(m) + if !strings.Contains(msg, fmt.Sprintf("%q", d)) || !strings.Contains(msg, "valid dialects: generic") { + t.Errorf("error should name the dialect and list valid ones, got %q", msg) + } + }) + } + } +} + +func TestDialectVariants_EmptyAndInvalidInput(t *testing.T) { + for _, e := range allExports { + for _, in := range []*string{nil, strPtr(""), strPtr("SELECT * FROM")} { + m := decodeMap(t, callDialectRaw(e, in, strPtr("postgresql"))) + if succeeded(e, m) { + t.Errorf("%s accepted empty/invalid input %v: %v", e, in, m) + } + if errorOf(m) == "" { + t.Errorf("%s: missing error message: %v", e, m) + } + if _, ok := m["error_code"]; ok { + t.Errorf("%s: parse errors must not carry error_code: %v", e, m) + } + } + } +} + +func TestDialectVariants_ParseErrorPosition(t *testing.T) { + m := decodeMap(t, callDialectRaw(exportParse, strPtr("SELECT *\nFROM users WHERE"), strPtr("mysql"))) + if ok, _ := m["success"].(bool); ok { + t.Fatalf("expected failure: %v", m) + } + if line, _ := m["error_line"].(float64); line < 1 { + t.Errorf("error_line = %v, want >= 1 (%s)", m["error_line"], errorOf(m)) + } +} + +// --------------------------------------------------------------------------- +// gosqlx_lint +// --------------------------------------------------------------------------- + +func decodeLint(t *testing.T, raw string) LintResult { + t.Helper() + var r LintResult + if err := json.Unmarshal([]byte(raw), &r); err != nil { + t.Fatalf("lint result is not valid JSON: %v\n%s", err, raw) + } + return r +} + +func hasRule(r LintResult, id string) bool { + for _, v := range r.Violations { + if v.Rule == id { + return true + } + } + return false +} + +func TestLint_DefaultRuleSet(t *testing.T) { + r := decodeLint(t, lintRaw("select id from users ", nil)) + if !r.Success { + t.Fatalf("lint failed: %+v", r) + } + if r.ViolationCount != len(r.Violations) { + t.Errorf("violation_count %d != len(violations) %d", r.ViolationCount, len(r.Violations)) + } + for _, id := range []string{"L001", "L007"} { + if !hasRule(r, id) { + t.Errorf("expected %s violation, got %+v", id, r.Violations) + } + } + for _, v := range r.Violations { + if v.Line < 1 || v.Severity == "" || v.RuleName == "" || v.Message == "" { + t.Errorf("incomplete violation: %+v", v) + } + // Only the CLI rule set (L001-L010) runs. + if v.Rule > "L010" { + t.Errorf("rule %s is outside the `gosqlx lint` rule set", v.Rule) + } + } +} + +func TestLint_CleanSQLAndEmptyOptions(t *testing.T) { + for _, opts := range []*string{nil, strPtr(""), strPtr(" "), strPtr("{}"), strPtr(`{"dialect": ""}`)} { + r := decodeLint(t, lintRaw("SELECT id FROM users", opts)) + if !r.Success || r.ViolationCount != 0 || r.Violations == nil { + t.Errorf("options %v: want success with empty violations array, got %+v", opts, r) + } + } +} + +func TestLint_EmptyInput(t *testing.T) { + raw := lintRaw("", nil) + r := decodeLint(t, raw) + if !r.Success || r.ViolationCount != 0 { + t.Errorf("empty input: want success with no violations, got %+v", r) + } + if !strings.Contains(raw, `"violations":[]`) { + t.Errorf("violations must encode as [], got %s", raw) + } +} + +func TestLint_InvalidSQLStillLinted(t *testing.T) { + r := decodeLint(t, lintRaw("select * from ", nil)) + if !r.Success { + t.Fatalf("unparseable SQL must still lint: %+v", r) + } + if !hasRule(r, "L001") { + t.Errorf("text rules should still run, got %+v", r.Violations) + } +} + +func TestLint_MaxLineLength(t *testing.T) { + sql := "SELECT " + strings.Repeat("a", 60) + " FROM t" + if r := decodeLint(t, lintRaw(sql, nil)); hasRule(r, "L005") { + t.Errorf("default limit 100 should not flag a %d-char line: %+v", len(sql), r.Violations) + } + r := decodeLint(t, lintRaw(sql, strPtr(`{"max_line_length": 40}`))) + if !r.Success || !hasRule(r, "L005") { + t.Errorf("max_line_length 40 should flag L005: %+v", r) + } +} + +func TestLint_Dialect(t *testing.T) { + r := decodeLint(t, lintRaw("SELECT TOP 5 [id] FROM [users]", strPtr(`{"dialect":"sqlserver"}`))) + if !r.Success { + t.Fatalf("sqlserver lint failed: %+v", r) + } +} + +func TestLint_ErrorPaths(t *testing.T) { + cases := []struct { + name, opts, code, contains string + }{ + {"malformed", `{"dialect":`, "INVALID_OPTIONS", "invalid options_json"}, + {"not an object", `[1]`, "INVALID_OPTIONS", "invalid options_json"}, + {"unknown field", `{"max_length": 10}`, "INVALID_OPTIONS", "max_length"}, + {"wrong type", `{"max_line_length": "10"}`, "INVALID_OPTIONS", "invalid options_json"}, + {"zero length", `{"max_line_length": 0}`, "INVALID_OPTIONS", "must be positive"}, + {"negative length", `{"max_line_length": -5}`, "INVALID_OPTIONS", "must be positive"}, + {"trailing data", `{} {}`, "INVALID_OPTIONS", "trailing data"}, + {"trailing brace", `{} }`, "INVALID_OPTIONS", "trailing data"}, + {"trailing bracket", `{"dialect":"mysql"} ]`, "INVALID_OPTIONS", "trailing data"}, + {"trailing value", `{} 1`, "INVALID_OPTIONS", "trailing data"}, + {"unknown dialect", `{"dialect":"fakesql"}`, "E4002", "valid dialects"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := lintRaw("SELECT 1", strPtr(tc.opts)) + r := decodeLint(t, raw) + if r.Success { + t.Fatalf("expected failure, got %s", raw) + } + if r.ErrorCode != tc.code { + t.Errorf("error_code = %q, want %q", r.ErrorCode, tc.code) + } + if !strings.Contains(r.Error, tc.contains) { + t.Errorf("error %q does not contain %q", r.Error, tc.contains) + } + if r.Violations == nil { + t.Errorf("violations must be [] on error, got null") + } + }) + } +} + +// --------------------------------------------------------------------------- +// gosqlx_security_scan +// --------------------------------------------------------------------------- + +func decodeScan(t *testing.T, raw string) SecurityScanResult { + t.Helper() + var r SecurityScanResult + if err := json.Unmarshal([]byte(raw), &r); err != nil { + t.Fatalf("scan result is not valid JSON: %v\n%s", err, raw) + } + return r +} + +func TestSecurityScan_DetectsInjection(t *testing.T) { + r := decodeScan(t, securityScanRaw(strPtr("SELECT * FROM users WHERE id = 1 OR 1=1 --"))) + if !r.Success || r.IsClean || r.TotalCount == 0 || len(r.Findings) != r.TotalCount { + t.Fatalf("expected findings, got %+v", r) + } + if r.CriticalCount+r.HighCount+r.MediumCount+r.LowCount != r.TotalCount { + t.Errorf("severity counts do not add up: %+v", r) + } + for _, f := range r.Findings { + if f.Severity == "" || f.Pattern == "" || f.Description == "" { + t.Errorf("incomplete finding: %+v", f) + } + } + if !r.HasHigh { + t.Errorf("tautology with comment should be at least HIGH: %+v", r) + } +} + +func TestSecurityScan_CleanAndEmpty(t *testing.T) { + for _, in := range []*string{nil, strPtr(""), strPtr("SELECT id FROM users WHERE id = $1")} { + raw := securityScanRaw(in) + r := decodeScan(t, raw) + if !r.Success || !r.IsClean || r.TotalCount != 0 || r.HasCritical || r.HasHigh { + t.Errorf("input %v: want clean result, got %s", in, raw) + } + if !strings.Contains(raw, `"findings":[]`) { + t.Errorf("findings must encode as [], got %s", raw) + } + } +} + +func TestSecurityScan_UnparseablePayload(t *testing.T) { + // The scan is text based, so payloads that do not parse are still scanned. + r := decodeScan(t, securityScanRaw(strPtr("'; DROP TABLE users; --"))) + if r.IsClean { + t.Errorf("stacked-query payload should not be clean: %+v", r) + } +} + +// --------------------------------------------------------------------------- +// gosqlx_transpile +// --------------------------------------------------------------------------- + +func decodeTranspile(t *testing.T, raw string) TranspileResult { + t.Helper() + var r TranspileResult + if err := json.Unmarshal([]byte(raw), &r); err != nil { + t.Fatalf("transpile result is not valid JSON: %v\n%s", err, raw) + } + return r +} + +func TestTranspile_MySQLToPostgres(t *testing.T) { + r := decodeTranspile(t, transpileRaw( + strPtr("CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY)"), + strPtr("mysql"), strPtr("postgresql"))) + if !r.Success { + t.Fatalf("transpile failed: %+v", r) + } + if !strings.Contains(strings.ToUpper(r.SQL), "SERIAL") || strings.Contains(strings.ToUpper(r.SQL), "AUTO_INCREMENT") { + t.Errorf("expected AUTO_INCREMENT -> SERIAL, got %q", r.SQL) + } +} + +func TestTranspile_PassthroughPair(t *testing.T) { + r := decodeTranspile(t, transpileRaw(strPtr("SELECT a FROM t"), strPtr("generic"), strPtr("snowflake"))) + if !r.Success || !strings.Contains(strings.ToUpper(r.SQL), "SELECT") { + t.Errorf("passthrough transpile failed: %+v", r) + } +} + +func TestTranspile_ErrorPaths(t *testing.T) { + cases := []struct { + name string + sql, from, to *string + code string + }{ + {"null from", strPtr("SELECT 1"), nil, strPtr("mysql"), "E4002"}, + {"empty to", strPtr("SELECT 1"), strPtr("mysql"), strPtr(""), "E4002"}, + {"unknown from", strPtr("SELECT 1"), strPtr("fakesql"), strPtr("mysql"), "E4002"}, + {"unknown to", strPtr("SELECT 1"), strPtr("mysql"), strPtr("postgres"), "E4002"}, + {"invalid sql", strPtr("SELECT * FROM"), strPtr("mysql"), strPtr("postgresql"), ""}, + {"empty sql", strPtr(""), strPtr("mysql"), strPtr("postgresql"), ""}, + {"null sql", nil, strPtr("mysql"), strPtr("postgresql"), ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := transpileRaw(tc.sql, tc.from, tc.to) + r := decodeTranspile(t, raw) + if r.Success || r.Error == "" || r.SQL != "" { + t.Fatalf("expected error without sql, got %s", raw) + } + if r.ErrorCode != tc.code { + t.Errorf("error_code = %q, want %q", r.ErrorCode, tc.code) + } + }) + } +} + +// --------------------------------------------------------------------------- +// gosqlx_dialects +// --------------------------------------------------------------------------- + +func TestDialects(t *testing.T) { + var r DialectsResult + raw := dialectsRaw() + if err := json.Unmarshal([]byte(raw), &r); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, raw) + } + if !reflect.DeepEqual(r.Dialects, keywords.DialectNames()) { + t.Errorf("dialects = %v, want %v", r.Dialects, keywords.DialectNames()) + } + for _, d := range r.Dialects { + if !keywords.IsValidDialect(d) { + t.Errorf("listed dialect %q is not accepted", d) + } + } + // Each call returns a fresh caller-owned allocation; both are freed. + if dialectsRaw() != raw { + t.Errorf("gosqlx_dialects is not stable across calls") + } +} + +// TestNewExports_Concurrent exercises every new export from many goroutines; +// run under -race (task test:cbinding) it checks the shared linter, scanner +// and parser paths are race free and every result is freed exactly once. +func TestNewExports_Concurrent(t *testing.T) { + const workers = 16 + const iterations = 25 + dialects := keywords.DialectNames() + var wg sync.WaitGroup + errs := make(chan string, workers) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + d := dialects[w%len(dialects)] + for i := 0; i < iterations; i++ { + for _, e := range allExports { + if raw := callDialectRaw(e, strPtr("SELECT a FROM t WHERE b = 1"), strPtr(d)); !json.Valid([]byte(raw)) { + errs <- fmt.Sprintf("%s/%s: invalid JSON %s", e, d, raw) + return + } + } + if raw := lintRaw("select a from t", strPtr(`{"dialect":"`+d+`"}`)); !json.Valid([]byte(raw)) { + errs <- "lint: invalid JSON " + raw + return + } + _ = securityScanRaw(strPtr("SELECT * FROM t WHERE 1=1 OR 'a'='a'")) + _ = transpileRaw(strPtr("SELECT a FROM t"), strPtr("mysql"), strPtr(d)) + _ = dialectsRaw() + } + }(w) + } + wg.Wait() + close(errs) + for e := range errs { + t.Error(e) + } +} diff --git a/pkg/cbinding/cbinding_testhelpers.go b/pkg/cbinding/cbinding_testhelpers.go index dc7b77f7..19d892dc 100644 --- a/pkg/cbinding/cbinding_testhelpers.go +++ b/pkg/cbinding/cbinding_testhelpers.go @@ -151,3 +151,132 @@ func toJSONString(v interface{}) string { defer gosqlx_free(cStr) return C.GoString(cStr) } + +// export identifies one of the original single-argument exports that has a +// *_dialect variant, so tests can call either form through one helper. +type export int + +const ( + exportParse export = iota + exportValidate + exportFormat + exportExtractTables + exportExtractColumns + exportExtractFunctions + exportExtractMetadata +) + +// allExports lists every export that has a *_dialect variant. +var allExports = []export{ + exportParse, exportValidate, exportFormat, + exportExtractTables, exportExtractColumns, exportExtractFunctions, exportExtractMetadata, +} + +func (e export) String() string { + return [...]string{"parse", "validate", "format", "extract_tables", + "extract_columns", "extract_functions", "extract_metadata"}[e] +} + +// cString converts s to a C string the caller frees with C.free; a nil s +// yields NULL. +func cString(s *string) *C.char { + if s == nil { + return nil + } + return C.CString(*s) +} + +func freeCString(p *C.char) { + if p != nil { + C.free(unsafe.Pointer(p)) + } +} + +// takeResult decodes a returned C string and frees it with gosqlx_free, as +// the ownership contract requires of every result. +func takeResult(p *C.char) string { + defer gosqlx_free(p) + return C.GoString(p) +} + +// callLegacyRaw calls the original export e and returns its raw JSON. A nil sql +// passes NULL. +func callLegacyRaw(e export, sql *string) string { + cSQL := cString(sql) + defer freeCString(cSQL) + switch e { + case exportParse: + return takeResult(gosqlx_parse(cSQL)) + case exportValidate: + return takeResult(gosqlx_validate(cSQL)) + case exportFormat: + return takeResult(gosqlx_format(cSQL)) + case exportExtractTables: + return takeResult(gosqlx_extract_tables(cSQL)) + case exportExtractColumns: + return takeResult(gosqlx_extract_columns(cSQL)) + case exportExtractFunctions: + return takeResult(gosqlx_extract_functions(cSQL)) + default: + return takeResult(gosqlx_extract_metadata(cSQL)) + } +} + +// callDialectRaw calls the *_dialect variant of e and returns its raw JSON. A +// nil sql or dialect passes NULL. +func callDialectRaw(e export, sql, dialect *string) string { + cSQL := cString(sql) + defer freeCString(cSQL) + cDialect := cString(dialect) + defer freeCString(cDialect) + switch e { + case exportParse: + return takeResult(gosqlx_parse_dialect(cSQL, cDialect)) + case exportValidate: + return takeResult(gosqlx_validate_dialect(cSQL, cDialect)) + case exportFormat: + return takeResult(gosqlx_format_dialect(cSQL, cDialect)) + case exportExtractTables: + return takeResult(gosqlx_extract_tables_dialect(cSQL, cDialect)) + case exportExtractColumns: + return takeResult(gosqlx_extract_columns_dialect(cSQL, cDialect)) + case exportExtractFunctions: + return takeResult(gosqlx_extract_functions_dialect(cSQL, cDialect)) + default: + return takeResult(gosqlx_extract_metadata_dialect(cSQL, cDialect)) + } +} + +// lintRaw calls gosqlx_lint and returns its raw JSON. A nil options passes NULL. +func lintRaw(sql string, options *string) string { + cSQL := C.CString(sql) + defer C.free(unsafe.Pointer(cSQL)) + cOpts := cString(options) + defer freeCString(cOpts) + return takeResult(gosqlx_lint(cSQL, cOpts)) +} + +// securityScanRaw calls gosqlx_security_scan and returns its raw JSON. A nil +// sql passes NULL. +func securityScanRaw(sql *string) string { + cSQL := cString(sql) + defer freeCString(cSQL) + return takeResult(gosqlx_security_scan(cSQL)) +} + +// transpileRaw calls gosqlx_transpile and returns its raw JSON. A nil argument +// passes NULL. +func transpileRaw(sql, from, to *string) string { + cSQL := cString(sql) + defer freeCString(cSQL) + cFrom := cString(from) + defer freeCString(cFrom) + cTo := cString(to) + defer freeCString(cTo) + return takeResult(gosqlx_transpile(cSQL, cFrom, cTo)) +} + +// dialectsRaw calls gosqlx_dialects and returns its raw JSON. +func dialectsRaw() string { + return takeResult(gosqlx_dialects()) +} diff --git a/python/README.md b/python/README.md index 997884d4..5347de6f 100644 --- a/python/README.md +++ b/python/README.md @@ -8,7 +8,7 @@ Powered by Go's compiled performance, PyGoSQLX is 100x+ faster than pure Python ### Step 1: Build the shared library -Requires Go 1.21+ installed. +Requires a Go toolchain matching the repository's `go.mod` (Go 1.26+) with cgo enabled. ```bash cd pkg/cbinding @@ -98,6 +98,79 @@ for qn in meta.tables_qualified: print(f"{qn.schema}.{qn.name}" if qn.schema else qn.name) ``` +### Dialects + +Every parsing function takes an optional keyword-only `dialect=` argument. +Without it (or with `dialect=None`) the default parser is used, exactly as in +earlier releases. + +```python +print(pygosqlx.dialects()) +# ['generic', 'postgresql', 'mysql', 'mariadb', 'sqlserver', 'oracle', +# 'sqlite', 'snowflake', 'bigquery', 'redshift', 'clickhouse'] + +sql = "SELECT TOP 5 [id] FROM [dbo].[users]" +pygosqlx.validate(sql) # False - not generic SQL +pygosqlx.validate(sql, dialect="sqlserver") # True +pygosqlx.extract_tables(sql, dialect="sqlserver") # ['dbo.users'] +pygosqlx.format("SELECT `id` FROM `users`", dialect="mysql") + +# An unknown dialect raises UnsupportedDialectError (also a ValueError) +pygosqlx.parse("SELECT 1", dialect="postgres") # use "postgresql" +``` + +### Linting + +`lint()` runs the same rule set as the `gosqlx lint` CLI command (L001-L010: +whitespace, indentation, line length, column alignment, keyword case, comma +placement, aliasing). SQL that does not parse is still linted by the text and +token rules. + +```python +result = pygosqlx.lint("select id from users ", max_line_length=100) +for v in result.violations: + print(v.rule, v.severity, v.line, v.column, v.message) +# L001 warning 1 21 Line has trailing whitespace +# L007 warning 1 1 Keyword 'select' should be uppercase: 'SELECT' +# ... +print(result.is_clean, result.violation_count) +``` + +### Security scanning + +`security_scan()` looks for SQL injection patterns (tautologies, comment +bypasses, stacked queries, UNION injection, time-based and out-of-band +techniques, dangerous functions). The scan works on the raw text, so payloads +that are not valid SQL are scanned too. + +```python +scan = pygosqlx.security_scan("SELECT * FROM users WHERE id = 1 OR 1=1 --") +print(scan.is_clean, scan.has_critical) # False True +for f in scan.findings: + print(f.severity, f.pattern, f.description) +``` + +### Transpiling + +```python +pygosqlx.transpile( + "CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY)", "mysql", "postgresql" +) +# 'CREATE TABLE t (id SERIAL PRIMARY KEY)' +``` + +Dedicated rewrites exist for MySQL to PostgreSQL, PostgreSQL to MySQL and +PostgreSQL to SQLite. Other pairs are parsed with the source dialect and +re-rendered without rewrites. + +### Library compatibility + +The functions and the `dialect=` argument added in 0.3.0 need a shared +library built from a matching GoSQLX checkout. With an older library, calls +without `dialect=` keep working, while `dialect=`, `lint()`, +`security_scan()`, `transpile()` and `dialects()` raise `GoSQLXError` asking +you to rebuild the library. + ### Error Handling ```python @@ -132,16 +205,23 @@ except GoSQLXError as e: | Function | Returns | Description | |---|---|---| -| `parse(sql)` | `ParseResult` | Parse SQL; raises `ParseError` on failure | -| `validate(sql)` | `bool` | Check if SQL is syntactically valid | -| `validate_detailed(sql)` | `ValidationResult` | Validate with error line/column details | -| `format(sql)` | `str` | Format/pretty-print a SQL statement | -| `extract_tables(sql)` | `list[str]` | Extract all table names | -| `extract_columns(sql)` | `list[str]` | Extract all column names | -| `extract_functions(sql)` | `list[str]` | Extract all function calls | -| `extract_metadata(sql)` | `Metadata` | Extract tables, columns, functions, and qualified names | +| `parse(sql, *, dialect=None)` | `ParseResult` | Parse SQL; raises `ParseError` on failure | +| `validate(sql, *, dialect=None)` | `bool` | Check if SQL is syntactically valid | +| `validate_detailed(sql, *, dialect=None)` | `ValidationResult` | Validate with error line/column details | +| `format(sql, *, dialect=None)` | `str` | Format/pretty-print a SQL statement | +| `extract_tables(sql, *, dialect=None)` | `list[str]` | Extract all table names | +| `extract_columns(sql, *, dialect=None)` | `list[str]` | Extract all column names | +| `extract_functions(sql, *, dialect=None)` | `list[str]` | Extract all function calls | +| `extract_metadata(sql, *, dialect=None)` | `Metadata` | Extract tables, columns, functions, and qualified names | +| `lint(sql, *, dialect=None, max_line_length=None)` | `LintResult` | Lint with the `gosqlx lint` rule set (L001-L010); `max_line_length` defaults to 100 | +| `security_scan(sql)` | `SecurityScanResult` | Scan for SQL injection patterns | +| `transpile(sql, from_dialect, to_dialect)` | `str` | Convert SQL between dialects; raises `TranspileError` on failure | +| `dialects()` | `list[str]` | Names accepted by every `dialect` argument | | `version()` | `str` | Get the GoSQLX library version | +`dialect` must be one of the names returned by `dialects()`; an unknown name +raises `UnsupportedDialectError`. + ### Data Types | Type | Fields | @@ -150,6 +230,10 @@ except GoSQLXError as e: | `ValidationResult` | `valid: bool`, `error: str?`, `error_line: int?`, `error_column: int?` | | `Metadata` | `tables`, `columns`, `functions`, `tables_qualified`, `columns_qualified` | | `QualifiedName` | `name: str`, `schema: str`, `table: str` | +| `LintResult` | `violations: list[LintViolation]`; properties `violation_count`, `is_clean` | +| `LintViolation` | `rule`, `rule_name`, `severity`, `message`, `line`, `column`, `suggestion`, `can_auto_fix` | +| `SecurityScanResult` | `findings: list[SecurityFinding]`, `total_count`, `critical_count`, `high_count`, `medium_count`, `low_count`; properties `is_clean`, `has_critical`, `has_high` | +| `SecurityFinding` | `severity`, `pattern`, `description`, `risk`, `line: int?`, `column: int?`, `sql`, `suggestion` | ### Exceptions @@ -159,6 +243,8 @@ except GoSQLXError as e: | `ParseError` | `GoSQLXError` | SQL parsing fails | | `FormatError` | `GoSQLXError` | SQL formatting fails | | `ValidationError` | `GoSQLXError` | SQL validation fails | +| `TranspileError` | `GoSQLXError` | `transpile()` cannot parse or rewrite the SQL | +| `UnsupportedDialectError` | `GoSQLXError`, `ValueError` | A `dialect` argument is not a supported dialect (`code` is `E4002`) | All exceptions have `message`, `code`, `line`, and `column` attributes. @@ -223,8 +309,10 @@ python/ py.typed # PEP 561 type hint marker lib/ # Shared library output directory (built artifacts) tests/ - test_types.py # Type and exception tests (40 tests) - test_integration.py # Integration tests with shared library (36 tests) + test_types.py # Type and exception tests (no shared library needed) + test_package_version.py # Version metadata consistency + test_integration.py # Integration tests with shared library + test_dialect_and_analysis.py # dialect=, lint, security_scan, transpile, dialects pyproject.toml # Package metadata and build config setup.py # Legacy build support ``` diff --git a/python/pygosqlx/__init__.py b/python/pygosqlx/__init__.py index a6836436..a2fa0643 100644 --- a/python/pygosqlx/__init__.py +++ b/python/pygosqlx/__init__.py @@ -39,19 +39,43 @@ metadata = pygosqlx.extract_metadata("SELECT COUNT(*) FROM users u JOIN orders o ON u.id = o.user_id") print(metadata.tables) # ['users', 'orders'] print(metadata.functions) # ['COUNT'] + + # Dialect-aware parsing (every function above accepts dialect=) + pygosqlx.parse("SELECT TOP 5 [id] FROM [users]", dialect="sqlserver") + print(pygosqlx.dialects()) # ['generic', 'postgresql', 'mysql', ...] + + # Lint with the `gosqlx lint` rule set (L001-L010) + for v in pygosqlx.lint("select id from users").violations: + print(v.rule, v.message) + + # Scan for SQL injection patterns + scan = pygosqlx.security_scan("SELECT * FROM users WHERE id = 1 OR 1=1 --") + print(scan.is_clean, scan.has_critical) + + # Convert between dialects + print(pygosqlx.transpile("CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY)", + "mysql", "postgresql")) """ from pygosqlx.core import ( + LintResult, + LintViolation, Metadata, ParseResult, QualifiedName, + SecurityFinding, + SecurityScanResult, ValidationResult, + dialects, extract_columns, extract_functions, extract_metadata, extract_tables, format, + lint, parse, + security_scan, + transpile, validate, validate_detailed, version, @@ -60,10 +84,12 @@ FormatError, GoSQLXError, ParseError, + TranspileError, + UnsupportedDialectError, ValidationError, ) -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = [ # Functions "parse", @@ -74,15 +100,25 @@ "extract_columns", "extract_functions", "extract_metadata", + "lint", + "security_scan", + "transpile", + "dialects", "version", # Data types "ParseResult", "ValidationResult", "Metadata", "QualifiedName", + "LintResult", + "LintViolation", + "SecurityScanResult", + "SecurityFinding", # Exceptions "GoSQLXError", "ParseError", "FormatError", "ValidationError", + "TranspileError", + "UnsupportedDialectError", ] diff --git a/python/pygosqlx/core.py b/python/pygosqlx/core.py index 7ca86fb2..b37b8e1f 100644 --- a/python/pygosqlx/core.py +++ b/python/pygosqlx/core.py @@ -2,7 +2,8 @@ This module loads the GoSQLX shared library (built from Go with cgo) and provides a Pythonic API for SQL parsing, validation, formatting, and -metadata extraction. +metadata extraction, plus dialect-aware variants, linting, SQL injection +scanning, and dialect transpilation. IMPORTANT: All C function return types are declared as c_void_p (not c_char_p) to prevent ctypes from auto-converting the C pointer to a Python bytes object. @@ -21,7 +22,13 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from pygosqlx.exceptions import FormatError, GoSQLXError, ParseError +from pygosqlx.exceptions import ( + FormatError, + GoSQLXError, + ParseError, + TranspileError, + UnsupportedDialectError, +) # --------------------------------------------------------------------------- @@ -77,6 +84,78 @@ class Metadata: columns_qualified: List[QualifiedName] = field(default_factory=list) +@dataclass +class LintViolation: + """A single lint rule violation reported by :func:`lint`.""" + + rule: str + rule_name: str + severity: str + message: str + line: int + column: int + suggestion: str = "" + can_auto_fix: bool = False + + +@dataclass +class LintResult: + """Result of linting SQL with :func:`lint`.""" + + violations: List[LintViolation] = field(default_factory=list) + + @property + def violation_count(self) -> int: + """Number of violations found.""" + return len(self.violations) + + @property + def is_clean(self) -> bool: + """True when no rule reported a violation.""" + return not self.violations + + +@dataclass +class SecurityFinding: + """A potential SQL injection pattern reported by :func:`security_scan`.""" + + severity: str + pattern: str + description: str + risk: str = "" + line: Optional[int] = None + column: Optional[int] = None + sql: str = "" + suggestion: str = "" + + +@dataclass +class SecurityScanResult: + """Result of scanning SQL with :func:`security_scan`.""" + + findings: List[SecurityFinding] = field(default_factory=list) + total_count: int = 0 + critical_count: int = 0 + high_count: int = 0 + medium_count: int = 0 + low_count: int = 0 + + @property + def is_clean(self) -> bool: + """True when no injection pattern was found.""" + return self.total_count == 0 + + @property + def has_critical(self) -> bool: + """True when at least one CRITICAL finding was reported.""" + return self.critical_count > 0 + + @property + def has_high(self) -> bool: + """True when at least one HIGH or CRITICAL finding was reported.""" + return self.critical_count + self.high_count > 0 + + # --------------------------------------------------------------------------- # Library loading # --------------------------------------------------------------------------- @@ -84,6 +163,20 @@ class Metadata: _lib = None _lib_lock = threading.Lock() +# Original single-argument exports mapped to their dialect-aware variants. +_DIALECT_EXPORTS = { + "gosqlx_parse": "gosqlx_parse_dialect", + "gosqlx_validate": "gosqlx_validate_dialect", + "gosqlx_format": "gosqlx_format_dialect", + "gosqlx_extract_tables": "gosqlx_extract_tables_dialect", + "gosqlx_extract_columns": "gosqlx_extract_columns_dialect", + "gosqlx_extract_functions": "gosqlx_extract_functions_dialect", + "gosqlx_extract_metadata": "gosqlx_extract_metadata_dialect", +} + +# error_code the library reports for an unknown dialect name. +_ERR_UNSUPPORTED_DIALECT = "E4002" + def _get_lib_path() -> str: """Find the shared library path. @@ -162,6 +255,23 @@ def _load_library() -> ctypes.CDLL: lib, "gosqlx_extract_metadata", [ctypes.c_char_p], ctypes.c_void_p ) + # --- Dialect-aware variants and analysis exports (libgosqlx for 0.3.0) --- + for name in _DIALECT_EXPORTS.values(): + _bind_optional( + lib, name, [ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p + ) + _bind_optional( + lib, "gosqlx_lint", [ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p + ) + _bind_optional(lib, "gosqlx_security_scan", [ctypes.c_char_p], ctypes.c_void_p) + _bind_optional( + lib, + "gosqlx_transpile", + [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p], + ctypes.c_void_p, + ) + _bind_optional(lib, "gosqlx_dialects", [], ctypes.c_void_p) + return lib @@ -232,6 +342,52 @@ def _has_func(name: str) -> bool: return False +def _require_func(name: str) -> None: + """Raise GoSQLXError when the loaded library predates the export ``name``.""" + if not _has_func(name): + raise GoSQLXError( + f"{name} not available in this library build. " + "Rebuild the shared library with the latest cbinding." + ) + + +def _check_dialect_arg(dialect: Optional[str], arg: str = "dialect") -> None: + if dialect is not None and not isinstance(dialect, str): + raise TypeError(f"{arg} must be a str or None, not {type(dialect).__name__}") + + +def _check_dialect_error(data: Dict[str, Any]) -> None: + """Raise UnsupportedDialectError when the library rejected the dialect.""" + if data.get("error_code") == _ERR_UNSUPPORTED_DIALECT: + raise UnsupportedDialectError( + message=data.get("error", "unsupported dialect"), + code=_ERR_UNSUPPORTED_DIALECT, + ) + + +def _call_sql(name: str, sql: str, dialect: Optional[str]) -> Dict[str, Any]: + """Call a SQL export, or its ``_dialect`` variant when a dialect is given. + + Without a dialect the original export is used, so these calls keep working + against libraries built before the dialect-aware exports existed. With a + dialect, an older library raises GoSQLXError, and an unknown dialect raises + UnsupportedDialectError. + """ + _check_dialect_arg(dialect) + if dialect is None: + _require_func(name) + return _call_and_decode(getattr(_get_lib(), name), sql.encode("utf-8")) + dialect_name = _DIALECT_EXPORTS[name] + _require_func(dialect_name) + data = _call_and_decode( + getattr(_get_lib(), dialect_name), + sql.encode("utf-8"), + dialect.encode("utf-8"), + ) + _check_dialect_error(data) + return data + + def _parse_error_from_json(data: Dict[str, Any]) -> ParseError: """Create a ParseError from a JSON error response.""" error_msg = data.get("error", "Unknown parse error") @@ -281,21 +437,25 @@ def _parse_qualified_names(raw_list: List[Dict[str, str]]) -> List[QualifiedName # --------------------------------------------------------------------------- -def parse(sql: str) -> ParseResult: +def parse(sql: str, *, dialect: Optional[str] = None) -> ParseResult: """Parse a SQL statement and return the result. Args: sql: The SQL string to parse. + dialect: Optional SQL dialect name (see :func:`dialects`), e.g. + ``"postgresql"`` or ``"sqlserver"``. ``None`` uses the default + parser. Returns: ParseResult with statement_count and statement_types. Raises: ParseError: If the SQL is syntactically invalid. - GoSQLXError: If the shared library is not available or returns NULL. + UnsupportedDialectError: If ``dialect`` is not a supported dialect. + GoSQLXError: If the shared library is not available, returns NULL, + or (with ``dialect``) predates dialect support. """ - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_parse, sql.encode("utf-8")) + data = _call_sql("gosqlx_parse", sql, dialect) _check_parse_error(data) @@ -305,31 +465,37 @@ def parse(sql: str) -> ParseResult: ) -def validate(sql: str) -> bool: +def validate(sql: str, *, dialect: Optional[str] = None) -> bool: """Validate SQL syntax. Args: sql: The SQL string to validate. + dialect: Optional SQL dialect name (see :func:`dialects`). Returns: True if the SQL is syntactically valid, False otherwise. + + Raises: + UnsupportedDialectError: If ``dialect`` is not a supported dialect. """ - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_validate, sql.encode("utf-8")) + data = _call_sql("gosqlx_validate", sql, dialect) return data.get("valid", False) -def validate_detailed(sql: str) -> ValidationResult: +def validate_detailed(sql: str, *, dialect: Optional[str] = None) -> ValidationResult: """Validate SQL syntax with detailed error information. Args: sql: The SQL string to validate. + dialect: Optional SQL dialect name (see :func:`dialects`). Returns: ValidationResult with valid flag and optional error details. + + Raises: + UnsupportedDialectError: If ``dialect`` is not a supported dialect. """ - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_validate, sql.encode("utf-8")) + data = _call_sql("gosqlx_validate", sql, dialect) return ValidationResult( valid=data.get("valid", False), error=data.get("error") or None, @@ -338,26 +504,23 @@ def validate_detailed(sql: str) -> ValidationResult: ) -def format(sql: str) -> str: +def format(sql: str, *, dialect: Optional[str] = None) -> str: """Format a SQL statement for readability. Args: sql: The SQL string to format. + dialect: Optional SQL dialect name (see :func:`dialects`). The SQL is + parsed with this dialect and rendered for it. Returns: The formatted SQL string. Raises: FormatError: If the SQL cannot be formatted (e.g., invalid syntax). + UnsupportedDialectError: If ``dialect`` is not a supported dialect. GoSQLXError: If the gosqlx_format function is not available. """ - if not _has_func("gosqlx_format"): - raise GoSQLXError( - "gosqlx_format not available in this library build. " - "Rebuild the shared library with the latest cbinding." - ) - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_format, sql.encode("utf-8")) + data = _call_sql("gosqlx_format", sql, dialect) if data.get("error"): raise FormatError(message=data["error"]) @@ -365,79 +528,72 @@ def format(sql: str) -> str: return data.get("formatted", data.get("result", sql)) -def extract_tables(sql: str) -> List[str]: +def extract_tables(sql: str, *, dialect: Optional[str] = None) -> List[str]: """Extract table names from a SQL statement. Args: sql: The SQL string to analyze. + dialect: Optional SQL dialect name (see :func:`dialects`). Returns: List of table names referenced in the SQL. Raises: ParseError: If the SQL is syntactically invalid. + UnsupportedDialectError: If ``dialect`` is not a supported dialect. """ - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_extract_tables, sql.encode("utf-8")) + data = _call_sql("gosqlx_extract_tables", sql, dialect) _check_parse_error(data) return data.get("tables", []) -def extract_columns(sql: str) -> List[str]: +def extract_columns(sql: str, *, dialect: Optional[str] = None) -> List[str]: """Extract column names from a SQL statement. Args: sql: The SQL string to analyze. + dialect: Optional SQL dialect name (see :func:`dialects`). Returns: List of column names referenced in the SQL. Raises: ParseError: If the SQL is syntactically invalid. + UnsupportedDialectError: If ``dialect`` is not a supported dialect. GoSQLXError: If the gosqlx_extract_columns function is not available. """ - if not _has_func("gosqlx_extract_columns"): - raise GoSQLXError( - "gosqlx_extract_columns not available in this library build. " - "Rebuild the shared library with the latest cbinding." - ) - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_extract_columns, sql.encode("utf-8")) + data = _call_sql("gosqlx_extract_columns", sql, dialect) _check_parse_error(data) return data.get("columns", []) -def extract_functions(sql: str) -> List[str]: +def extract_functions(sql: str, *, dialect: Optional[str] = None) -> List[str]: """Extract function names from a SQL statement. Args: sql: The SQL string to analyze. + dialect: Optional SQL dialect name (see :func:`dialects`). Returns: List of function names used in the SQL. Raises: ParseError: If the SQL is syntactically invalid. + UnsupportedDialectError: If ``dialect`` is not a supported dialect. GoSQLXError: If the gosqlx_extract_functions function is not available. """ - if not _has_func("gosqlx_extract_functions"): - raise GoSQLXError( - "gosqlx_extract_functions not available in this library build. " - "Rebuild the shared library with the latest cbinding." - ) - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_extract_functions, sql.encode("utf-8")) + data = _call_sql("gosqlx_extract_functions", sql, dialect) _check_parse_error(data) return data.get("functions", []) -def extract_metadata(sql: str) -> Metadata: +def extract_metadata(sql: str, *, dialect: Optional[str] = None) -> Metadata: """Extract comprehensive metadata from a SQL statement. This returns tables, columns, functions, and their qualified variants @@ -445,21 +601,17 @@ def extract_metadata(sql: str) -> Metadata: Args: sql: The SQL string to analyze. + dialect: Optional SQL dialect name (see :func:`dialects`). Returns: Metadata object containing all extracted information. Raises: ParseError: If the SQL is syntactically invalid. + UnsupportedDialectError: If ``dialect`` is not a supported dialect. GoSQLXError: If the gosqlx_extract_metadata function is not available. """ - if not _has_func("gosqlx_extract_metadata"): - raise GoSQLXError( - "gosqlx_extract_metadata not available in this library build. " - "Rebuild the shared library with the latest cbinding." - ) - lib = _get_lib() - data = _call_and_decode(lib.gosqlx_extract_metadata, sql.encode("utf-8")) + data = _call_sql("gosqlx_extract_metadata", sql, dialect) _check_parse_error(data) @@ -476,6 +628,179 @@ def extract_metadata(sql: str) -> Metadata: ) +def lint( + sql: str, + *, + dialect: Optional[str] = None, + max_line_length: Optional[int] = None, +) -> LintResult: + """Lint SQL with the rule set of the ``gosqlx lint`` CLI command. + + The rules are L001-L010 (whitespace, indentation, line length, column + alignment, keyword case, comma placement, aliasing). SQL that does not + parse is still linted by the text and token rules. + + Args: + sql: The SQL string to lint. + dialect: Optional SQL dialect name (see :func:`dialects`). + max_line_length: Maximum line length for rule L005 (default 100, + like ``gosqlx lint --max-length``). Must be a positive int. + + Returns: + LintResult with the list of violations. + + Raises: + ValueError: If ``max_line_length`` is not a positive int. + UnsupportedDialectError: If ``dialect`` is not a supported dialect. + GoSQLXError: If the library predates gosqlx_lint or linting fails. + """ + _check_dialect_arg(dialect) + options: Dict[str, Any] = {} + if dialect is not None: + options["dialect"] = dialect + if max_line_length is not None: + if ( + isinstance(max_line_length, bool) + or not isinstance(max_line_length, int) + or max_line_length <= 0 + ): + raise ValueError( + f"max_line_length must be a positive int, got {max_line_length!r}" + ) + options["max_line_length"] = max_line_length + + _require_func("gosqlx_lint") + lib = _get_lib() + data = _call_and_decode( + lib.gosqlx_lint, sql.encode("utf-8"), json.dumps(options).encode("utf-8") + ) + _check_dialect_error(data) + if not data.get("success", False): + raise GoSQLXError( + message=data.get("error", "lint failed"), + code=data.get("error_code") or None, + ) + + violations = [ + LintViolation( + rule=v.get("rule", ""), + rule_name=v.get("rule_name", ""), + severity=v.get("severity", ""), + message=v.get("message", ""), + line=v.get("line", 0), + column=v.get("column", 0), + suggestion=v.get("suggestion", ""), + can_auto_fix=bool(v.get("can_auto_fix", False)), + ) + for v in data.get("violations") or [] + if isinstance(v, dict) + ] + return LintResult(violations=violations) + + +def security_scan(sql: str) -> SecurityScanResult: + """Scan SQL for SQL injection patterns. + + Detects tautologies (``OR 1=1``), comment bypasses, stacked queries, + UNION-based injection, time-based and out-of-band techniques, and + dangerous functions. The scan works on the raw text, so payloads that are + not valid SQL are scanned too; empty input is clean. + + Args: + sql: The SQL string to scan. + + Returns: + SecurityScanResult with findings and per-severity counts. + + Raises: + GoSQLXError: If the library predates gosqlx_security_scan. + """ + _require_func("gosqlx_security_scan") + lib = _get_lib() + data = _call_and_decode(lib.gosqlx_security_scan, sql.encode("utf-8")) + if data.get("error"): + raise GoSQLXError(message=data["error"]) + + findings = [ + SecurityFinding( + severity=f.get("severity", ""), + pattern=f.get("pattern", ""), + description=f.get("description", ""), + risk=f.get("risk", ""), + line=f.get("line"), + column=f.get("column"), + sql=f.get("sql", ""), + suggestion=f.get("suggestion", ""), + ) + for f in data.get("findings") or [] + if isinstance(f, dict) + ] + return SecurityScanResult( + findings=findings, + total_count=data.get("total_count", len(findings)), + critical_count=data.get("critical_count", 0), + high_count=data.get("high_count", 0), + medium_count=data.get("medium_count", 0), + low_count=data.get("low_count", 0), + ) + + +def transpile(sql: str, from_dialect: str, to_dialect: str) -> str: + """Convert SQL from one dialect to another. + + Dedicated rewrites exist for MySQL -> PostgreSQL, PostgreSQL -> MySQL and + PostgreSQL -> SQLite (e.g. ``AUTO_INCREMENT`` <-> ``SERIAL``); other pairs + are parsed with the source dialect and re-rendered without rewrites. + + Args: + sql: The SQL string to convert. + from_dialect: Source dialect name (see :func:`dialects`). + to_dialect: Target dialect name (see :func:`dialects`). + + Returns: + The transpiled SQL. + + Raises: + UnsupportedDialectError: If either dialect is missing or unsupported. + TranspileError: If the SQL cannot be parsed or rewritten. + GoSQLXError: If the library predates gosqlx_transpile. + """ + _check_dialect_arg(from_dialect, "from_dialect") + _check_dialect_arg(to_dialect, "to_dialect") + for value, arg in ((from_dialect, "from_dialect"), (to_dialect, "to_dialect")): + if not value: + raise UnsupportedDialectError( + message=f"{arg} is required", code=_ERR_UNSUPPORTED_DIALECT + ) + _require_func("gosqlx_transpile") + lib = _get_lib() + data = _call_and_decode( + lib.gosqlx_transpile, + sql.encode("utf-8"), + (from_dialect or "").encode("utf-8"), + (to_dialect or "").encode("utf-8"), + ) + _check_dialect_error(data) + if not data.get("success", False): + raise TranspileError(message=data.get("error", "transpile failed")) + return data.get("sql", "") + + +def dialects() -> List[str]: + """List the SQL dialect names accepted by every ``dialect`` argument. + + Returns: + Dialect names such as ``["generic", "postgresql", "mysql", ...]``. + + Raises: + GoSQLXError: If the library predates gosqlx_dialects. + """ + _require_func("gosqlx_dialects") + lib = _get_lib() + data = _call_and_decode(lib.gosqlx_dialects) + return list(data.get("dialects", [])) + + def version() -> str: """Get the GoSQLX library version. diff --git a/python/pygosqlx/exceptions.py b/python/pygosqlx/exceptions.py index 70b20f4f..d779485e 100644 --- a/python/pygosqlx/exceptions.py +++ b/python/pygosqlx/exceptions.py @@ -50,3 +50,20 @@ class ValidationError(GoSQLXError): """Raised when SQL validation fails.""" pass + + +class UnsupportedDialectError(GoSQLXError, ValueError): + """Raised when a ``dialect`` argument is not a supported SQL dialect name. + + Also a :class:`ValueError`, since it signals a bad argument rather than bad + SQL. ``code`` is ``"E4002"``; :func:`pygosqlx.dialects` lists the valid + names. + """ + + pass + + +class TranspileError(GoSQLXError): + """Raised when SQL cannot be transpiled from one dialect to another.""" + + pass diff --git a/python/pyproject.toml b/python/pyproject.toml index 7e27bb89..6457a5a4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pygosqlx" -version = "0.2.0" +version = "0.3.0" description = "Python bindings for GoSQLX - High-performance SQL parser" readme = "README.md" requires-python = ">=3.8" diff --git a/python/setup.py b/python/setup.py index 4fae70c3..b4dfb07d 100644 --- a/python/setup.py +++ b/python/setup.py @@ -11,7 +11,7 @@ setup( name="pygosqlx", - version="0.2.0", + version="0.3.0", description="Python bindings for GoSQLX - High-performance SQL parser", long_description=long_description, long_description_content_type="text/markdown", diff --git a/python/tests/test_dialect_and_analysis.py b/python/tests/test_dialect_and_analysis.py new file mode 100644 index 00000000..fc208a30 --- /dev/null +++ b/python/tests/test_dialect_and_analysis.py @@ -0,0 +1,442 @@ +"""Tests for the pygosqlx 0.3.0 API: dialect=, lint(), security_scan(), +transpile() and dialects(). + +Tests in the ``TestOlderLibrary`` and ``TestArgumentValidation`` classes run +without the shared library. The rest exercise the real library and are skipped +when it is not built (run: cd pkg/cbinding && ./build.sh). +""" + +import os +import platform +import sys +import threading + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pygosqlx +from pygosqlx import core +from pygosqlx import ( + GoSQLXError, + FormatError, + LintResult, + LintViolation, + Metadata, + ParseError, + ParseResult, + SecurityFinding, + SecurityScanResult, + TranspileError, + UnsupportedDialectError, + ValidationResult, +) + + +def _lib_available() -> bool: + env_path = os.environ.get("GOSQLX_LIB_PATH") + if env_path and os.path.exists(env_path): + return True + ext = {"linux": "so", "darwin": "dylib", "windows": "dll"}.get( + platform.system().lower() + ) + if ext is None: + return False + lib_path = os.path.join( + os.path.dirname(__file__), "..", "pygosqlx", "lib", f"libgosqlx.{ext}" + ) + return os.path.exists(lib_path) + + +requires_lib = pytest.mark.skipif( + not _lib_available(), + reason="GoSQLX shared library not built (run: cd pkg/cbinding && ./build.sh)", +) + +EXPECTED_DIALECTS = [ + "generic", + "postgresql", + "mysql", + "mariadb", + "sqlserver", + "oracle", + "sqlite", + "snowflake", + "bigquery", + "redshift", + "clickhouse", +] + +SQLSERVER_SQL = "SELECT TOP 5 [id] FROM [dbo].[users]" +PORTABLE_SQL = "SELECT id, COUNT(*) FROM orders WHERE total > 10 GROUP BY id" + +DIALECT_CALLS = [ + ("parse", lambda sql, d: pygosqlx.parse(sql, dialect=d)), + ("validate", lambda sql, d: pygosqlx.validate(sql, dialect=d)), + ("validate_detailed", lambda sql, d: pygosqlx.validate_detailed(sql, dialect=d)), + ("format", lambda sql, d: pygosqlx.format(sql, dialect=d)), + ("extract_tables", lambda sql, d: pygosqlx.extract_tables(sql, dialect=d)), + ("extract_columns", lambda sql, d: pygosqlx.extract_columns(sql, dialect=d)), + ("extract_functions", lambda sql, d: pygosqlx.extract_functions(sql, dialect=d)), + ("extract_metadata", lambda sql, d: pygosqlx.extract_metadata(sql, dialect=d)), +] + + +# --------------------------------------------------------------------------- +# No shared library required +# --------------------------------------------------------------------------- + + +class TestPublicAPI: + def test_new_names_exported(self): + for name in [ + "lint", + "security_scan", + "transpile", + "dialects", + "LintResult", + "LintViolation", + "SecurityScanResult", + "SecurityFinding", + "TranspileError", + "UnsupportedDialectError", + ]: + assert name in pygosqlx.__all__ + assert hasattr(pygosqlx, name) + + def test_version_is_0_3_0(self): + assert pygosqlx.__version__ == "0.3.0" + + def test_dialect_is_keyword_only(self): + with pytest.raises(TypeError): + pygosqlx.parse("SELECT 1", "mysql") # type: ignore[misc] + + def test_unsupported_dialect_error_hierarchy(self): + err = UnsupportedDialectError("bad", code="E4002") + assert isinstance(err, GoSQLXError) + assert isinstance(err, ValueError) + assert str(err) == "[E4002] bad" + + def test_transpile_error_hierarchy(self): + assert issubclass(TranspileError, GoSQLXError) + + +class TestResultTypes: + def test_lint_result_properties(self): + v = LintViolation( + rule="L007", + rule_name="Keyword Case Consistency", + severity="warning", + message="m", + line=1, + column=1, + ) + assert v.suggestion == "" and v.can_auto_fix is False + r = LintResult(violations=[v]) + assert r.violation_count == 1 + assert not r.is_clean + assert LintResult().is_clean + assert LintResult().violation_count == 0 + + def test_security_scan_result_properties(self): + clean = SecurityScanResult() + assert clean.is_clean and not clean.has_critical and not clean.has_high + high = SecurityScanResult(total_count=1, high_count=1) + assert not high.is_clean and high.has_high and not high.has_critical + crit = SecurityScanResult(total_count=1, critical_count=1) + assert crit.has_critical and crit.has_high + + def test_security_finding_defaults(self): + f = SecurityFinding(severity="LOW", pattern="P", description="d") + assert f.line is None and f.column is None + assert f.risk == "" and f.sql == "" and f.suggestion == "" + + +class TestArgumentValidation: + """Argument errors are raised before the library is touched.""" + + @pytest.mark.parametrize("value", [0, -1, True, 1.5, "100"]) + def test_lint_rejects_bad_max_line_length(self, value): + with pytest.raises(ValueError): + pygosqlx.lint("SELECT 1", max_line_length=value) + + @pytest.mark.parametrize("name,call", DIALECT_CALLS) + def test_non_string_dialect_rejected(self, name, call): + with pytest.raises(TypeError): + call("SELECT 1", 42) + + def test_lint_non_string_dialect_rejected(self): + with pytest.raises(TypeError): + pygosqlx.lint("SELECT 1", dialect=b"mysql") # type: ignore[arg-type] + + def test_transpile_non_string_dialect_rejected(self): + with pytest.raises(TypeError): + pygosqlx.transpile("SELECT 1", 1, "mysql") # type: ignore[arg-type] + + +class TestOlderLibrary: + """A library built before 0.3.0 lacks the new exports: fail cleanly.""" + + @pytest.fixture + def old_library(self, monkeypatch): + new_exports = set(core._DIALECT_EXPORTS.values()) | { + "gosqlx_lint", + "gosqlx_security_scan", + "gosqlx_transpile", + "gosqlx_dialects", + } + + def fake_has_func(name): + if name in new_exports: + return False + return True + + monkeypatch.setattr(core, "_has_func", fake_has_func) + + @pytest.mark.parametrize("name,call", DIALECT_CALLS) + def test_dialect_argument_needs_new_library(self, old_library, name, call): + with pytest.raises(GoSQLXError, match=r"_dialect not available"): + call("SELECT 1", "mysql") + + @pytest.mark.parametrize( + "symbol,call", + [ + ("gosqlx_lint", lambda: pygosqlx.lint("SELECT 1")), + ("gosqlx_security_scan", lambda: pygosqlx.security_scan("SELECT 1")), + ( + "gosqlx_transpile", + lambda: pygosqlx.transpile("SELECT 1", "mysql", "postgresql"), + ), + ("gosqlx_dialects", lambda: pygosqlx.dialects()), + ], + ) + def test_new_functions_need_new_library(self, old_library, symbol, call): + with pytest.raises(GoSQLXError, match=f"{symbol} not available"): + call() + + @requires_lib + def test_calls_without_dialect_still_work(self, old_library): + # dialect=None goes through the original exports. + assert pygosqlx.parse("SELECT 1").statement_count == 1 + assert pygosqlx.validate("SELECT 1") + assert pygosqlx.extract_tables("SELECT a FROM t") == ["t"] + + +# --------------------------------------------------------------------------- +# Shared library required +# --------------------------------------------------------------------------- + + +@requires_lib +class TestDialects: + def test_dialects_list(self): + assert pygosqlx.dialects() == EXPECTED_DIALECTS + + def test_dialects_returns_fresh_list(self): + a = pygosqlx.dialects() + a.append("x") + assert "x" not in pygosqlx.dialects() + + +@requires_lib +class TestDialectArgument: + @pytest.mark.parametrize("dialect", EXPECTED_DIALECTS) + def test_every_dialect_parses_portable_sql(self, dialect): + result = pygosqlx.parse(PORTABLE_SQL, dialect=dialect) + assert result.statement_types == ["SELECT"] + assert pygosqlx.validate(PORTABLE_SQL, dialect=dialect) + assert pygosqlx.extract_tables(PORTABLE_SQL, dialect=dialect) == ["orders"] + assert "COUNT" in pygosqlx.extract_functions(PORTABLE_SQL, dialect=dialect) + + @pytest.mark.parametrize("name,call", DIALECT_CALLS) + def test_none_and_empty_match_default(self, name, call): + sql = "SELECT u.id, UPPER(u.name) FROM users u WHERE u.id = 1" + assert call(sql, None) == call(sql, "") + + def test_sqlserver_syntax(self): + result = pygosqlx.parse(SQLSERVER_SQL, dialect="sqlserver") + assert isinstance(result, ParseResult) + assert result.statement_count == 1 + assert pygosqlx.validate(SQLSERVER_SQL, dialect="sqlserver") + detail = pygosqlx.validate_detailed(SQLSERVER_SQL, dialect="sqlserver") + assert isinstance(detail, ValidationResult) and detail.valid + assert pygosqlx.extract_tables(SQLSERVER_SQL, dialect="sqlserver") == ["dbo.users"] + assert pygosqlx.extract_columns(SQLSERVER_SQL, dialect="sqlserver") == ["id"] + meta = pygosqlx.extract_metadata(SQLSERVER_SQL, dialect="sqlserver") + assert isinstance(meta, Metadata) + assert "TOP" in pygosqlx.format(SQLSERVER_SQL, dialect="sqlserver").upper() + + @pytest.mark.parametrize("name,call", DIALECT_CALLS) + @pytest.mark.parametrize("dialect", ["fakesql", "MySQL", "postgres"]) + def test_unknown_dialect_raises(self, name, call, dialect): + with pytest.raises(UnsupportedDialectError) as exc_info: + call("SELECT 1", dialect) + err = exc_info.value + assert err.code == "E4002" + assert dialect in err.message + assert "valid dialects: generic" in err.message + + def test_unknown_dialect_is_value_error(self): + with pytest.raises(ValueError): + pygosqlx.validate("SELECT 1", dialect="nope") + + def test_invalid_sql_with_dialect(self): + with pytest.raises(ParseError) as exc_info: + pygosqlx.parse("SELECT * FROM", dialect="postgresql") + assert not isinstance(exc_info.value, UnsupportedDialectError) + assert not pygosqlx.validate("SELECT * FROM", dialect="mysql") + detail = pygosqlx.validate_detailed("SELECT *\nFROM t WHERE", dialect="mysql") + assert not detail.valid and detail.error and detail.error_line + with pytest.raises(FormatError): + pygosqlx.format("SELECT * FROM", dialect="mysql") + for fn in ( + pygosqlx.extract_tables, + pygosqlx.extract_columns, + pygosqlx.extract_functions, + pygosqlx.extract_metadata, + ): + with pytest.raises(ParseError): + fn("SELECT * FROM", dialect="mysql") + + def test_empty_sql_with_dialect(self): + with pytest.raises(ParseError): + pygosqlx.parse("", dialect="mysql") + assert not pygosqlx.validate("", dialect="mysql") + with pytest.raises(FormatError): + pygosqlx.format("", dialect="mysql") + + +@requires_lib +class TestLint: + def test_lint_reports_violations(self): + result = pygosqlx.lint("select id from users ") + assert isinstance(result, LintResult) + rules = {v.rule for v in result.violations} + assert {"L001", "L007"} <= rules + assert result.violation_count == len(result.violations) + for v in result.violations: + assert isinstance(v, LintViolation) + assert v.line >= 1 and v.column >= 1 + assert v.severity in {"error", "warning", "info"} + assert v.rule_name and v.message + # Only the `gosqlx lint` rule set (L001-L010) runs. + assert v.rule <= "L010" + + def test_lint_clean_sql(self): + result = pygosqlx.lint("SELECT id FROM users") + assert result.is_clean + assert result.violations == [] + + def test_lint_empty_sql(self): + assert pygosqlx.lint("").is_clean + + def test_lint_unparseable_sql_still_linted(self): + result = pygosqlx.lint("select * from ") + assert any(v.rule == "L001" for v in result.violations) + + def test_lint_max_line_length(self): + sql = "SELECT " + "a" * 60 + " FROM t" + assert not any(v.rule == "L005" for v in pygosqlx.lint(sql).violations) + result = pygosqlx.lint(sql, max_line_length=40) + assert any(v.rule == "L005" for v in result.violations) + + def test_lint_dialect(self): + result = pygosqlx.lint(SQLSERVER_SQL, dialect="sqlserver") + assert isinstance(result, LintResult) + + def test_lint_unknown_dialect(self): + with pytest.raises(UnsupportedDialectError): + pygosqlx.lint("SELECT 1", dialect="fakesql") + + +@requires_lib +class TestSecurityScan: + def test_detects_tautology(self): + result = pygosqlx.security_scan("SELECT * FROM users WHERE id = 1 OR 1=1 --") + assert isinstance(result, SecurityScanResult) + assert not result.is_clean + assert result.has_critical and result.has_high + assert result.total_count == len(result.findings) + assert ( + result.critical_count + + result.high_count + + result.medium_count + + result.low_count + == result.total_count + ) + patterns = {f.pattern for f in result.findings} + assert "TAUTOLOGY" in patterns + for f in result.findings: + assert isinstance(f, SecurityFinding) + assert f.severity in {"CRITICAL", "HIGH", "MEDIUM", "LOW"} + assert f.description + + def test_clean_query(self): + result = pygosqlx.security_scan("SELECT id FROM users WHERE id = $1") + assert result.is_clean + assert result.findings == [] + + def test_empty_input_is_clean(self): + assert pygosqlx.security_scan("").is_clean + + def test_unparseable_payload_scanned(self): + assert not pygosqlx.security_scan("'; DROP TABLE users; --").is_clean + + +@requires_lib +class TestTranspile: + def test_mysql_to_postgresql(self): + out = pygosqlx.transpile( + "CREATE TABLE t (id INT AUTO_INCREMENT PRIMARY KEY)", "mysql", "postgresql" + ) + assert "SERIAL" in out.upper() + assert "AUTO_INCREMENT" not in out.upper() + + def test_passthrough_pair(self): + out = pygosqlx.transpile("SELECT a FROM t", "generic", "snowflake") + assert out.upper().startswith("SELECT") + + @pytest.mark.parametrize( + "src,dst", [("fakesql", "mysql"), ("mysql", "postgres"), ("", "mysql")] + ) + def test_bad_dialects(self, src, dst): + with pytest.raises(UnsupportedDialectError): + pygosqlx.transpile("SELECT 1", src, dst) + + def test_none_dialect(self): + with pytest.raises(UnsupportedDialectError): + pygosqlx.transpile("SELECT 1", None, "mysql") # type: ignore[arg-type] + + def test_invalid_sql(self): + with pytest.raises(TranspileError): + pygosqlx.transpile("SELECT * FROM", "mysql", "postgresql") + + def test_empty_sql(self): + with pytest.raises(TranspileError): + pygosqlx.transpile("", "mysql", "postgresql") + + +@requires_lib +class TestNewFunctionsThreadSafety: + def test_concurrent_calls(self): + errors = [] + + def worker(dialect): + try: + for _ in range(50): + pygosqlx.parse(PORTABLE_SQL, dialect=dialect) + pygosqlx.lint("select a from t", dialect=dialect) + pygosqlx.security_scan("SELECT * FROM t WHERE 1=1 OR 'a'='a'") + pygosqlx.transpile("SELECT a FROM t", "mysql", dialect) + pygosqlx.dialects() + except Exception as exc: # pragma: no cover - reported below + errors.append(exc) + + threads = [ + threading.Thread(target=worker, args=(d,)) for d in EXPECTED_DIALECTS + ] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == []