Skip to content

Yggdrasil Plan Module

SQL parsing, lazy execution plans, and Arrow-native UDF execution for Tabular data.

Quick Start

from yggdrasil.plan import parse_sql, SelectPlan, LazyTabular, BUILTIN_REGISTRY
from yggdrasil.arrow.tabular import ArrowTabular
import pyarrow as pa

# Create a table
users = ArrowTabular(pa.table({
    "id": [1, 2, 3, 4, 5],
    "name": ["alice", "bob", "carol", "dave", "eve"],
    "region": ["US", "EU", "US", "EU", "US"],
    "score": [90, 80, 95, 70, 85],
}))

# Parse and execute SQL
node = parse_sql("SELECT name, score FROM users WHERE score > 80 ORDER BY score DESC")
result = node.execute(tables={"users": users})
print(result.read_arrow_table().to_pylist())
# [{'name': 'carol', 'score': 95}, {'name': 'alice', 'score': 90}, {'name': 'eve', 'score': 85}]

SQL Parsing

Basic SELECT

parse_sql("SELECT * FROM t")
parse_sql("SELECT a, b, c FROM t")
parse_sql("SELECT DISTINCT region FROM users")
parse_sql("SELECT a + b AS total, c * 2 AS doubled FROM t")
parse_sql("SELECT 1 AS one, 'hello' AS greeting, NULL AS empty")
parse_sql("SELECT t.* FROM t")  # qualified star

WHERE Clauses

parse_sql("SELECT * FROM t WHERE id > 10")
parse_sql("SELECT * FROM t WHERE a > 1 AND b < 2 OR c = 3")
parse_sql("SELECT * FROM t WHERE id IN (1, 2, 3)")
parse_sql("SELECT * FROM t WHERE id NOT IN (4, 5)")
parse_sql("SELECT * FROM t WHERE id BETWEEN 10 AND 20")
parse_sql("SELECT * FROM t WHERE name LIKE '%test%'")
parse_sql("SELECT * FROM t WHERE name ILIKE '%john%'")  # case-insensitive
parse_sql("SELECT * FROM t WHERE x IS NULL")
parse_sql("SELECT * FROM t WHERE x IS NOT NULL")
parse_sql("SELECT * FROM t WHERE NOT (a > 1)")

JOIN

parse_sql("SELECT * FROM a INNER JOIN b ON a.id = b.id")
parse_sql("SELECT * FROM a LEFT JOIN b ON a.id = b.id")
parse_sql("SELECT * FROM a RIGHT OUTER JOIN b ON a.id = b.ref_id")
parse_sql("SELECT * FROM a FULL OUTER JOIN b ON a.id = b.id")
parse_sql("SELECT * FROM a CROSS JOIN b")
parse_sql("SELECT * FROM a JOIN b ON a.id = b.id LEFT JOIN c ON b.id = c.id")  # multi-join
parse_sql("SELECT * FROM a JOIN (SELECT * FROM b WHERE active) sub ON a.id = sub.id")  # subquery

GROUP BY / HAVING

parse_sql("SELECT region, COUNT(*) AS cnt FROM users GROUP BY region")
parse_sql("SELECT region, SUM(score), AVG(score), MIN(score), MAX(score) FROM users GROUP BY region")
parse_sql("SELECT region, COUNT(*) AS cnt FROM users GROUP BY region HAVING COUNT(*) > 2")
parse_sql("SELECT COUNT(*) AS total FROM users")  # aggregate without GROUP BY
parse_sql("SELECT YEAR(dt), COUNT(*) FROM events GROUP BY YEAR(dt)")  # expression in GROUP BY

ORDER BY / LIMIT / OFFSET

parse_sql("SELECT * FROM t ORDER BY score DESC")
parse_sql("SELECT * FROM t ORDER BY region ASC, score DESC")
parse_sql("SELECT * FROM t ORDER BY score DESC NULLS LAST")
parse_sql("SELECT * FROM t LIMIT 10")
parse_sql("SELECT * FROM t LIMIT 10 OFFSET 20")
parse_sql("SELECT * FROM t ORDER BY id DESC LIMIT 5")

UNION / INTERSECT / EXCEPT

