FunSQL.jl
repository·master·Indexed 41 days ago
https://github.com/mechanicalrabbit/funsql.jlA Julia library for the compositional construction of SQL queries. It allows developers to build complex queries incrementally from small, independent fragments using a domain-specific language and the @funsql macro, avoiding the management of monolithic SQL strings. It supports features like recursive CTEs via Iterate, parameterized queries with Bind, and window functions through Partition and Define, relying on DBInterface.jl for connectivity with drivers such as SQLite.jl, LibPQ.jl, MySQL.jl, and ODBC.jl.
What's inside FunSQL.jl
- FunSQL.jl is a Julia library designed for the compositional construction of SQL queries. It is intended for developers who need to programmatically build SQL queries in a modular fashion, avoiding the difficulties of managing large, monolithic SQL strings. It exposes the full expressive power of SQL through a semantics that allows queries to be built from small, independent fragments.
How correlated queries work with Bind
masterA correlated query is an inner query that depends on data from the outer query. In FunSQL, you implement this using the
Bindnode andVarparameters.To create a correlated query:
- Define the inner query using
Varplaceholders (e.g.,Var.PERSON_ID) to refer to external data. - Wrap the inner query in a
Bindcall, mapping theVarsymbols to the actual expressions/columns from the outer query. - Use the resulting query within an operator like
Fun.existsin the outer query.
using FunSQL: Bind # Define the correlated subquery logic CorrelatedCondition(person_id, start_date, end_date) = From(:condition_occurrence) | Where(Fun.and(Get.person_id .== Var.PERSON_ID, Fun.between(Get.condition_start_date, Var.START_DATE, Var.END_DATE))) | Bind(:PERSON_ID => person_id, :START_DATE => start_date, :END_DATE => end_date) # Use it in the outer query q = From(:visit_occurrence) | Where(Fun.exists(CorrelatedCondition(Get.person_id, Get.visit_start_date, Get.visit_end_date)))using FunSQL: Bind CorrelatedCondition(person_id, start_date, end_date) = From(:condition_occurrence) | Where(Fun.and(Get.person_id .== Var.PERSON_ID, Fun.between(Get.condition_start_date, Var.START_DATE, Var.END_DATE))) | Bind(:PERSON_ID => person_id, :START_DATE => start_date, :END_DATE => end_date) q = From(:visit_occurrence) | Where(Fun.exists(CorrelatedCondition(Get.person_id, Get.visit_start_date, Get.visit_end_date)))- Define the inner query using
Use Partition and Window functions
masterThe
Partitionnode allows you to relate each row to other rows in the same dataset without collapsing them into a single row (unlikeGroup). This is used for window functions.- Partitioning: Use
Partition(key, ...)to divide rows into groups. - Ordering: Use the
order_bykeyword argument to specify the sort order within the partition. - Window Frame: Use the
framekeyword argument to specify the subset of rows relative to the current row. Theframeis a tuple:(mode = :rows, start = ..., finish = ...). For example,start = -Infandfinish = -1represents all rows prior to the current row. - Aggregates in Windows: You can apply regular aggregate functions within a
Selectclause after aPartitionto calculate summary values for the window frame.
using FunSQL: Partition q = From(:visit_occurrence) |> Partition(Get.person_id, order_by = [Get.visit_start_date], frame = (mode = :rows, start = -Inf, finish = -1)) |> Select(Get.person_id, Get.visit_start_date, Get.visit_end_date, :gap => Fun.julianday(Get.visit_start_date) .- Fun.julianday(Agg.max(Get.visit_end_date)))- Partitioning: Use
Understand the difference between data-oriented and syntax-oriented query builders
masterFunSQL is a data-oriented query builder. This distinguishes it from syntax-oriented builders (like Active Record or Laravel) in how they handle the order of operations:
Data-oriented (FunSQL, EF/LINQ, dbplyr): The pipeline represents a sequence of data transformations. The order of nodes matters because each node refines the output of the previous one. For example, placing
LimitbeforeWherechanges the result from "all filtered records" to "the first N records, then filtered". FunSQL achieves this by converting the pipeline into nested SQL queries (using subqueries inFROMclauses) to respect SQL's rigid clause order while allowing arbitrary pipeline composition.Syntax-oriented (Active Record, Laravel): The pipeline incrementally populates a fixed SQL syntax tree (a
structwith slots forSELECT,FROM,WHERE, etc.). The order of calls does not change the final SQL because they are all just filling different slots in the same structure.
Why use FunSQL? While syntax-oriented builders are easier to implement, they inherit SQL's rigid grammar and deficiencies. Data-oriented builders like FunSQL allow for more natural, compositional data processing pipelines that can be mapped to complex SQL features like correlated subqueries and lateral joins.
Assemble queries incrementally using FunSQL
masterFunSQL allows you to build complex queries by assembling small, reusable, and testable components. Instead of writing a single monolithic SQL statement, you can define functions that return query objects (using
From,Join,Where, etc.), validate them individually by executing them against a database connection, and then compose them into larger pipelines.Key patterns for incremental assembly:
- Define Concepts: Create functions that represent medical or domain concepts (e.g., using
SNOMED()orVISIT()). - Define Occurrences: Create functions that represent rows in a table filtered by those concepts (e.g., using
From(:table) |> Join(...)). - Compose with Logic: Use higher-order functions like
Where(Fun.exists(...))to correlate different occurrence datasets. - Apply Filters: Use window functions or partitioning (e.g.,
Partition(...) |> Define(...)) to create complex filtering logic like time-gap requirements.
# Example of composing a complex query from smaller parts MyocardialInfarctionDuringInpatientVisit() = MyocardialInfarctionOccurrence() | Where(Fun.exists(CorrelatedInpatientVisit(Get.person_id, Get.condition_start_date))) # Execute the composed query DBInterface.execute(conn, MyocardialInfarctionDuringInpatientVisit()) |> DataFrame- Define Concepts: Create functions that represent medical or domain concepts (e.g., using
Perform Aggregations and SELECT DISTINCT
masterFunSQL uses the
Groupnode to handle both aggregations and distinct selections:- Aggregations: Apply
Group()(without arguments) followed bySelect(Agg.count())to calculate aggregate values for the whole dataset. - SELECT DISTINCT: Apply
Group(column)without any aggregate functions in theSelectclause. FunSQL will render this as aSELECT DISTINCTstatement.
# Aggregate: COUNT(*) q_count = From(:person) | Group() | Select(Agg.count()) # Distinct: SELECT DISTINCT state q_distinct = From(:location) | Group(Get.state)- Aggregations: Apply
Handle output columns in a `Join` using `As`
masterWhen performing a
Join, theAsnode is used to disambiguate columns between the two branches.Important Behavior:
- By default, columns wrapped in an
Asnode are not present in the final output of theJoinnode. - To include columns from both branches of a join, you must use a
Selectnode following the join. - When using
Selectto combine columns from joined tables, you must ensure all column names are unique to avoid aFunSQL.DuplicateLabelError.
To avoid duplicate labels, you can use the
tailkeyword inGetto reference columns from a specific branch, and filter out keys that already exist in the first branch.# 1. Join with disambiguation (columns in As are hidden by default) q = From(:person) |> As(:person) |> Join(From(:visit_occurrence), on = Get.person.person_id .== Get.person_id) # 2. Select columns from both branches while avoiding duplicates const person_table = conn.catalog[:person] const visit_occurrence_table = conn.catalog[:visit_occurrence] q_with_all_columns = q |> Select( Get.(keys(person_table.columns))..., Get.(filter(!in(keys(person_table.columns)), collect(keys(visit_occurrence_table.columns))), tail = Get.person)... )- By default, columns wrapped in an
Why use FunSQL instead of raw SQL strings?
masterWhile raw SQL strings can be embedded in Julia using
DBInterface.execute(conn, "SELECT ..."), FunSQL allows you to construct a query object (aSQLNode).This approach is preferred for:
- Parameterization: Building complex, dynamic queries based on runtime variables (e.g., date ranges, lists of states) is robust and avoids error-prone string concatenation.
- Composability: You can create custom functions that return
SQLNodeobjects, which can then be used as building blocks in larger queries. - Structure: Queries are treated as composite data structures rather than opaque text.
Use WHERE, ORDER, and LIMIT in FunSQL
masterFunSQL provides
Where,Order, andLimitnodes. A key difference from standard SQL is that FunSQL allows you to apply these operations in any order. If you apply aLimitbefore aWhere, FunSQL will automatically wrap the limited query in a subquery to ensure the filter is applied correctly to the resulting set.# Example: Top 3 oldest male patients q = From(:person) | Where(Get.gender_concept_id .== 8507) | Order(Get.year_of_birth) | Limit(3) # Example: Filtering the result of a limited set (uses subquery) q = From(:person) | Order(Get.year_of_birth) | Limit(3) | Where(Get.gender_concept_id .== 8507)Building a domain-specific language for concept sets
masterFunSQL can be used to build high-level, domain-specific languages (DSLs) by composing low-level query primitives. In healthcare data, this is often used to manage 'concept sets'—groups of medical terms (concepts) that include a target term, its subtypes, or exclusions.
By defining functions that return FunSQL query objects, you can create a compact, readable notation that abstracts away complex SQL logic like recursive CTEs, multiple joins, and set operations (including/excluding).
# Example of a high-level DSL function SNOMED(codes...) = ConceptByCode("SNOMED", codes...) # Composing a complex query pipeline q = SNOMED("22298006") |> WithSubtypes() |> ExcludingConcepts(SNOMED("70422006") |> WithSubtypes())Summarize data using Group and Aggregate functions
masterTo summarize data (e.g., calculating totals or averages), use the
Groupnode to partition rows into disjoint groups, followed by theAggnode to apply aggregate functions to those groups.- Grouping by keys: Pass arguments to
Group(...)to define the grouping key. The output contains all distinct values of the grouping key. - Global aggregation: To apply an aggregate function to the entire dataset (equivalent to omitting
GROUP BYin SQL), useGroup()without arguments. - Aggregate functions: Created using the
Aggnode (e.g.,Agg.count(),Agg.avg(),Agg.max()). - Flexibility: Unlike SQL, FunSQL allows aggregate functions in any context where an expression is permitted, including
Whereclauses (which replaces the need for aHAVINGclause).
using FunSQL: Agg, Group # Grouping by a key q = From(:person) |> Group(Get.year_of_birth) |> Select(Get.year_of_birth, Agg.count()) # Global aggregation (no GROUP BY) q = From(:person) |> Group() |> Select(Agg.avg(Get.year_of_birth))- Grouping by keys: Pass arguments to
Perform Joins and Left Joins
masterThe
Joinnode correlates rows from two datasets.Join: Requires the first pipeline to be attached via|>, the second pipeline to be provided as an argument, and anoncondition.LeftJoin: An alias forJoin(..., left = true), used to ensure all records from the primary dataset are kept.- Disambiguation: When joining tables with overlapping column names, use the
Asnode or the=>operator to nest one dataset's columns into a record, preventing name collisions.
Alternatively, you can specify both pipelines using
tail(primary) andjoinee(secondary) keyword arguments.using FunSQL: From, LeftJoin, As, Get # Using LeftJoin with disambiguation via As From(:person) | LeftJoin(From(:location) |> As(:location), on = Get.location_id .== Get.location.location_id) | Select(Get.person_id, Get.location.state) # Alternative syntax using keyword arguments Join(tail = From(:person), joinee = :location => From(:location), on = Get.location_id .== Get.location.location_id, left = true) | Select(Get.person_id, Get.location.state)