Skip to content

feat(trino): parse routine characteristics for inline UDFs [CLAUDE] - #7981

Merged
georgesittas merged 4 commits into
tobymao:mainfrom
rusackas:trino-udf-2-routine-characteristics
Jul 30, 2026
Merged

feat(trino): parse routine characteristics for inline UDFs [CLAUDE]#7981
georgesittas merged 4 commits into
tobymao:mainfrom
rusackas:trino-udf-2-routine-characteristics

Conversation

@rusackas

@rusackas rusackas commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

I'm back! :)

PR 2 of 7-ish, continuing to build on #7934.

Adds parsing/generation for the routine characteristics that can follow a Trino inline WITH FUNCTION UDF's RETURNS clause: LANGUAGE, DETERMINISTIC/NOT DETERMINISTIC, CALLED ON NULL INPUT/RETURNS NULL ON NULL INPUT, SECURITY DEFINER/INVOKER, and COMMENT (https://trino.io/docs/current/udf/sql.html):

WITH FUNCTION custom_sqrt(a integer)
  RETURNS double
  COMMENT 'Custom sqrt function'
  RETURNS NULL ON NULL INPUT
  NOT DETERMINISTIC
  LANGUAGE SQL
  SECURITY DEFINER
  RETURN a
SELECT custom_sqrt(4)