parse_sql("SELECT a FROM t1 UNION ALL SELECT a FROM t2")
parse_sql("SELECT a FROM t1 UNION SELECT a FROM t2")  # distinct
parse_sql("SELECT a FROM t1 INTERSECT SELECT a FROM t2")
parse_sql("SELECT a FROM t1 EXCEPT SELECT a FROM t2")

WITH (CTEs)

parse_sql("""
    WITH top_users AS (
        SELECT * FROM users WHERE score > 80
    )
    SELECT name, score FROM top_users ORDER BY score DESC
""")

parse_sql("""
    WITH a AS (SELECT 1 AS x),
         b AS (SELECT 2 AS y)
    SELECT * FROM a JOIN b ON a.x = b.y
""")

Function Calls

# String
parse_sql("SELECT UPPER(name), LOWER(name), LENGTH(name) FROM t")
parse_sql("SELECT TRIM(name), REVERSE(name), INITCAP(name) FROM t")
parse_sql("SELECT CONCAT(first, ' ', last) FROM t", dialect="databricks")
parse_sql("SELECT SUBSTRING(name, 1, 3) FROM t", dialect="databricks")
parse_sql("SELECT REGEXP_REPLACE(s, '[0-9]+', 'X') FROM t", dialect="databricks")

# Math
parse_sql("SELECT ABS(x), CEIL(x), FLOOR(x), ROUND(x, 2) FROM t")
parse_sql("SELECT SQRT(x), POWER(x, 2), LN(x), EXP(x) FROM t")
parse_sql("SELECT SIN(x), COS(x), TAN(x), ATAN2(y, x) FROM t")

# Null handling
parse_sql("SELECT COALESCE(a, b, 0) FROM t")
parse_sql("SELECT NVL(a, 0), IFNULL(a, 0), NULLIF(a, b) FROM t")

# Date/time
parse_sql("SELECT YEAR(ts), MONTH(ts), DAY(ts) FROM t")
parse_sql("SELECT DATE_TRUNC('month', ts) FROM t", dialect="databricks")
parse_sql("SELECT DATE_ADD(dt, 7), DATEDIFF(a, b) FROM t", dialect="databricks")
parse_sql("SELECT EXTRACT(YEAR FROM ts) FROM t")
parse_sql("SELECT CURRENT_TIMESTAMP(), CURRENT_DATE() FROM t")
parse_sql("SELECT ts + INTERVAL '7' DAY FROM t")

# Aggregate
parse_sql("SELECT COUNT(*), COUNT(DISTINCT id) FROM t")
parse_sql("SELECT SUM(amount), AVG(score), MIN(id), MAX(id) FROM t")
parse_sql("SELECT COLLECT_LIST(name) FROM t GROUP BY region", dialect="databricks")
parse_sql("SELECT APPROX_COUNT_DISTINCT(id) FROM t", dialect="databricks")

# Window
parse_sql("SELECT ROW_NUMBER() OVER (PARTITION BY region ORDER BY score DESC) FROM t", dialect="databricks")
parse_sql("SELECT RANK() OVER (ORDER BY score DESC) FROM t", dialect="databricks")
parse_sql("SELECT LAG(val, 1) OVER (ORDER BY ts) FROM t", dialect="databricks")
parse_sql("SELECT SUM(val) OVER (ORDER BY ts ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) FROM t",
          dialect="databricks")

# Conditional
parse_sql("SELECT CASE WHEN score > 90 THEN 'A' WHEN score > 80 THEN 'B' ELSE 'C' END FROM t")
parse_sql("SELECT IF(a > 0, 'positive', 'negative') FROM t", dialect="databricks")

# Type casting
parse_sql("SELECT CAST(id AS BIGINT), CAST(name AS STRING) FROM t")
parse_sql("SELECT CAST(price AS DECIMAL(10, 2)) FROM t")
parse_sql("SELECT TRY_CAST(x AS INT) FROM t", dialect="databricks")
parse_sql("SELECT TIMESTAMP '2024-01-01 00:00:00', DATE '2024-01-01'")

LATERAL VIEW / EXPLODE

parse_sql("""
    SELECT id, val
    FROM t
    LATERAL VIEW EXPLODE(arr) vals AS val
""", dialect="databricks")

