Skip to main content

polyglot_sql/
lib.rs

1//! Polyglot Core - SQL parsing and dialect translation library
2//!
3//! This library provides the core functionality for parsing SQL statements,
4//! building an abstract syntax tree (AST), and generating SQL in different dialects.
5//!
6//! # Architecture
7//!
8//! The library follows a pipeline architecture:
9//! 1. **Tokenizer** - Converts SQL string to token stream
10//! 2. **Parser** - Builds AST from tokens
11//! 3. **Generator** - Converts AST back to SQL string
12//!
13//! Each stage can be customized per dialect.
14
15mod ast_children;
16pub mod ast_json;
17#[cfg(any(feature = "ast-tools", feature = "generate", feature = "semantic"))]
18pub mod ast_transforms;
19#[cfg(feature = "builder")]
20pub mod builder;
21pub mod dialects;
22#[cfg(feature = "diff")]
23pub mod diff;
24pub mod error;
25pub mod expressions;
26#[cfg(any(test, feature = "dialect-tsql"))]
27mod format_tokens;
28#[cfg(feature = "semantic")]
29pub mod function_catalog;
30mod function_registry;
31#[cfg(feature = "generate")]
32pub mod generator;
33pub mod guard;
34#[cfg(feature = "semantic")]
35pub mod helper;
36#[cfg(feature = "semantic")]
37pub mod lineage;
38#[cfg(feature = "openlineage")]
39pub mod openlineage;
40#[cfg(feature = "semantic")]
41pub mod optimizer;
42pub mod parser;
43#[cfg(feature = "planner")]
44pub mod planner;
45#[cfg(all(feature = "semantic", feature = "generate"))]
46pub mod query_analysis;
47#[cfg(feature = "semantic")]
48pub mod resolver;
49#[cfg(feature = "semantic")]
50pub mod schema;
51#[cfg(feature = "semantic")]
52pub mod scope;
53#[cfg(feature = "time")]
54pub mod time;
55pub mod tokens;
56#[cfg(feature = "transpile")]
57pub mod transforms;
58#[cfg(any(feature = "ast-tools", feature = "generate", feature = "semantic"))]
59pub mod traversal;
60#[cfg(not(any(feature = "ast-tools", feature = "generate", feature = "semantic")))]
61mod traversal;
62#[cfg(any(feature = "semantic", feature = "time"))]
63pub mod trie;
64#[cfg(feature = "semantic")]
65pub mod validation;
66
67#[cfg(any(feature = "generate", feature = "semantic"))]
68use serde::{Deserialize, Serialize};
69
70#[cfg(feature = "ast-tools")]
71pub use ast_transforms::{
72    add_select_columns, add_where, get_aggregate_functions, get_column_names, get_functions,
73    get_identifiers, get_literals, get_output_column_names, get_subqueries, get_table_names,
74    get_window_functions, node_count, qualify_columns, remove_limit_offset, remove_nodes,
75    remove_select_columns, remove_where, rename_columns, rename_tables, rename_tables_with_options,
76    replace_by_type, replace_nodes, set_distinct, set_limit, set_limit_expr, set_offset,
77    set_offset_expr, set_order_by, RenameTablesOptions,
78};
79pub use dialects::{unregister_custom_dialect, CustomDialectBuilder, Dialect, DialectType};
80#[cfg(feature = "transpile")]
81pub use dialects::{TranspileOptions, TranspileTarget};
82pub use error::{Error, Result};
83#[cfg(feature = "semantic")]
84pub use error::{ValidationError, ValidationResult, ValidationSeverity};
85pub use expressions::{DataType, Expression};
86#[cfg(feature = "semantic")]
87pub use function_catalog::{
88    FunctionCatalog, FunctionNameCase, FunctionSignature, HashMapFunctionCatalog,
89};
90#[cfg(feature = "generate")]
91pub use generator::{Generator, UnsupportedLevel};
92pub use guard::ComplexityGuardOptions;
93#[cfg(feature = "semantic")]
94pub use helper::{
95    csv, find_new_name, is_date_unit, is_float, is_int, is_iso_date, is_iso_datetime, merge_ranges,
96    name_sequence, seq_get, split_num_words, tsort, while_changing, DATE_UNITS,
97};
98#[cfg(feature = "semantic")]
99pub use optimizer::{
100    annotate_types, qualify_tables, QualifyTablesOptions, TypeAnnotator, TypeCoercionClass,
101};
102pub use parser::Parser;
103#[cfg(all(feature = "semantic", feature = "generate"))]
104pub use query_analysis::{
105    analyze_query, AnalyzeQueryOptions, ColumnReferenceFact, CteFact, ProjectionFact,
106    ProjectionNullability, QueryAnalysis, QueryShape, ReferenceConfidence, RelationFact,
107    SetOperationBranchFact, SetOperationFact, StarProjectionFact, TransformFunctionFact,
108    TransformKind,
109};
110#[cfg(feature = "semantic")]
111pub use resolver::{is_column_ambiguous, resolve_column, Resolver, ResolverError, ResolverResult};
112#[cfg(feature = "semantic")]
113pub use schema::{
114    ensure_schema, from_simple_map, normalize_name, MappingSchema, Schema, SchemaError,
115};
116#[cfg(feature = "semantic")]
117pub use scope::{
118    build_scope, find_all_in_scope, find_in_scope, traverse_scope, walk_in_scope, ColumnRef, Scope,
119    ScopeType, SourceInfo,
120};
121#[cfg(feature = "time")]
122pub use time::{format_time, is_valid_timezone, subsecond_precision, TIMEZONES};
123pub use tokens::{Token, TokenType, Tokenizer};
124#[cfg(feature = "ast-tools")]
125pub use traversal::{
126    contains_aggregate,
127    contains_subquery,
128    contains_window_function,
129    find_ancestor,
130    find_parent,
131    get_all_tables,
132    get_columns,
133    get_merge_source,
134    get_merge_target,
135    get_tables,
136    is_add,
137    is_aggregate,
138    is_alias,
139    is_alter_table,
140    is_and,
141    is_arithmetic,
142    is_avg,
143    is_between,
144    is_boolean,
145    is_case,
146    is_cast,
147    is_coalesce,
148    is_column,
149    is_comparison,
150    is_concat,
151    is_count,
152    is_create_index,
153    is_create_table,
154    is_create_view,
155    is_cte,
156    is_ddl,
157    is_delete,
158    is_div,
159    is_drop_index,
160    is_drop_table,
161    is_drop_view,
162    is_eq,
163    is_except,
164    is_exists,
165    is_from,
166    is_function,
167    is_group_by,
168    is_gt,
169    is_gte,
170    is_having,
171    is_identifier,
172    is_ilike,
173    is_in,
174    // Extended type predicates
175    is_insert,
176    is_intersect,
177    is_is_null,
178    is_join,
179    is_like,
180    is_limit,
181    is_literal,
182    is_logical,
183    is_lt,
184    is_lte,
185    is_max_func,
186    is_merge,
187    is_min_func,
188    is_mod,
189    is_mul,
190    is_neq,
191    is_not,
192    is_null_if,
193    is_null_literal,
194    is_offset,
195    is_or,
196    is_order_by,
197    is_ordered,
198    is_paren,
199    // Composite predicates
200    is_query,
201    is_safe_cast,
202    is_select,
203    is_set_operation,
204    is_star,
205    is_sub,
206    is_subquery,
207    is_sum,
208    is_table,
209    is_try_cast,
210    is_union,
211    is_update,
212    is_where,
213    is_window_function,
214    is_with,
215    transform,
216    transform_map,
217    BfsIter,
218    DfsIter,
219    ExpressionWalk,
220    ParentInfo,
221    TreeContext,
222};
223#[cfg(any(feature = "semantic", feature = "time"))]
224pub use trie::{new_trie, new_trie_from_keys, Trie, TrieResult};
225#[cfg(feature = "semantic")]
226pub use validation::{
227    mapping_schema_from_validation_schema, validate_with_schema, SchemaColumn,
228    SchemaColumnReference, SchemaForeignKey, SchemaTable, SchemaTableReference,
229    SchemaValidationOptions, ValidationSchema,
230};
231
232#[cfg(feature = "generate")]
233const DEFAULT_FORMAT_MAX_INPUT_BYTES: usize = 16 * 1024 * 1024; // 16 MiB
234#[cfg(feature = "generate")]
235const DEFAULT_FORMAT_MAX_TOKENS: usize = 1_000_000;
236#[cfg(feature = "generate")]
237const DEFAULT_FORMAT_MAX_AST_NODES: usize = 1_000_000;
238#[cfg(feature = "generate")]
239const DEFAULT_FORMAT_MAX_SET_OP_CHAIN: usize = 256;
240
241#[cfg(feature = "generate")]
242fn default_format_max_input_bytes() -> Option<usize> {
243    Some(DEFAULT_FORMAT_MAX_INPUT_BYTES)
244}
245
246#[cfg(feature = "generate")]
247fn default_format_max_tokens() -> Option<usize> {
248    Some(DEFAULT_FORMAT_MAX_TOKENS)
249}
250
251#[cfg(feature = "generate")]
252fn default_format_max_ast_nodes() -> Option<usize> {
253    Some(DEFAULT_FORMAT_MAX_AST_NODES)
254}
255
256#[cfg(feature = "generate")]
257fn default_format_max_set_op_chain() -> Option<usize> {
258    Some(DEFAULT_FORMAT_MAX_SET_OP_CHAIN)
259}
260
261/// Guard options for SQL pretty-formatting.
262///
263/// These limits protect against extremely large/complex queries that can cause
264/// high memory pressure in constrained runtimes (for example browser WASM).
265#[cfg(feature = "generate")]
266#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct FormatGuardOptions {
269    /// Maximum allowed SQL input size in bytes.
270    /// `None` disables this check.
271    #[serde(default = "default_format_max_input_bytes")]
272    pub max_input_bytes: Option<usize>,
273    /// Maximum allowed number of tokens after tokenization.
274    /// `None` disables this check.
275    #[serde(default = "default_format_max_tokens")]
276    pub max_tokens: Option<usize>,
277    /// Maximum allowed AST node count after parsing.
278    /// `None` disables this check.
279    #[serde(default = "default_format_max_ast_nodes")]
280    pub max_ast_nodes: Option<usize>,
281    /// Maximum allowed count of set-operation operators (`UNION`/`INTERSECT`/`EXCEPT`)
282    /// observed in a statement before parsing.
283    ///
284    /// `None` disables this check.
285    #[serde(default = "default_format_max_set_op_chain")]
286    pub max_set_op_chain: Option<usize>,
287}
288
289#[cfg(feature = "generate")]
290impl Default for FormatGuardOptions {
291    fn default() -> Self {
292        Self {
293            max_input_bytes: default_format_max_input_bytes(),
294            max_tokens: default_format_max_tokens(),
295            max_ast_nodes: default_format_max_ast_nodes(),
296            max_set_op_chain: default_format_max_set_op_chain(),
297        }
298    }
299}
300
301#[cfg(feature = "generate")]
302fn format_guard_error(code: &str, actual: usize, limit: usize) -> Error {
303    Error::generate(format!(
304        "{code}: value {actual} exceeds configured limit {limit}"
305    ))
306}
307
308#[cfg(feature = "generate")]
309fn enforce_input_guard(sql: &str, options: &FormatGuardOptions) -> Result<()> {
310    if let Some(max) = options.max_input_bytes {
311        let input_bytes = sql.len();
312        if input_bytes > max {
313            return Err(format_guard_error(
314                "E_GUARD_INPUT_TOO_LARGE",
315                input_bytes,
316                max,
317            ));
318        }
319    }
320    Ok(())
321}
322
323#[cfg(feature = "generate")]
324fn parse_with_token_guard(
325    sql: &str,
326    dialect: &Dialect,
327    options: &FormatGuardOptions,
328) -> Result<Vec<Expression>> {
329    let tokens = dialect.tokenize(sql)?;
330    if let Some(max) = options.max_tokens {
331        let token_count = tokens.len();
332        if token_count > max {
333            return Err(format_guard_error(
334                "E_GUARD_TOKEN_BUDGET_EXCEEDED",
335                token_count,
336                max,
337            ));
338        }
339    }
340    enforce_set_op_chain_guard(&tokens, options)?;
341
342    let complexity_guard = ComplexityGuardOptions {
343        max_input_bytes: options.max_input_bytes,
344        max_tokens: options.max_tokens,
345        max_ast_nodes: options.max_ast_nodes,
346        ..Default::default()
347    };
348    let config = crate::parser::ParserConfig {
349        dialect: Some(dialect.dialect_type()),
350        complexity_guard,
351        ..Default::default()
352    };
353    let mut parser = Parser::with_source(tokens, config, sql.to_string());
354    parser.parse()
355}
356
357#[cfg(feature = "generate")]
358fn is_trivia_token(token_type: TokenType) -> bool {
359    matches!(
360        token_type,
361        TokenType::Space | TokenType::Break | TokenType::LineComment | TokenType::BlockComment
362    )
363}
364
365#[cfg(feature = "generate")]
366fn next_significant_token(tokens: &[Token], start: usize) -> Option<&Token> {
367    tokens
368        .iter()
369        .skip(start)
370        .find(|token| !is_trivia_token(token.token_type))
371}
372
373#[cfg(feature = "generate")]
374fn is_set_operation_token(tokens: &[Token], idx: usize) -> bool {
375    let token = &tokens[idx];
376    match token.token_type {
377        TokenType::Union | TokenType::Intersect => true,
378        TokenType::Except => {
379            // MINUS is aliased to EXCEPT in the tokenizer, but in ClickHouse minus(...)
380            // is a function call rather than a set operation.
381            if token.text.eq_ignore_ascii_case("minus")
382                && matches!(
383                    next_significant_token(tokens, idx + 1).map(|t| t.token_type),
384                    Some(TokenType::LParen)
385                )
386            {
387                return false;
388            }
389            true
390        }
391        _ => false,
392    }
393}
394
395#[cfg(feature = "generate")]
396fn enforce_set_op_chain_guard(tokens: &[Token], options: &FormatGuardOptions) -> Result<()> {
397    let Some(max) = options.max_set_op_chain else {
398        return Ok(());
399    };
400
401    let mut set_op_count = 0usize;
402    for (idx, token) in tokens.iter().enumerate() {
403        if token.token_type == TokenType::Semicolon {
404            set_op_count = 0;
405            continue;
406        }
407
408        if is_set_operation_token(tokens, idx) {
409            set_op_count += 1;
410            if set_op_count > max {
411                return Err(format_guard_error(
412                    "E_GUARD_SET_OP_CHAIN_EXCEEDED",
413                    set_op_count,
414                    max,
415                ));
416            }
417        }
418    }
419
420    Ok(())
421}
422
423#[cfg(feature = "generate")]
424fn enforce_ast_guard(expressions: &[Expression], options: &FormatGuardOptions) -> Result<()> {
425    if let Some(max) = options.max_ast_nodes {
426        let ast_nodes: usize = expressions
427            .iter()
428            .map(crate::ast_transforms::node_count)
429            .sum();
430        if ast_nodes > max {
431            return Err(format_guard_error(
432                "E_GUARD_AST_BUDGET_EXCEEDED",
433                ast_nodes,
434                max,
435            ));
436        }
437    }
438    Ok(())
439}
440
441#[cfg(feature = "generate")]
442fn format_with_dialect(
443    sql: &str,
444    dialect: &Dialect,
445    options: &FormatGuardOptions,
446) -> Result<Vec<String>> {
447    enforce_input_guard(sql, options)?;
448    let expressions = parse_with_token_guard(sql, dialect, options)?;
449    enforce_ast_guard(&expressions, options)?;
450
451    expressions
452        .iter()
453        .map(|expr| dialect.generate_pretty(expr))
454        .collect()
455}
456
457/// Transpile SQL from one dialect to another.
458///
459/// # Arguments
460/// * `sql` - The SQL string to transpile
461/// * `read` - The source dialect to parse with
462/// * `write` - The target dialect to generate
463///
464/// # Returns
465/// A vector of transpiled SQL statements
466///
467/// # Example
468/// ```
469/// use polyglot_sql::{transpile, DialectType};
470///
471/// let result = transpile(
472///     "SELECT EPOCH_MS(1618088028295)",
473///     DialectType::DuckDB,
474///     DialectType::Hive
475/// );
476/// ```
477#[cfg(feature = "transpile")]
478pub fn transpile(sql: &str, read: DialectType, write: DialectType) -> Result<Vec<String>> {
479    // Delegate to Dialect::transpile so that the full cross-dialect rewrite
480    // pipeline (source+target-aware normalization in `cross_dialect_normalize`)
481    // runs here as well. This keeps Rust crate users on the same code path as
482    // the WASM/FFI/Python bindings and the playground.
483    Dialect::get(read).transpile(sql, write)
484}
485
486/// Parse SQL into an AST.
487///
488/// # Arguments
489/// * `sql` - The SQL string to parse
490/// * `dialect` - The dialect to use for parsing
491///
492/// # Returns
493/// A vector of parsed expressions
494pub fn parse(sql: &str, dialect: DialectType) -> Result<Vec<Expression>> {
495    let d = Dialect::get(dialect);
496    d.parse(sql)
497}
498
499/// Parse a single SQL statement.
500///
501/// # Arguments
502/// * `sql` - The SQL string containing a single statement
503/// * `dialect` - The dialect to use for parsing
504///
505/// # Returns
506/// The parsed expression, or an error if multiple statements found
507pub fn parse_one(sql: &str, dialect: DialectType) -> Result<Expression> {
508    let mut expressions = parse(sql, dialect)?;
509
510    if expressions.len() != 1 {
511        return Err(Error::parse(
512            format!("Expected 1 statement, found {}", expressions.len()),
513            0,
514            0,
515            0,
516            0,
517        ));
518    }
519
520    Ok(expressions.remove(0))
521}
522
523/// Parse a standalone SQL data type.
524///
525/// # Arguments
526/// * `sql` - The data type string to parse, e.g. `DECIMAL(10, 2)`
527/// * `dialect` - The dialect to use for parsing
528///
529/// # Returns
530/// The parsed data type
531pub fn parse_data_type(sql: &str, dialect: DialectType) -> Result<DataType> {
532    Dialect::get(dialect).parse_data_type(sql)
533}
534
535/// Generate SQL from a standalone data type.
536///
537/// # Arguments
538/// * `data_type` - The data type to render
539/// * `dialect` - The target dialect
540///
541/// # Returns
542/// The generated type SQL string
543#[cfg(feature = "generate")]
544pub fn generate_data_type(data_type: &DataType, dialect: DialectType) -> Result<String> {
545    Dialect::get(dialect).generate(&Expression::DataType(data_type.clone()))
546}
547
548/// Generate SQL from an AST.
549///
550/// # Arguments
551/// * `expression` - The expression to generate SQL from
552/// * `dialect` - The target dialect
553///
554/// # Returns
555/// The generated SQL string
556#[cfg(feature = "generate")]
557pub fn generate(expression: &Expression, dialect: DialectType) -> Result<String> {
558    let d = Dialect::get(dialect);
559    d.generate(expression)
560}
561
562/// Format/pretty-print SQL statements.
563///
564/// Uses [`FormatGuardOptions::default`] guards.
565#[cfg(feature = "generate")]
566pub fn format(sql: &str, dialect: DialectType) -> Result<Vec<String>> {
567    format_with_options(sql, dialect, &FormatGuardOptions::default())
568}
569
570/// Format/pretty-print SQL statements with configurable guard limits.
571#[cfg(feature = "generate")]
572pub fn format_with_options(
573    sql: &str,
574    dialect: DialectType,
575    options: &FormatGuardOptions,
576) -> Result<Vec<String>> {
577    let d = Dialect::get(dialect);
578    format_with_dialect(sql, &d, options)
579}
580
581/// Validate SQL syntax.
582///
583/// # Arguments
584/// * `sql` - The SQL string to validate
585/// * `dialect` - The dialect to use for validation
586///
587/// # Returns
588/// A validation result with any errors found
589#[cfg(feature = "semantic")]
590pub fn validate(sql: &str, dialect: DialectType) -> ValidationResult {
591    validate_with_options(sql, dialect, &ValidationOptions::default())
592}
593
594/// Options for syntax validation behavior.
595#[cfg(feature = "semantic")]
596#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
597#[serde(rename_all = "camelCase")]
598pub struct ValidationOptions {
599    /// When enabled, validation rejects non-canonical trailing commas that the parser
600    /// would otherwise accept for compatibility (e.g. `SELECT a, FROM t`).
601    #[serde(default)]
602    pub strict_syntax: bool,
603    /// When enabled, validation reports query-quality warnings W001 through W004.
604    #[serde(default)]
605    pub semantic: bool,
606}
607
608/// Validate SQL syntax and optional query-quality semantic warnings.
609#[cfg(feature = "semantic")]
610pub fn validate_with_options(
611    sql: &str,
612    dialect: DialectType,
613    options: &ValidationOptions,
614) -> ValidationResult {
615    let d = Dialect::get(dialect);
616    validate_with_dialect(sql, &d, options)
617}
618
619/// Validate SQL using an already-resolved dialect.
620///
621/// This is useful for wrappers and custom dialect consumers that should retain
622/// the exact tokenizer and parser configuration of a [`Dialect`] handle.
623#[cfg(feature = "semantic")]
624pub fn validate_with_dialect(
625    sql: &str,
626    dialect: &Dialect,
627    options: &ValidationOptions,
628) -> ValidationResult {
629    match dialect.parse(sql) {
630        Ok(expressions) => {
631            // Reject bare expressions that aren't valid SQL statements.
632            // The parser accepts any expression at the top level, but bare identifiers,
633            // literals, function calls, etc. are not valid statements.
634            for expr in &expressions {
635                if !expr.is_statement() {
636                    let msg = format!("Invalid expression / Unexpected token");
637                    return ValidationResult::with_errors(vec![ValidationError::error(
638                        msg, "E004",
639                    )]);
640                }
641            }
642            if options.strict_syntax {
643                if let Some(error) = strict_syntax_error(sql, dialect) {
644                    return ValidationResult::with_errors(vec![error]);
645                }
646            }
647            let mut errors = Vec::new();
648            if options.semantic {
649                for expression in &expressions {
650                    errors.extend(validation::check_semantics(expression));
651                }
652            }
653            ValidationResult::with_errors(errors)
654        }
655        Err(e) => {
656            let error = match &e {
657                Error::Syntax {
658                    message,
659                    line,
660                    column,
661                    start,
662                    end,
663                } => ValidationError::error(message.clone(), "E001")
664                    .with_location(*line, *column)
665                    .with_span(Some(*start), Some(*end)),
666                Error::Tokenize {
667                    message,
668                    line,
669                    column,
670                    start,
671                    end,
672                } => ValidationError::error(message.clone(), "E002")
673                    .with_location(*line, *column)
674                    .with_span(Some(*start), Some(*end)),
675                Error::Parse {
676                    message,
677                    line,
678                    column,
679                    start,
680                    end,
681                } => ValidationError::error(message.clone(), "E003")
682                    .with_location(*line, *column)
683                    .with_span(Some(*start), Some(*end)),
684                _ => ValidationError::error(e.to_string(), "E000"),
685            };
686            ValidationResult::with_errors(vec![error])
687        }
688    }
689}
690
691#[cfg(feature = "semantic")]
692fn strict_syntax_error(sql: &str, dialect: &Dialect) -> Option<ValidationError> {
693    let tokens = dialect.tokenize(sql).ok()?;
694
695    for (idx, token) in tokens.iter().enumerate() {
696        if token.token_type != TokenType::Comma {
697            continue;
698        }
699
700        let next = tokens.get(idx + 1);
701        let (is_boundary, boundary_name) = match next.map(|t| t.token_type) {
702            Some(TokenType::From) => (true, "FROM"),
703            Some(TokenType::Where) => (true, "WHERE"),
704            Some(TokenType::GroupBy) => (true, "GROUP BY"),
705            Some(TokenType::Having) => (true, "HAVING"),
706            Some(TokenType::Order) | Some(TokenType::OrderBy) => (true, "ORDER BY"),
707            Some(TokenType::Limit) => (true, "LIMIT"),
708            Some(TokenType::Offset) => (true, "OFFSET"),
709            Some(TokenType::Union) => (true, "UNION"),
710            Some(TokenType::Intersect) => (true, "INTERSECT"),
711            Some(TokenType::Except) => (true, "EXCEPT"),
712            Some(TokenType::Qualify) => (true, "QUALIFY"),
713            Some(TokenType::Window) => (true, "WINDOW"),
714            Some(TokenType::Semicolon) | None => (true, "end of statement"),
715            _ => (false, ""),
716        };
717
718        if is_boundary {
719            let message = format!(
720                "Trailing comma before {} is not allowed in strict syntax mode",
721                boundary_name
722            );
723            return Some(
724                ValidationError::error(message, "E005")
725                    .with_location(token.span.line, token.span.column),
726            );
727        }
728    }
729
730    None
731}
732
733/// Transpile SQL from one dialect to another, using string dialect names.
734///
735/// This supports both built-in dialect names (e.g., "postgresql", "mysql") and
736/// custom dialects registered via [`CustomDialectBuilder`].
737///
738/// # Arguments
739/// * `sql` - The SQL string to transpile
740/// * `read` - The source dialect name
741/// * `write` - The target dialect name
742///
743/// # Returns
744/// A vector of transpiled SQL statements, or an error if a dialect name is unknown.
745#[cfg(feature = "transpile")]
746pub fn transpile_by_name(sql: &str, read: &str, write: &str) -> Result<Vec<String>> {
747    transpile_with_by_name(sql, read, write, &TranspileOptions::default())
748}
749
750/// Transpile SQL with configurable [`TranspileOptions`], using string dialect names.
751///
752/// Same as [`transpile_by_name`] but accepts options (e.g., pretty-printing).
753#[cfg(feature = "transpile")]
754pub fn transpile_with_by_name(
755    sql: &str,
756    read: &str,
757    write: &str,
758    opts: &TranspileOptions,
759) -> Result<Vec<String>> {
760    let read_dialect = Dialect::get_by_name(read)
761        .ok_or_else(|| Error::parse(format!("Unknown dialect: {}", read), 0, 0, 0, 0))?;
762    let write_dialect = Dialect::get_by_name(write)
763        .ok_or_else(|| Error::parse(format!("Unknown dialect: {}", write), 0, 0, 0, 0))?;
764    read_dialect.transpile_with(sql, &write_dialect, opts.clone())
765}
766
767/// Parse SQL into an AST using a string dialect name.
768///
769/// Supports both built-in and custom dialect names.
770pub fn parse_by_name(sql: &str, dialect: &str) -> Result<Vec<Expression>> {
771    let d = Dialect::get_by_name(dialect)
772        .ok_or_else(|| Error::parse(format!("Unknown dialect: {}", dialect), 0, 0, 0, 0))?;
773    d.parse(sql)
774}
775
776/// Generate SQL from an AST using a string dialect name.
777///
778/// Supports both built-in and custom dialect names.
779#[cfg(feature = "generate")]
780pub fn generate_by_name(expression: &Expression, dialect: &str) -> Result<String> {
781    let d = Dialect::get_by_name(dialect)
782        .ok_or_else(|| Error::parse(format!("Unknown dialect: {}", dialect), 0, 0, 0, 0))?;
783    d.generate(expression)
784}
785
786/// Format SQL using a string dialect name.
787///
788/// Uses [`FormatGuardOptions::default`] guards.
789#[cfg(feature = "generate")]
790pub fn format_by_name(sql: &str, dialect: &str) -> Result<Vec<String>> {
791    format_with_options_by_name(sql, dialect, &FormatGuardOptions::default())
792}
793
794/// Format SQL using a string dialect name with configurable guard limits.
795#[cfg(feature = "generate")]
796pub fn format_with_options_by_name(
797    sql: &str,
798    dialect: &str,
799    options: &FormatGuardOptions,
800) -> Result<Vec<String>> {
801    let d = Dialect::get_by_name(dialect)
802        .ok_or_else(|| Error::parse(format!("Unknown dialect: {}", dialect), 0, 0, 0, 0))?;
803    format_with_dialect(sql, &d, options)
804}
805
806#[cfg(test)]
807mod api_contract_tests {
808    use super::*;
809    use serde_json::Value;
810    use std::collections::{BTreeMap, BTreeSet};
811
812    macro_rules! exported_symbols {
813        ($($capability:literal => { $($name:literal => $symbol:expr),+ $(,)? }),+ $(,)?) => {{
814            let mut capabilities = BTreeMap::new();
815            $(
816                $(let _ = $symbol;)+
817                capabilities.insert(
818                    $capability,
819                    BTreeSet::from([$($name),+]),
820                );
821            )+
822            capabilities
823        }};
824    }
825
826    #[test]
827    fn public_api_matches_capability_contract() {
828        let Ok(path) = std::env::var("POLYGLOT_API_CONTRACT") else {
829            return;
830        };
831        let contract: Value = serde_json::from_str(
832            &std::fs::read_to_string(path).expect("read API capability contract"),
833        )
834        .expect("parse API capability contract");
835
836        let actual = exported_symbols! {
837            "dialects" => { "Dialect.get" => Dialect::get },
838            "transpile" => { "transpile" => transpile },
839            "parse" => { "parse" => parse, "parse_one" => parse_one },
840            "data_types" => { "parse_data_type" => parse_data_type, "generate_data_type" => generate_data_type },
841            "generate" => { "generate" => generate },
842            "format" => { "format" => format, "format_with_options" => format_with_options },
843            "validate" => {
844                "validate" => validate,
845                "validate_with_options" => validate_with_options,
846                "validate_with_dialect" => validate_with_dialect
847            },
848            "validate_schema" => { "validation.validate_with_schema" => validation::validate_with_schema },
849            "optimize" => { "optimizer.optimize" => optimizer::optimize },
850            "tokenize" => { "Tokenizer.tokenize" => Tokenizer::tokenize },
851            "annotate_types" => { "annotate_types" => annotate_types },
852            "diff" => { "diff.diff" => diff::diff },
853            "ast_transforms" => {
854                "rename_tables" => rename_tables,
855                "set_limit" => set_limit,
856                "set_offset" => set_offset,
857                "set_order_by" => set_order_by
858            },
859            "lineage" => {
860                "lineage.lineage" => lineage::lineage,
861                "lineage.lineage_with_schema" => lineage::lineage_with_schema,
862                "lineage.get_source_tables" => lineage::get_source_tables
863            },
864            "openlineage" => {
865                "openlineage.openlineage_column_lineage" => openlineage::openlineage_column_lineage,
866                "openlineage.openlineage_job_event" => openlineage::openlineage_job_event,
867                "openlineage.openlineage_run_event" => openlineage::openlineage_run_event
868            },
869            "analyze_query" => { "analyze_query" => analyze_query },
870            "planner" => { "planner.Plan.from_expression" => planner::Plan::from_expression },
871            "builders" => { "builder.col" => builder::col },
872            "visitors" => {
873                "traversal.transform" => traversal::transform::<fn(Expression) -> Result<Option<Expression>>>,
874                "traversal.get_columns" => traversal::get_columns
875            },
876        };
877
878        assert_layer_contract(&contract, "rust", &actual);
879    }
880
881    fn assert_layer_contract(
882        contract: &Value,
883        layer: &str,
884        actual: &BTreeMap<&str, BTreeSet<&str>>,
885    ) {
886        let capabilities = contract["capabilities"]
887            .as_array()
888            .expect("capabilities must be an array");
889        let mut declared_available = BTreeSet::new();
890
891        for capability in capabilities {
892            let id = capability["id"].as_str().expect("capability id");
893            let entry = &capability["layers"][layer];
894            let status = entry["status"].as_str().expect("capability status");
895            assert!(matches!(status, "supported" | "partial" | "unavailable"));
896            if status != "supported" {
897                assert!(entry["notes"].as_str().is_some_and(|note| !note.is_empty()));
898            }
899            if status == "unavailable" {
900                assert!(!actual.contains_key(id), "{id} is declared unavailable");
901                continue;
902            }
903
904            declared_available.insert(id);
905            let expected = entry["symbols"]
906                .as_array()
907                .expect("symbols must be an array")
908                .iter()
909                .map(|symbol| symbol.as_str().expect("symbol must be a string"))
910                .collect::<BTreeSet<_>>();
911            assert_eq!(actual.get(id), Some(&expected), "capability {id}");
912        }
913
914        assert_eq!(
915            actual.keys().copied().collect::<BTreeSet<_>>(),
916            declared_available
917        );
918    }
919}
920
921#[cfg(all(test, feature = "semantic"))]
922mod validation_tests {
923    use super::*;
924
925    #[test]
926    fn validate_is_permissive_by_default_for_trailing_commas() {
927        let result = validate("SELECT name, FROM employees", DialectType::Generic);
928        assert!(result.valid, "Result: {:?}", result.errors);
929    }
930
931    #[test]
932    fn validate_with_options_rejects_trailing_comma_before_from() {
933        let options = ValidationOptions {
934            strict_syntax: true,
935            ..Default::default()
936        };
937        let result = validate_with_options(
938            "SELECT name, FROM employees",
939            DialectType::Generic,
940            &options,
941        );
942        assert!(!result.valid, "Result should be invalid");
943        assert!(
944            result.errors.iter().any(|e| e.code == "E005"),
945            "Expected E005, got: {:?}",
946            result.errors
947        );
948    }
949
950    #[test]
951    fn validate_with_options_rejects_trailing_comma_before_where() {
952        let options = ValidationOptions {
953            strict_syntax: true,
954            ..Default::default()
955        };
956        let result = validate_with_options(
957            "SELECT name FROM employees, WHERE salary > 10",
958            DialectType::Generic,
959            &options,
960        );
961        assert!(!result.valid, "Result should be invalid");
962        assert!(
963            result.errors.iter().any(|e| e.code == "E005"),
964            "Expected E005, got: {:?}",
965            result.errors
966        );
967    }
968
969    #[test]
970    fn validate_with_options_reports_semantic_warnings() {
971        let options = ValidationOptions {
972            semantic: true,
973            ..Default::default()
974        };
975        let result = validate_with_options(
976            "SELECT *, category, COUNT(*) FROM products LIMIT 10",
977            DialectType::Generic,
978            &options,
979        );
980
981        assert!(result.valid, "Warnings must not invalidate SQL");
982        assert!(result.errors.iter().any(|error| error.code == "W001"));
983        assert!(result.errors.iter().any(|error| error.code == "W002"));
984        assert!(result.errors.iter().any(|error| error.code == "W004"));
985    }
986
987    #[test]
988    fn validate_with_dialect_combines_strict_and_semantic_options() {
989        let options = ValidationOptions {
990            strict_syntax: true,
991            semantic: true,
992        };
993        let dialect = Dialect::get_by_name("generic").expect("generic dialect");
994        let result = validate_with_dialect("SELECT *, FROM products", &dialect, &options);
995
996        assert!(!result.valid);
997        assert_eq!(result.errors.len(), 1);
998        assert_eq!(result.errors[0].code, "E005");
999    }
1000}
1001
1002#[cfg(all(test, feature = "generate"))]
1003mod format_tests {
1004    use super::*;
1005
1006    #[test]
1007    fn format_basic_query() {
1008        let result = format("SELECT a,b FROM t", DialectType::Generic).expect("format failed");
1009        assert_eq!(result.len(), 1);
1010        assert!(result[0].contains('\n'));
1011    }
1012
1013    #[test]
1014    fn format_guard_rejects_large_input() {
1015        let options = FormatGuardOptions {
1016            max_input_bytes: Some(7),
1017            max_tokens: None,
1018            max_ast_nodes: None,
1019            max_set_op_chain: None,
1020        };
1021        let err = format_with_options("SELECT 1", DialectType::Generic, &options)
1022            .expect_err("expected guard error");
1023        assert!(err.to_string().contains("E_GUARD_INPUT_TOO_LARGE"));
1024    }
1025
1026    #[test]
1027    fn format_guard_rejects_token_budget() {
1028        let options = FormatGuardOptions {
1029            max_input_bytes: None,
1030            max_tokens: Some(1),
1031            max_ast_nodes: None,
1032            max_set_op_chain: None,
1033        };
1034        let err = format_with_options("SELECT 1", DialectType::Generic, &options)
1035            .expect_err("expected guard error");
1036        assert!(err.to_string().contains("E_GUARD_TOKEN_BUDGET_EXCEEDED"));
1037    }
1038
1039    #[test]
1040    fn format_guard_rejects_ast_budget() {
1041        let options = FormatGuardOptions {
1042            max_input_bytes: None,
1043            max_tokens: None,
1044            max_ast_nodes: Some(1),
1045            max_set_op_chain: None,
1046        };
1047        let err = format_with_options("SELECT 1", DialectType::Generic, &options)
1048            .expect_err("expected guard error");
1049        assert!(err.to_string().contains("E_GUARD_AST_BUDGET_EXCEEDED"));
1050    }
1051
1052    #[test]
1053    fn format_guard_rejects_set_op_chain_budget() {
1054        let options = FormatGuardOptions {
1055            max_input_bytes: None,
1056            max_tokens: None,
1057            max_ast_nodes: None,
1058            max_set_op_chain: Some(1),
1059        };
1060        let err = format_with_options(
1061            "SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3",
1062            DialectType::Generic,
1063            &options,
1064        )
1065        .expect_err("expected guard error");
1066        assert!(err.to_string().contains("E_GUARD_SET_OP_CHAIN_EXCEEDED"));
1067    }
1068
1069    #[test]
1070    fn format_guard_does_not_treat_clickhouse_minus_function_as_set_op() {
1071        let options = FormatGuardOptions {
1072            max_input_bytes: None,
1073            max_tokens: None,
1074            max_ast_nodes: None,
1075            max_set_op_chain: Some(0),
1076        };
1077        let result = format_with_options("SELECT minus(3, 2)", DialectType::ClickHouse, &options);
1078        assert!(result.is_ok(), "Result: {:?}", result);
1079    }
1080
1081    #[test]
1082    fn issue57_invalid_ternary_returns_error() {
1083        // https://github.com/tobilg/polyglot/issues/57
1084        // Invalid SQL with ternary operator should return an error, not garbled output.
1085        let sql = "SELECT x > 0 ? 1 : 0 FROM t";
1086
1087        let parse_result = parse(sql, DialectType::PostgreSQL);
1088        assert!(
1089            parse_result.is_err(),
1090            "Expected parse error for invalid ternary SQL, got: {:?}",
1091            parse_result
1092        );
1093
1094        let format_result = format(sql, DialectType::PostgreSQL);
1095        assert!(
1096            format_result.is_err(),
1097            "Expected format error for invalid ternary SQL, got: {:?}",
1098            format_result
1099        );
1100
1101        let transpile_result = transpile(sql, DialectType::PostgreSQL, DialectType::PostgreSQL);
1102        assert!(
1103            transpile_result.is_err(),
1104            "Expected transpile error for invalid ternary SQL, got: {:?}",
1105            transpile_result
1106        );
1107    }
1108
1109    /// Regression guard: `lib::transpile()` must apply the full cross-dialect
1110    /// rewrite pipeline (same as `Dialect::transpile()`). If these two paths
1111    /// diverge again, Rust crate users silently get under-transformed SQL that
1112    /// differs from what WASM/FFI/Python bindings produce.
1113    #[test]
1114    fn transpile_applies_cross_dialect_rewrites() {
1115        // DuckDB to_timestamp → Trino FROM_UNIXTIME (different input semantics).
1116        let out = transpile(
1117            "SELECT to_timestamp(col) FROM t",
1118            DialectType::DuckDB,
1119            DialectType::Trino,
1120        )
1121        .expect("transpile failed");
1122        assert_eq!(out[0], "SELECT FROM_UNIXTIME(col) FROM t");
1123
1124        // DuckDB CAST(x AS JSON) → Trino JSON_PARSE(x) (different CAST semantics).
1125        let out = transpile(
1126            "SELECT CAST(col AS JSON) FROM t",
1127            DialectType::DuckDB,
1128            DialectType::Trino,
1129        )
1130        .expect("transpile failed");
1131        assert_eq!(out[0], "SELECT JSON_PARSE(col) FROM t");
1132    }
1133
1134    /// Regression guard: all three transpile entry points (lib::transpile,
1135    /// lib::transpile_by_name, Dialect::transpile) must produce identical
1136    /// output. transpile_by_name is the one used by Python and C FFI bindings.
1137    #[test]
1138    fn transpile_matches_dialect_method() {
1139        let cases: &[(DialectType, DialectType, &str, &str, &str)] = &[
1140            (
1141                DialectType::DuckDB,
1142                DialectType::Trino,
1143                "duckdb",
1144                "trino",
1145                "SELECT to_timestamp(col) FROM t",
1146            ),
1147            (
1148                DialectType::DuckDB,
1149                DialectType::Trino,
1150                "duckdb",
1151                "trino",
1152                "SELECT CAST(col AS JSON) FROM t",
1153            ),
1154            (
1155                DialectType::DuckDB,
1156                DialectType::Trino,
1157                "duckdb",
1158                "trino",
1159                "SELECT json_valid(col) FROM t",
1160            ),
1161            (
1162                DialectType::Snowflake,
1163                DialectType::DuckDB,
1164                "snowflake",
1165                "duckdb",
1166                "SELECT DATEDIFF(day, a, b) FROM t",
1167            ),
1168            (
1169                DialectType::BigQuery,
1170                DialectType::DuckDB,
1171                "bigquery",
1172                "duckdb",
1173                "SELECT DATE_DIFF(a, b, DAY) FROM t",
1174            ),
1175            (
1176                DialectType::Generic,
1177                DialectType::Generic,
1178                "generic",
1179                "generic",
1180                "SELECT 1",
1181            ),
1182        ];
1183        for (read, write, read_name, write_name, sql) in cases {
1184            let via_lib = transpile(sql, *read, *write).expect("lib::transpile failed");
1185            let via_name = transpile_by_name(sql, read_name, write_name)
1186                .expect("lib::transpile_by_name failed");
1187            let via_dialect = Dialect::get(*read)
1188                .transpile(sql, *write)
1189                .expect("Dialect::transpile failed");
1190            assert_eq!(
1191                via_lib, via_dialect,
1192                "lib::transpile / Dialect::transpile diverged for {:?} -> {:?}: {sql}",
1193                read, write
1194            );
1195            assert_eq!(
1196                via_name, via_dialect,
1197                "lib::transpile_by_name / Dialect::transpile diverged for {read_name} -> {write_name}: {sql}"
1198            );
1199        }
1200    }
1201
1202    #[test]
1203    fn format_default_guard_rejects_deep_union_chain_before_parse() {
1204        let base = "SELECT col0, col1 FROM t";
1205        let mut sql = base.to_string();
1206        for _ in 0..1100 {
1207            sql.push_str(" UNION ALL ");
1208            sql.push_str(base);
1209        }
1210
1211        let err = format(&sql, DialectType::Athena).expect_err("expected guard error");
1212        assert!(err.to_string().contains("E_GUARD_SET_OP_CHAIN_EXCEEDED"));
1213    }
1214}