Most of this already worked for free: _parse_function_specification (from #7934) calls the base _parse_properties() between RETURNS and RETURN, so LANGUAGE, CALLED/RETURNS NULL ON NULL INPUT, SECURITY DEFINER/INVOKER, and COMMENT all parsed and generated correctly with zero new code, via existing exp.LanguageProperty/exp.CalledOnNullInputProperty/exp.SqlSecurityProperty/exp.SchemaCommentProperty + their existing generic generators.

Two things actually needed fixing:

  1. DETERMINISTIC/NOT DETERMINISTIC — reused exp.StabilityProperty (base already parses bare DETERMINISTIC into it), and mirrored BigQuery's existing StabilityProperty handling exactly: a NOT DETERMINISTIC entry in TrinoParser.PROPERTY_PARSERS, the same TrinoGenerator.TRANSFORMS lambda BigQuery uses, and the same tokenizer-level "NOT DETERMINISTIC" keyword-merge BigQuery has (_match_texts only inspects one token, so the two words need to merge into one before the parser can dispatch on them). Before this, bare DETERMINISTIC round-tripped as the invalid-for-Trino IMMUTABLE, and NOT DETERMINISTIC didn't parse at all.

  2. LANGUAGE SQL immediately followed by a SECURITY clause — the base tokenizer already merges SQL SECURITY into one TokenType.SQL_SECURITY token (for MySQL/StarRocks' ... SQL SECURITY DEFINER VIEW), which greedily ate the SQL that was supposed to be LANGUAGE's value, e.g. LANGUAGE SQL SECURITY DEFINER tokenized as LANGUAGE / SQL SECURITY / DEFINER instead of LANGUAGE / SQL / SECURITY / DEFINER. Trino has no SQL SECURITY phrase in its own grammar (only bare SECURITY DEFINER/INVOKER), and the base parser's PROPERTY_PARSERS already maps plain "SECURITY" to the identical _parse_sql_security the merged token maps to, so Trino.Tokenizer just pops the merged keyword (same idiom as its existing KEYWORDS.pop("/*+") on the Presto side, or BigQuery's KEYWORDS.pop("DIV")). Confirmed this doesn't affect MySQL/StarRocks, whose tokenizers are untouched.

Next steps (remaining follow-up PRs, each gated on the previous)

  1. Core WITH FUNCTION ... RETURNS ... RETURN ... (feat(trino): parse WITH FUNCTION ... RETURNS ... RETURN inline UDFs [CLAUDE] #7934)
  2. This PR — routine characteristics
  3. BEGIN...END block bodies + DECLARE/SET, including the semicolon/chunk-continuation handling so routine bodies don't get split as separate statements
  4. IF/ELSEIF/ELSE — reusing/extending exp.IfBlock
  5. CASE...WHEN...END CASE
  6. WHILE...DO...END WHILE — reusing/extending exp.WhileBlock with a label arg
  7. LOOP/REPEAT/ITERATE/LEAVE

Related: apache/superset#26162 (Superset issue asking for Trino inline SQL UDF support end-to-end).

Disclosure: implemented with Claude Code, reviewed, tested (make unit, make style) and understood by me.

LANGUAGE, DETERMINISTIC/NOT DETERMINISTIC, CALLED/RETURNS NULL ON NULL
INPUT, SECURITY DEFINER/INVOKER, and COMMENT now parse and generate
correctly for WITH FUNCTION inline UDFs (tobymao#7934 was PR 1 of ~7).
@treysp treysp self-assigned this Jul 28, 2026
Trino has no SQL SECURITY clause of its own; the merged base keyword
otherwise swallows the SQL in LANGUAGE SQL SECURITY DEFINER.
@treysp

treysp commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR, it's looking good!

I noticed that the Trino UDF docs show an optional WITH () clause for specifying properties:

FUNCTION name ( [ parameter_name data_type [, ...] ] )
  RETURNS type
  [ LANGUAGE language]
  [ NOT? DETERMINISTIC ]
  [ RETURNS NULL ON NULL INPUT ]
  [ CALLED ON NULL INPUT ]
  [ SECURITY { DEFINER | INVOKER } ]
  [ COMMENT description]
  [ WITH ( property_name = expression [, ...] ) ]   <-------------- This guy
  { statements | AS definition }

Let's include that in this PR so we have the full spec covered.

Annoyingly, base sqlglot property handling expects either all or none of the properties to be wrapped in WITH (). Since these UDFs can have both, we'll need parse WITH separately from the other characteristics in _parse_function_specification.

So the parsing would happen in three steps: this, characteristics/properties, expression. The characteristics/properties parsing would look something like this:

       characteristics = []                                                                                             
       function_properties = []                                                                                         
                                                                                                                        
       while True:                                                                                                      
           if self._match(TokenType.WITH):                                                                              
               function_properties.extend(                                                                              
                   self._parse_wrapped_csv(self._parse_key_value_property)                                              
               )                                                                                                        
               continue                                                                                                 
                                                                                                                        
           characteristic = self._parse_property()                                                                      
           if not characteristic:                                                                                       
               break

           characteristics.extend(ensure_list(characteristic))                                                                                                    

We'll also need to add an arg to the FunctionSpecification expression so we can store the two sets separately. How about adding a new arg characteristics to store the non-WITH and existing arg properties to store the WITH?

Then, in the generator, generate the two sets separately. We won't always be able to retain the original order - it will always be WITH at the end. But that doesn't affect the semantics so should be fine.

Per @treysp's review: the routine characteristics and the trailing
WITH (property_name = expression, ...) clause need separate parsing,
since WITH's entries are arbitrary key/value pairs rather than one of
the fixed PROPERTY_PARSERS keywords the rest of the characteristics
loop matches on. FunctionSpecification gets a new characteristics arg
for the former; the existing properties arg now holds the WITH set.
@rusackas

Copy link
Copy Markdown
Contributor Author

Addressed in 87bd0a1. Split the routine characteristics from the trailing WITH (...) clause as suggested: _parse_function_specification now loops _parse_property() for the fixed-keyword characteristics and _parse_key_value_property() (via _parse_wrapped_csv) for WITH's arbitrary key/value pairs, stopping only when neither branch matches. FunctionSpecification picked up a new characteristics arg for the former; the existing properties arg now holds the WITH set. Generator renders WITH (...) last via the existing with_properties() helper, so it always lands right before the body regardless of where it appeared in the source. Added test coverage for WITH (...) alone, combined with other characteristics, and combined with the full characteristics set from the earlier example.

@rusackas

Copy link
Copy Markdown
Contributor Author

Thanks for the review @treysp - will open the next item in the lineup once this merges.

Or, if y'all want to try the new Github PR Stack feature, this might be a fun excuse to do so.

@georgesittas georgesittas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several invalid tests, because we use catalog-stored UDF syntax in inline UDF declarations, which don't seem to support the full grammar spec.

If the machinery is reused for the former category of statements (catalog-stored UDFs), then it may be helpful adding tests for them and/or cleaning up existing invalid tests to ensure they represent runnable workloads.

Other than that the approach looks good.

Comment thread tests/dialects/test_trino.py
Comment thread tests/dialects/test_trino.py
Comment thread tests/dialects/test_trino.py
Comment thread sqlglot/dialects/trino.py Outdated
Comment thread sqlglot/dialects/trino.py
Comment thread sqlglot/generators/trino.py Outdated
Comment thread sqlglot/parsers/trino.py Outdated
- _parse_property() now intercepts NOT DETERMINISTIC directly instead
  of merging it into one token at the tokenizer level (as BigQuery
  does): the merged token misparsed SELECT NOT deterministic FROM t,
  silently swallowing NOT as a boolean operator. Scoped to Trino only.
- Clarify/trim the two comments flagged as unclear or inaccurate.
- Note in test_inline_udf that the SECURITY / WITH (...) cases assert
  grammar round-tripping, not that Trino accepts that combination on
  an inline SQL-language UDF specifically (confirmed against a real
  Trino instance in review).
@rusackas

rusackas commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, I think everything is addressed. Thanks also for actually running these against Trino, @georgesittas, it caught some legit issues:

  • SELECT NOT deterministic FROM t was misparsing into a single bare column named "NOT DETERMINISTIC" instead of NOT applied to a column, because of the tokenizer-level keyword merge. Fixed by moving that check into TrinoParser._parse_property() (a two-token _match_text_seq) instead, so NOT is never swallowed outside of a FunctionSpecification. Scoped to Trino; flagged that BigQuery has the same tokenizer-merge pattern but a cross-dialect cleanup felt out of scope here.
  • Confirmed Athena inherits both the SQL SECURITY fix and this new one automatically, since its tokenizer builds off Trino.Tokenizer.KEYWORDS by reference.
  • Added a comment clarifying the SECURITY / WITH (...) test cases assert grammar round-tripping against Trino's documented function-specification grammar, not that this exact combination runs on an inline LANGUAGE SQL UDF (confirmed by your Trino output). Left the underlying parsing/generation in place since it's generic reuse that should carry over if catalog-stored CREATE FUNCTION support gets added later.
  • Trimmed/reworded the two comments flagged as unclear or inaccurate.

Full unit suite (1257 tests) plus ruff/ruff-format green.

@georgesittas georgesittas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@georgesittas
georgesittas merged commit 9815ccb into tobymao:main Jul 30, 2026
8 checks passed
georgesittas added a commit that referenced this pull request Aug 4, 2026
…E] (#8004)

* feat(trino): parse BEGIN...END routine bodies with DECLARE/SET [CLAUDE]

Trino inline UDFs can use a BEGIN...END compound body instead of a bare
RETURN <expr>, with DECLARE (including Trino's multi-identifier-one-type
form, DECLARE a, b, c type) and SET statements, and nested BEGIN blocks:

  https://trino.io/docs/current/udf/sql/begin.html
  https://trino.io/docs/current/udf/sql/declare.html
  https://trino.io/docs/current/udf/sql/set.html

DECLARE/SET parsing and generation is entirely existing generic
infrastructure (exp.Declare/DeclareItem/Set already support Trino's
grammar as-is); Trino only needed its own DECLARE tokenizer keyword and
a STATEMENT_PARSERS entry, mirroring BigQuery/Spark/T-SQL, plus
DECLARE_DEFAULT_ASSIGNMENT = "DEFAULT" (also matching BigQuery) since
the base default is "=".

The routine body itself needed more care. _parse_block() (used for e.g.
CREATE PROCEDURE ... BEGIN ... END) only recognizes its closing END when
nothing follows it in the token stream, which doesn't fit Trino's
grammar: the UDF's END is always followed by the enclosing query with no
separating semicolon (... END SELECT f(1)). _parse_routine_block reuses
the same underlying chunk-advancing primitives (_chunks/_advance_chunk)
that back _parse_block's own semicolon/chunk-continuation handling, but
closes on END regardless of what follows it, leaving the remaining
tokens for the caller. block_sql wraps unconditionally in BEGIN ...,
since every exp.Block in Trino's grammar is one (including nested ones,
which a first pass missed by only prefixing BEGIN at the top level).

_parse_routine_statement's fallback dispatches through the same
STATEMENT_PARSERS entries _parse_statement() uses (for SET/DECLARE)
rather than delegating to _parse_statement() itself, since that also
carries a generic expression/SELECT fallback that would otherwise
silently swallow whatever follows a malformed routine body as if it
were the routine's own, rather than raising a clear parse error.

Continues #7934/#7981. Remaining phases: IF/CASE/WHILE/LOOP control
statements (reusing/extending exp.IfBlock and exp.WhileBlock, per
review feedback on the original #7926).

Related: apache/superset#26162.

* docs(trino): trim the _parse_routine_block comment

* test(trino): verify BEGIN...END tests against a real Trino instance

All test_inline_udf_begin_end cases confirmed against Trino via Docker
(trinodb/trino):
- meaning_of_life() = 42, one() = 1, multi-identifier DECLARE, and
  doubled(x)+CTE all execute and return the expected values
- Nested BEGIN confirmed to actually run (returns 2)
- The LANGUAGE SQL + NOT DETERMINISTIC + BEGIN combination itself is
  valid and runs fine (confirmed separately with a RANDOM() body); the
  specific test case fails only because Trino's own function-body
  analysis flags this exact trivial body as deterministic despite the
  NOT DETERMINISTIC declaration, the same class of issue already noted
  for SECURITY/WITH (...) above. Updated the comment accordingly.

* fix(trino): address review feedback on BEGIN/DECLARE/SET [CLAUDE]

- DECLARE isn't a reserved word in Trino (confirmed: SELECT declare FROM
  t was already broken the same way for the pre-existing BigQuery/T-SQL/
  Spark DECLARE keyword, since none of them add it to ID_VAR_TOKENS
  either). Adding the tokenizer keyword without this made DECLARE
  unusable as a plain column/alias identifier. Added DECLARE to Trino's
  own ID_VAR_TOKENS/TABLE_ALIAS_TOKENS rather than the shared base sets,
  since other dialects may have it genuinely reserved.

- block_sql unconditionally prefixing BEGIN assumed every exp.Block is a
  Trino routine body, but Block is shared machinery other dialects also
  construct (e.g. via a fallback Command node when their own BEGIN
  handling doesn't fully parse). Traced the reported repro down to that:
  the BEGIN in the correct main output was already baked into a Command
  node's preserved text, not added by block_sql at all, so Trino's
  override doubled it. Fixed at the root instead of guessing by
  isinstance: added a begin arg to the shared exp.Block (mirroring the
  identical begin flag exp.Create already carries for this same
  purpose), handled generically in the base block_sql, and set explicitly
  by _parse_routine_block, which is the only place that should synthesize
  one. No other dialect's Block ever sets this, so their rendering is
  unaffected.

Both confirmed against the exact repros from review, full suite (1262
tests) green, and BigQuery/Snowflake/T-SQL/Spark's own Block-based tests
unaffected by the base generator change.

* Taking suggestion on comments...

Co-authored-by: Jo <46752250+georgesittas@users.noreply.github.com>

* fix(trino): incorporate georgesittas's review feedback [CLAUDE]

- DECLARE-as-identifier is valid in BigQuery, Presto, Trino, Athena, and
  Spark, only T-SQL genuinely reserves it. Moved TokenType.DECLARE into
  base ID_VAR_TOKENS/STATEMENT_PARSERS instead of a Trino-only override,
  so all of those dialects pick it up (T-SQL becomes more permissive
  parse-wise, which is fine per review). TABLE_ALIAS_TOKENS needs no
  separate change since it derives from ID_VAR_TOKENS in base already.

- Reworded the _parse_routine_block comment per suggestion, and
  restructured its loop into a while/else (matching the suggested
  diff): the loop condition itself now checks for END, so the else
  clause naturally fires only when END was actually found rather than
  on any other break.

- Replaced the STATEMENT_PARSERS-lookup fallback in
  _parse_routine_statement with explicit DECLARE/SET branches per
  review: none of the later phases (WHILE, REPEAT, ...) go through
  STATEMENT_PARSERS either, so keeping generic dispatch for just these
  two was unnecessary indirection.

- Rebuilt the block_sql regression test to construct the AST directly
  instead of parsing through BigQuery's Command fallback, removing the
  unrelated parser warning noise from the test output.

Full suite (1262 tests) green, including T-SQL/BigQuery/Spark/Presto/
Athena's own test suites specifically re-run for the ID_VAR_TOKENS
change.

---------

Co-authored-by: Jo <46752250+georgesittas@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants