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