parse_sql("""
    SELECT id, v1, v2
    FROM t
    LATERAL VIEW EXPLODE(arr1) t1 AS v1
    LATERAL VIEW EXPLODE(arr2) t2 AS v2
""", dialect="databricks")

INSERT / MERGE

parse_sql("INSERT INTO target SELECT * FROM source")
parse_sql("INSERT INTO t (id, name) VALUES (1, 'alice'), (2, 'bob')")

parse_sql("""
    MERGE INTO target t USING source s ON t.id = s.id
    WHEN MATCHED THEN UPDATE SET t.name = s.name
    WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)
""")

FROM Path/URL Sources

# String literal paths in FROM clause
parse_sql("SELECT * FROM '/data/users.parquet'")
parse_sql("SELECT * FROM 's3://bucket/data.csv'")

Execution

Against Arrow Tables

users = ArrowTabular(pa.table({...}))
node = parse_sql("SELECT * FROM users WHERE score > 80")
result = node.execute(tables={"users": users})
table = result.read_arrow_table()

Against Spark DataFrames

from yggdrasil.spark.tabular import SparkDataset

spark_users = SparkDataset(frame=spark.createDataFrame(...))
node = parse_sql("SELECT * FROM users WHERE score > 80")
result = node.execute(tables={"users": spark_users})
# Uses Spark-native filter, stays as SparkDataset

Against Folders (disk-backed)

from yggdrasil.path.local_path import LocalPath
from yggdrasil.path.folder import Folder

folder = Folder(path=LocalPath("/data/users"))
node = parse_sql("SELECT * FROM users WHERE region = 'US'")
# Pushes predicate into CastOptions → partition pruning
result = node.execute(tables={"users": folder})

With CTEs

node = parse_sql("""
    WITH active AS (SELECT * FROM users WHERE active = TRUE),
         scored AS (SELECT * FROM active WHERE score > 80)
    SELECT region, COUNT(*) AS cnt FROM scored GROUP BY region
""")
result = node.execute(tables={"users": users_table})

Default on Parse Failure

node = parse_sql("INVALID SQL", default=None)  # returns None
node = parse_sql("SELECT 1", default=None)     # returns SelectNode

Lazy Execution (LazyTabular)

lazy = users.lazy()
lazy.select("id", "name").filter("score > 80").limit(10)
table = lazy.read_arrow_table()  # executes here

# Chain operations
result = (users.lazy()
          .filter("region = 'US'")
          .select("name", "score")
          .limit(5)
          .read_arrow_table())

# Join
result = (users.lazy()
          .join(orders, on="id", how="inner")
          .filter("amount > 20")
          .read_arrow_table())

SelectPlan (Programmatic Builder)

plan = SelectPlan()
plan.select("a", "b").filter("x > 10").limit(100)
result = plan.execute(source_tabular)

# SQL round-trip
sql = plan.to_sql(dialect="databricks")
plan2 = ExecutionPlan.from_sql(sql)

UDF System

Built-in Arrow Kernels (78 functions)

from yggdrasil.plan import BUILTIN_REGISTRY

# Direct Arrow execution
BUILTIN_REGISTRY.apply_arrow("UPPER", pa.array(["hello"]))    # → ["HELLO"]
BUILTIN_REGISTRY.apply_arrow("ABS", pa.array([-1, -2, 3]))    # → [1, 2, 3]
BUILTIN_REGISTRY.apply_arrow("SQRT", pa.array([4.0, 9.0]))    # → [2.0, 3.0]
BUILTIN_REGISTRY.apply_arrow("COALESCE", pa.array([1, None]), pa.array([10, 20]))  # → [1, 20]
BUILTIN_REGISTRY.apply_arrow("MD5", pa.array(["hello"]))       # → ["5d41402abc..."]

# Via SQL execution
node = parse_sql("SELECT UPPER(name) AS uname, ABS(score) AS abs_score FROM users")
result = node.execute(tables={"users": users})

Custom UDFs

import pyarrow.compute as pc

# Register
BUILTIN_REGISTRY.register_udf("DOUBLE_IT", lambda a: pc.multiply(a, 2))

# Apply directly
BUILTIN_REGISTRY.apply_arrow("DOUBLE_IT", pa.array([1, 2, 3]))  # → [2, 4, 6]

