use octofhir_fhirpath::parse_ast;
use crate::Error;
use crate::column::ColumnType;
use crate::view_definition::ViewDefinition;
mod boundary;
mod constants;
mod ddl;
mod dialect;
mod lower;
pub use ddl::{Dialect, create_table};
use lower::Lower;
pub(crate) use constants::{build_constants, substitute_constants};
pub struct SqlGenerator {
table_pattern: String,
row_filter: Option<String>,
dialect: Dialect,
}
impl Default for SqlGenerator {
fn default() -> Self {
Self::new()
}
}
impl SqlGenerator {
pub fn new() -> Self {
Self {
table_pattern: "base".to_string(),
row_filter: Some("{base}.status <> 'deleted'".to_string()),
dialect: Dialect::Postgres,
}
}
pub fn with_table_pattern(table_pattern: impl Into<String>) -> Self {
Self {
table_pattern: table_pattern.into(),
..Self::new()
}
}
pub fn with_dialect(mut self, dialect: Dialect) -> Self {
self.dialect = dialect;
self
}
pub fn with_row_filter(mut self, filter: Option<String>) -> Self {
self.row_filter = filter;
self
}
pub fn generate(&self, view: &ViewDefinition) -> Result<GeneratedSql, Error> {
if view.resource.trim().is_empty() {
return Err(Error::InvalidViewDefinition(
"ViewDefinition is missing the required `resource`".to_string(),
));
}
let constants = build_constants(view)?;
let table = view.resource.to_lowercase();
let ctx0 = format!("{}.resource", self.table_pattern);
let lower = Lower::new(view.resource.clone(), constants, ctx0.clone(), self.dialect);
let mut plans = vec![Plan::empty()];
for select in &view.select {
let mut next = Vec::new();
for p in &plans {
for child in lower.build_select(select, &p.joins, &ctx0)? {
next.push(p.cross(&child));
}
}
plans = next;
}
if plans.is_empty() || plans.iter().all(|p| p.columns.is_empty()) {
return Err(Error::InvalidViewDefinition(
"ViewDefinition produces no columns".to_string(),
));
}
let shape: Vec<&str> = plans[0].columns.iter().map(|c| c.name.as_str()).collect();
for p in &plans[1..] {
let other: Vec<&str> = p.columns.iter().map(|c| c.name.as_str()).collect();
if other != shape {
return Err(Error::InvalidViewDefinition(
"unionAll branches have mismatched column shape".to_string(),
));
}
}
let mut where_sql = Vec::new();
for clause in &view.where_ {
let path = lower.substitute(&clause.path)?;
let ast = parse_ast(&path).map_err(|e| Error::FhirPath(e.to_string()))?;
where_sql.push(lower.bool(&ast, &ctx0)?);
}
let select_sqls: Vec<String> = plans
.iter()
.map(|p| self.render_plan(&table, p, &where_sql))
.collect();
let sql = select_sqls.join(" UNION ALL ");
let columns = plans[0]
.columns
.iter()
.map(|c| GeneratedColumn {
name: c.name.clone(),
expression: c.expr.clone(),
alias: c.name.clone(),
col_type: c.col_type,
})
.collect();
Ok(GeneratedSql {
sql,
columns,
ctes: Vec::new(),
})
}
fn render_plan(&self, table: &str, plan: &Plan, where_sql: &[String]) -> String {
let cols: Vec<String> = plan
.columns
.iter()
.map(|c| format!("{} AS \"{}\"", c.expr, c.name.replace('"', "\"\"")))
.collect();
let mut sql = format!(
"SELECT {} FROM {} {}",
cols.join(", "),
table,
self.table_pattern
);
for j in &plan.joins {
sql.push(' ');
sql.push_str(j);
}
let mut conds = Vec::new();
if let Some(f) = &self.row_filter {
conds.push(f.replace("{base}", &self.table_pattern));
}
for w in where_sql {
conds.push(format!("({w})"));
}
if !conds.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conds.join(" AND "));
}
sql
}
}
#[derive(Debug, Clone)]
struct PlanColumn {
name: String,
expr: String,
col_type: ColumnType,
}
#[derive(Debug, Clone)]
struct Plan {
joins: Vec<String>,
columns: Vec<PlanColumn>,
}
impl Plan {
fn empty() -> Self {
Self {
joins: Vec::new(),
columns: Vec::new(),
}
}
fn cross(&self, child: &Plan) -> Plan {
let mut columns = self.columns.clone();
columns.extend(child.columns.iter().cloned());
Plan {
joins: child.joins.clone(),
columns,
}
}
}
#[derive(Debug, Clone)]
pub struct GeneratedSql {
pub sql: String,
pub columns: Vec<GeneratedColumn>,
pub ctes: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GeneratedColumn {
pub name: String,
pub expression: String,
pub alias: String,
pub col_type: ColumnType,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn build_sql(view: serde_json::Value) -> GeneratedSql {
let v = ViewDefinition::from_json(&view).unwrap();
SqlGenerator::new().generate(&v).unwrap()
}
#[test]
fn simple_columns() {
let g = build_sql(json!({
"resource": "Patient",
"select": [{ "column": [
{ "name": "id", "path": "id", "type": "id" },
{ "name": "gender", "path": "gender", "type": "code" }
] }]
}));
assert!(g.sql.contains("FROM patient base"));
assert_eq!(g.columns.len(), 2);
assert_eq!(g.columns[0].name, "id");
}
#[test]
fn collection_column_is_json() {
let g = build_sql(json!({
"resource": "Patient",
"select": [{ "column": [
{ "name": "fam", "path": "name.family", "type": "string", "collection": true }
] }]
}));
assert_eq!(g.columns[0].col_type, ColumnType::Json);
}
#[test]
fn union_shape_mismatch_errors() {
let v = ViewDefinition::from_json(&json!({
"resource": "Patient",
"select": [{ "unionAll": [
{ "column": [{ "name": "a", "path": "id" }, { "name": "b", "path": "id" }] },
{ "column": [{ "name": "a", "path": "id" }, { "name": "c", "path": "id" }] }
] }]
}))
.unwrap();
assert!(SqlGenerator::new().generate(&v).is_err());
}
#[test]
fn undefined_constant_errors() {
let v = ViewDefinition::from_json(&json!({
"resource": "Patient",
"select": [{ "forEach": "name.where(use = %missing)",
"column": [{ "name": "f", "path": "family" }] }]
}))
.unwrap();
assert!(SqlGenerator::new().generate(&v).is_err());
}
#[test]
fn missing_resource_errors() {
let v = ViewDefinition::from_json(&json!({
"resource": "",
"select": [{ "column": [{ "name": "id", "path": "id" }] }]
}))
.unwrap();
assert!(SqlGenerator::new().generate(&v).is_err());
}
}