# Register with metadata
BUILTIN_REGISTRY.register_udf("NORMALIZE",
    lambda a: pc.divide(pc.subtract(a, pc.mean(a)), pc.stddev(a)),
    min_args=1, max_args=1)

Nested Type Constructors

# STRUCT — combine columns into a struct array
BUILTIN_REGISTRY.apply_arrow("STRUCT", pa.array([1, 2]), pa.array(["a", "b"]))
# → [{"c0": 1, "c1": "a"}, {"c0": 2, "c1": "b"}]

# NAMED_STRUCT — with explicit field names
BUILTIN_REGISTRY.apply_arrow("NAMED_STRUCT",
    "id", pa.array([1, 2]), "name", pa.array(["alice", "bob"]))
# → [{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}]

# ARRAY — zip columns into list arrays (one list per row)
BUILTIN_REGISTRY.apply_arrow("ARRAY",
    pa.array([1, 2]), pa.array([3, 4]), pa.array([5, 6]))
# → [[1, 3, 5], [2, 4, 6]]

# MAP — build map/dict per row from key-value pairs
BUILTIN_REGISTRY.apply_arrow("MAP",
    "x", pa.array([1, 2]), "y", pa.array([3, 4]))
# → [{"x": 1, "y": 3}, {"x": 2, "y": 4}]

# MAP_FROM_ARRAYS — zip key-list and value-list per row
BUILTIN_REGISTRY.apply_arrow("MAP_FROM_ARRAYS",
    pa.array([["a", "b"]]), pa.array([[1, 2]]))
# → [{"a": 1, "b": 2}]

# Struct/map field access
import pyarrow.compute as pc
struct = pc.make_struct(pa.array([1, 2]), field_names=["id"])
BUILTIN_REGISTRY.apply_arrow("GET_FIELD", struct, "id")  # → [1, 2]

# Map key/value extraction
m = pa.array([[("a", 1), ("b", 2)]], type=pa.map_(pa.utf8(), pa.int64()))
BUILTIN_REGISTRY.apply_arrow("MAP_KEYS", m)    # → [["a", "b"]]
BUILTIN_REGISTRY.apply_arrow("MAP_VALUES", m)   # → [[1, 2]]

Collection Operations (Explode, Array Functions)

from yggdrasil.plan.func_registry import explode_table, posexplode_table

# Explode — flatten list column, repeat scalar columns
t = pa.table({"id": [1, 2], "vals": [[10, 20], [30, 40, 50]]})
explode_table(t, "vals")
# id | vals      (Arrow-native: list_flatten + list_parent_indices)
# 1  | 10
# 1  | 20
# 2  | 30
# 2  | 40
# 2  | 50

# Explode with rename
explode_table(t, "vals", out_col="value")  # renames column

# Posexplode — adds position column
posexplode_table(t, "vals", pos_col="pos", out_col="val")
# id | val | pos
# 1  | 10  | 0
# 1  | 20  | 1
# 2  | 30  | 0
# 2  | 40  | 1
# 2  | 50  | 2

# Array functions
BUILTIN_REGISTRY.apply_arrow("SIZE", pa.array([[1,2,3],[4,5]]))        # → [3, 2]
BUILTIN_REGISTRY.apply_arrow("FLATTEN", pa.array([[1,2],[3],[4,5]]))   # → [1,2,3,4,5]
BUILTIN_REGISTRY.apply_arrow("SORT_ARRAY", pa.array([[3,1,2]]))       # → [[1,2,3]]
BUILTIN_REGISTRY.apply_arrow("ARRAY_DISTINCT", pa.array([[1,2,2,3]])) # → [[1,2,3]]
BUILTIN_REGISTRY.apply_arrow("ARRAY_CONTAINS", pa.array([[1,2,3]]), 2) # → [True]
BUILTIN_REGISTRY.apply_arrow("ARRAY_MIN", pa.array([[3,1,2]]))        # → [1]
BUILTIN_REGISTRY.apply_arrow("ARRAY_MAX", pa.array([[3,1,2]]))        # → [3]

Type Casting

BUILTIN_REGISTRY.apply_arrow("STRING", pa.array([1, 2, 3]))    # → ["1", "2", "3"]
BUILTIN_REGISTRY.apply_arrow("BIGINT", pa.array(["1", "2"]))   # → [1, 2]
BUILTIN_REGISTRY.apply_arrow("INT", pa.array([1.5, 2.7]))      # → [1, 2]
BUILTIN_REGISTRY.apply_arrow("DOUBLE", pa.array([1, 2]))       # → [1.0, 2.0]
BUILTIN_REGISTRY.apply_arrow("BOOLEAN", pa.array([1, 0, 1]))   # → [True, False, True]

Hash Functions

BUILTIN_REGISTRY.apply_arrow("MD5", pa.array(["hello"]))
# → ["5d41402abc4b2a76b9719d911017c592"]

BUILTIN_REGISTRY.apply_arrow("SHA1", pa.array(["hello"]))
# → ["aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"]

IO UDFs

# Read file contents
BUILTIN_REGISTRY.apply_arrow("READ_FILES", pa.array(["/path/to/file.txt"]))

# List directory
BUILTIN_REGISTRY.apply_arrow("READ_PATHS", pa.array(["/data/"]))

# Compress / decompress
data = pa.array(["hello world"])
compressed = BUILTIN_REGISTRY.apply_arrow("COMPRESS", data, "gzip")
BUILTIN_REGISTRY.apply_arrow("DECOMPRESS", compressed, "gzip")

# JSON parse / serialize
BUILTIN_REGISTRY.apply_arrow("PARSE_JSON", pa.array(['{"a": 1}']))
BUILTIN_REGISTRY.apply_arrow("TO_JSON", pa.array([1, 2, 3]))

# Type casting
BUILTIN_REGISTRY.apply_arrow("STRING", pa.array([1, 2, 3]))    # → ["1", "2", "3"]
BUILTIN_REGISTRY.apply_arrow("BIGINT", pa.array(["1", "2"]))   # → [1, 2]

Spark UDF Registration

# Register all kerneled functions in Spark
BUILTIN_REGISTRY.register_in_spark(spark_session)

# Custom UDFs work in both Arrow and Spark
reg = BUILTIN_REGISTRY.copy()
reg.register_udf("MY_FN", lambda a: pc.multiply(a, 2))
reg.register_in_spark(spark)
spark.sql("SELECT MY_FN(val) FROM t")

SQL Dialects

parse_sql("SELECT * FROM t", dialect="ansi")
parse_sql("SELECT * FROM t", dialect="databricks")  # default
parse_sql("SELECT * FROM t", dialect="postgres")
parse_sql("SELECT * FROM t", dialect="mysql")
parse_sql("SELECT * FROM t", dialect="sqlite")

Databricks dialect adds recognition for 200+ built-in functions, LATERAL VIEW, TRY_CAST, INTERVAL literals, and backtick identifier quoting.

SQL Round-Trip

node = parse_sql("SELECT a, b FROM t WHERE id > 10 LIMIT 5", dialect="databricks")
sql = node.to_sql(dialect="databricks")
# "SELECT `a`, `b` FROM `t` WHERE `id` > 10 LIMIT 5"

node2 = parse_sql(sql, dialect="databricks")  # round-trips cleanly

Function Registry

from yggdrasil.plan import BUILTIN_REGISTRY

len(BUILTIN_REGISTRY)          # 290+ functions
BUILTIN_REGISTRY.is_known("DATE_TRUNC")  # True
meta = BUILTIN_REGISTRY.get("DATE_TRUNC")
# FunctionMeta(name='DATE_TRUNC', category='datetime', min_args=2, max_args=2, kernel=...)

# Categories
# aggregate, window, datetime, string, null, math, collection,
# generator, type, json, higher_order, hash, conditional, io, misc

# Copy for isolated scope
my_reg = BUILTIN_REGISTRY.copy()
my_reg.register_udf("MY_FN", ...)

Performance

Operation (10k rows) Time
Parse simple SELECT 35µs
Parse complex CTE+window 170µs
Execute SELECT* 31µs
Execute WHERE filter 670µs
Execute GROUP BY 700µs
Execute ORDER BY+LIMIT 490µs
UPPER(name) UDF 340µs
ABS(score) UDF 145µs
3-column UDF projection 450µs
SQL round-trip 100µs
Registry lookup 0.2µs
Folder SELECT* (disk) 370µs
Folder WHERE (disk) 1.1ms