use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process;
use log::{debug, error};
use regex::Regex;
use crate::core::config::prepare_query_context;
use crate::resource::manifest::Resource;
use crate::template::engine::TemplateEngine;
#[derive(Debug, Clone)]
pub struct ParsedQuery {
pub template: String,
pub options: QueryOptions,
}
#[derive(Debug, Clone, Default)]
pub struct QueryOptions {
pub retries: u32,
pub retry_delay: u32,
pub postdelete_retries: u32,
pub postdelete_retry_delay: u32,
pub short_circuit_field: Option<String>,
pub short_circuit_value: Option<String>,
}
fn parse_anchor(anchor: &str) -> (String, HashMap<String, u32>, HashMap<String, String>) {
let parts: Vec<&str> = anchor.split(',').collect();
let key = parts[0].trim().to_lowercase();
let mut uint_options: HashMap<String, u32> = HashMap::new();
let mut str_options: HashMap<String, String> = HashMap::new();
for part in &parts[1..] {
if let Some((option_key, option_value)) = part.split_once('=') {
let k = option_key.trim().to_string();
let v = option_value.trim().to_string();
if let Ok(uint_val) = v.parse::<u32>() {
uint_options.insert(k, uint_val);
} else {
str_options.insert(k, v);
}
}
}
(key, uint_options, str_options)
}
type SqlQueriesResult = (
HashMap<String, String>,
HashMap<String, HashMap<String, u32>>,
HashMap<String, HashMap<String, String>>,
);
fn load_sql_queries(file_path: &Path) -> SqlQueriesResult {
let content = match fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
error!("Failed to read query file {:?}: {}", file_path, e);
process::exit(1);
}
};
let mut queries: HashMap<String, String> = HashMap::new();
let mut uint_options: HashMap<String, HashMap<String, u32>> = HashMap::new();
let mut str_options: HashMap<String, HashMap<String, String>> = HashMap::new();
let mut current_anchor: Option<String> = None;
let mut query_buffer: Vec<String> = Vec::new();
for line in content.lines() {
if line.trim_start().starts_with("/*+") && line.contains("*/") {
if let Some(ref anchor) = current_anchor {
if !query_buffer.is_empty() {
let (anchor_key, anchor_uint_opts, anchor_str_opts) = parse_anchor(anchor);
queries.insert(
anchor_key.clone(),
query_buffer.join("\n").trim().to_string(),
);
uint_options.insert(anchor_key.clone(), anchor_uint_opts);
str_options.insert(anchor_key, anchor_str_opts);
query_buffer.clear();
}
}
let start = line.find("/*+").unwrap() + 3;
let end = line.find("*/").unwrap();
current_anchor = Some(line[start..end].trim().to_string());
} else {
query_buffer.push(line.to_string());
}
}
if let Some(ref anchor) = current_anchor {
if !query_buffer.is_empty() {
let (anchor_key, anchor_uint_opts, anchor_str_opts) = parse_anchor(anchor);
queries.insert(
anchor_key.clone(),
query_buffer.join("\n").trim().to_string(),
);
uint_options.insert(anchor_key.clone(), anchor_uint_opts);
str_options.insert(anchor_key, anchor_str_opts);
}
}
(queries, uint_options, str_options)
}
pub fn preprocess_inline_dicts(template: &str, context: &mut HashMap<String, String>) -> String {
let re = Regex::new(r"\{\{\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}\s*\|\s*(\w+)\s*\}\}").unwrap();
let mut result = template.to_string();
let mut counter = 0;
loop {
let captures = re.captures(&result);
if captures.is_none() {
break;
}
let caps = captures.unwrap();
let full_match = caps.get(0).unwrap();
let dict_body = caps.get(1).unwrap().as_str().trim();
let filter_name = caps.get(2).unwrap().as_str();
let mut obj = serde_json::Map::new();
for entry in split_dict_entries(dict_body) {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if let Some((key_part, val_part)) = entry.split_once(':') {
let key = key_part
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
let var_name = val_part.trim();
let value = context.get(var_name).cloned().unwrap_or_default();
let json_val = match serde_json::from_str::<serde_json::Value>(&value) {
Ok(v) => v,
Err(_) => serde_json::Value::String(value),
};
obj.insert(key, json_val);
}
}
let var_name = format!("__inline_dict_{}", counter);
let json_str = serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default();
context.insert(var_name.clone(), json_str);
let replacement = format!("{{{{ {} | {} }}}}", var_name, filter_name);
result = format!(
"{}{}{}",
&result[..full_match.start()],
replacement,
&result[full_match.end()..]
);
counter += 1;
}
result
}
fn split_dict_entries(s: &str) -> Vec<String> {
let mut entries = Vec::new();
let mut current = String::new();
let mut brace_depth = 0;
let mut in_quote = false;
let mut quote_char = ' ';
for ch in s.chars() {
match ch {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = ch;
current.push(ch);
}
c if in_quote && c == quote_char => {
in_quote = false;
current.push(ch);
}
'{' if !in_quote => {
brace_depth += 1;
current.push(ch);
}
'}' if !in_quote => {
brace_depth -= 1;
current.push(ch);
}
',' if !in_quote && brace_depth == 0 => {
entries.push(current.trim().to_string());
current.clear();
}
_ => {
current.push(ch);
}
}
}
if !current.trim().is_empty() {
entries.push(current.trim().to_string());
}
entries
}
fn preprocess_jinja2_compat(template: &str) -> String {
let mut result = template.to_string();
let uuid_re = Regex::new(r"\{\{\s*uuid\(\)\s*\}\}").unwrap();
result = uuid_re.replace_all(&result, "{{ uuid }}").to_string();
let replace_re =
Regex::new(r#"replace\(\s*['"]([^'"]*)['"]\s*,\s*['"]([^'"]*)['"]\s*\)"#).unwrap();
result = replace_re
.replace_all(&result, r#"replace(from="$1", to="$2")"#)
.to_string();
result
}
pub fn render_query(
engine: &TemplateEngine,
res_name: &str,
anchor: &str,
template: &str,
context: &HashMap<String, String>,
) -> String {
let temp_context = prepare_query_context(context);
let expanded = match preprocess_this_prefix(template, res_name) {
Ok(t) => t,
Err(e) => {
crate::core::utils::catch_error_and_exit(&format!("[{}] [{}] {}", res_name, anchor, e));
}
};
let mut ctx = temp_context;
let compat_query = preprocess_jinja2_compat(&expanded);
let processed_query = preprocess_inline_dicts(&compat_query, &mut ctx);
let template_name = format!("{}__{}", res_name, anchor);
match engine.render_with_filters(&template_name, &processed_query, &ctx) {
Ok(rendered) => {
let unresolved_re = Regex::new(r"\{\{[^}]+\}\}").unwrap();
if let Some(m) = unresolved_re.find(&rendered) {
crate::core::utils::catch_error_and_exit(&format!(
"Unresolved template variable in [{}] [{}]: '{}'\n\nRendered query:\n{}\n",
res_name,
anchor,
m.as_str(),
rendered
));
}
debug!(
"Rendered [{}] [{}] query:\n\n{}\n",
res_name, anchor, rendered
);
rendered
}
Err(e) => {
error!(
"Error rendering query for [{}] [{}]: {}",
res_name, anchor, e
);
let re = Regex::new(r"\{\{\s*(\w+)").unwrap();
let referenced_vars: Vec<&str> = re
.captures_iter(&processed_query)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
let missing: Vec<&&str> = referenced_vars
.iter()
.filter(|v| !ctx.contains_key(**v))
.collect();
if !missing.is_empty() {
error!(
"Missing variables in context for [{}] [{}]: {:?}",
res_name, anchor, missing
);
error!(
"Hint: ensure these properties are defined in the manifest for resource [{}], \
or that the .iql template only references variables provided by the manifest.",
res_name
);
}
debug!(
"[{}] [{}] available context keys: {:?}",
res_name,
anchor,
ctx.keys().collect::<Vec<_>>()
);
crate::core::utils::catch_error_and_exit(&format!(
"Failed to render query for [{}] [{}]",
res_name, anchor
));
}
}
}
pub fn try_render_query(
engine: &TemplateEngine,
res_name: &str,
anchor: &str,
template: &str,
context: &HashMap<String, String>,
) -> Option<String> {
let temp_context = prepare_query_context(context);
let expanded = match preprocess_this_prefix(template, res_name) {
Ok(t) => t,
Err(_) => return None,
};
let mut ctx = temp_context;
let compat_query = preprocess_jinja2_compat(&expanded);
let processed_query = preprocess_inline_dicts(&compat_query, &mut ctx);
let template_name = format!("{}__{}", res_name, anchor);
match engine.render_with_filters(&template_name, &processed_query, &ctx) {
Ok(rendered) => {
let unresolved_re = Regex::new(r"\{\{[^}]+\}\}").unwrap();
if unresolved_re.is_match(&rendered) {
debug!(
"Unresolved variables in [{}] [{}], deferring render",
res_name, anchor
);
return None;
}
debug!(
"Rendered [{}] [{}] query:\n\n{}\n",
res_name, anchor, rendered
);
Some(rendered)
}
Err(_) => None,
}
}
pub fn get_queries(
_engine: &TemplateEngine,
stack_dir: &str,
resource: &Resource,
_full_context: &HashMap<String, String>,
) -> HashMap<String, ParsedQuery> {
let mut result = HashMap::new();
let template_path = if let Some(ref file) = resource.file {
Path::new(stack_dir).join("resources").join(file)
} else {
Path::new(stack_dir)
.join("resources")
.join(format!("{}.iql", resource.name))
};
if !template_path.exists() {
error!("Query file not found: {:?}", template_path);
process::exit(1);
}
let (query_templates, query_uint_options, query_str_options) = load_sql_queries(&template_path);
for (anchor, template) in &query_templates {
let normalized_anchor = match anchor.as_str() {
"preflight" => "exists".to_string(),
"postdeploy" => "statecheck".to_string(),
other => other.to_string(),
};
let uint_opts = query_uint_options.get(anchor).cloned().unwrap_or_default();
let str_opts = query_str_options.get(anchor).cloned().unwrap_or_default();
result.insert(
normalized_anchor.clone(),
ParsedQuery {
template: template.clone(),
options: QueryOptions {
retries: *uint_opts.get("retries").unwrap_or(&1),
retry_delay: *uint_opts.get("retry_delay").unwrap_or(&0),
postdelete_retries: *uint_opts.get("postdelete_retries").unwrap_or(&10),
postdelete_retry_delay: *uint_opts.get("postdelete_retry_delay").unwrap_or(&5),
short_circuit_field: str_opts.get("short_circuit_field").cloned(),
short_circuit_value: str_opts.get("short_circuit_value").cloned(),
},
},
);
}
debug!(
"Queries for [{}]: {:?}",
resource.name,
result.keys().collect::<Vec<_>>()
);
result
}
pub fn preprocess_this_prefix(template: &str, resource_name: &str) -> Result<String, String> {
if !template.contains("this.") {
return Ok(template.to_string());
}
if resource_name.is_empty() {
return Err(
"Template uses 'this.' prefix but no resource context is active; \
'this.' is only valid inside a resource's .iql file."
.to_string(),
);
}
let replacement = format!("{}.", resource_name);
let var_re = Regex::new(r"(?s)\{\{(.*?)\}\}").unwrap();
let with_vars = var_re.replace_all(template, |caps: ®ex::Captures| {
let inner = caps[1].replace("this.", &replacement);
format!("{{{{{}}}}}", inner)
});
let tag_re = Regex::new(r"(?s)\{%(.*?)%\}").unwrap();
let with_tags = tag_re.replace_all(&with_vars, |caps: ®ex::Captures| {
let inner = caps[1].replace("this.", &replacement);
format!("{{%{}%}}", inner)
});
Ok(with_tags.to_string())
}
pub fn render_inline_template(
engine: &TemplateEngine,
resource_name: &str,
template_string: &str,
full_context: &HashMap<String, String>,
) -> String {
debug!(
"[{}] inline template:\n\n{}\n",
resource_name, template_string
);
let mut temp_context = prepare_query_context(full_context);
let expanded = match preprocess_this_prefix(template_string, resource_name) {
Ok(t) => t,
Err(e) => {
error!("[{}] inline template: {}", resource_name, e);
process::exit(1);
}
};
let compat = preprocess_jinja2_compat(&expanded);
let processed = preprocess_inline_dicts(&compat, &mut temp_context);
let template_name = format!("{}__inline", resource_name);
match engine.render_with_filters(&template_name, &processed, &temp_context) {
Ok(rendered) => {
debug!(
"[{}] rendered inline template:\n\n{}\n",
resource_name, rendered
);
rendered
}
Err(e) => {
error!(
"Error rendering inline template for [{}]: {}",
resource_name, e
);
let re = Regex::new(r"\{\{\s*(\w+)").unwrap();
let referenced_vars: Vec<&str> = re
.captures_iter(&processed)
.filter_map(|c| c.get(1).map(|m| m.as_str()))
.collect();
let missing: Vec<&&str> = referenced_vars
.iter()
.filter(|v| !temp_context.contains_key(**v))
.collect();
if !missing.is_empty() {
error!(
"Missing variables in context for [{}]: {:?}",
resource_name, missing
);
error!(
"Hint: ensure these properties are defined in the manifest for resource [{}], \
or that the inline SQL only references variables provided by the manifest.",
resource_name
);
}
debug!(
"[{}] available context keys: {:?}",
resource_name,
temp_context.keys().collect::<Vec<_>>()
);
process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::template::engine::TemplateEngine;
#[test]
fn test_preprocess_this_prefix_basic_rewrite() {
let result = preprocess_this_prefix("{{ this.fred }}", "resource_name_x").unwrap();
assert_eq!(result, "{{ resource_name_x.fred }}");
}
#[test]
fn test_preprocess_this_prefix_noop_when_no_this() {
let template = "{{ fred }}";
let result = preprocess_this_prefix(template, "resource_name_x").unwrap();
assert_eq!(
result, template,
"template without 'this.' should be unchanged"
);
}
#[test]
fn test_preprocess_this_prefix_error_when_no_resource_name() {
let result = preprocess_this_prefix("{{ this.fred }}", "");
assert!(result.is_err(), "empty resource_name should return Err");
let msg = result.unwrap_err();
assert!(
msg.contains("this.") || msg.contains("resource context"),
"error message should mention 'this.' or resource context, got: {}",
msg
);
}
#[test]
fn test_preprocess_this_prefix_multiple_occurrences() {
let template = "{{ this.a }} and {{ this.b }}";
let result = preprocess_this_prefix(template, "my_res").unwrap();
assert_eq!(result, "{{ my_res.a }} and {{ my_res.b }}");
}
#[test]
fn test_preprocess_this_prefix_deep_path() {
let template = "{{ this.callback.ProgressEvent.RequestToken }}";
let result = preprocess_this_prefix(template, "resource_name_x").unwrap();
assert_eq!(
result,
"{{ resource_name_x.callback.ProgressEvent.RequestToken }}"
);
}
#[test]
fn test_preprocess_this_prefix_in_tag_block() {
let template = "{% if this.flag %}yes{% endif %}";
let result = preprocess_this_prefix(template, "res").unwrap();
assert_eq!(result, "{% if res.flag %}yes{% endif %}");
}
#[test]
fn test_preprocess_this_prefix_with_filter() {
let template = "{{ this.tags | from_json }}";
let result = preprocess_this_prefix(template, "my_vpc").unwrap();
assert_eq!(result, "{{ my_vpc.tags | from_json }}");
}
#[test]
fn test_this_resolves_resource_scoped_over_global() {
let engine = TemplateEngine::new();
let mut context = std::collections::HashMap::new();
context.insert("fred".to_string(), "global_fred".to_string());
context.insert(
"resource_name_x.fred".to_string(),
"scoped_fred".to_string(),
);
let expanded = preprocess_this_prefix("{{ this.fred }}", "resource_name_x").unwrap();
let result = engine
.render_with_filters("t", &expanded, &context)
.unwrap();
assert_eq!(
result, "scoped_fred",
"this.fred should resolve to the resource-scoped value, not the global"
);
}
#[test]
fn test_this_resolves_when_only_resource_scoped_exists() {
let engine = TemplateEngine::new();
let mut context = std::collections::HashMap::new();
context.insert(
"resource_name_x.fred".to_string(),
"scoped_only".to_string(),
);
let expanded = preprocess_this_prefix("{{ this.fred }}", "resource_name_x").unwrap();
let result = engine
.render_with_filters("t", &expanded, &context)
.unwrap();
assert_eq!(result, "scoped_only");
}
#[test]
fn test_this_errors_when_only_global_exists_not_resource_scoped() {
let engine = TemplateEngine::new();
let mut context = std::collections::HashMap::new();
context.insert("fred".to_string(), "global_fred".to_string());
let expanded = preprocess_this_prefix("{{ this.fred }}", "resource_name_x").unwrap();
let result = engine.render_with_filters("t", &expanded, &context);
assert!(
result.is_err(),
"this.fred should error when resource_name_x.fred is not in context"
);
}
#[test]
fn test_this_callback_resolves_same_as_scoped_and_shorthand() {
let engine = TemplateEngine::new();
let mut context = std::collections::HashMap::new();
context.insert(
"resource_name_x.callback.ProgressEvent.RequestToken".to_string(),
"token-abc".to_string(),
);
context.insert(
"callback.ProgressEvent.RequestToken".to_string(),
"token-abc".to_string(),
);
let expanded = preprocess_this_prefix(
"{{ this.callback.ProgressEvent.RequestToken }}",
"resource_name_x",
)
.unwrap();
let via_this = engine
.render_with_filters("t1", &expanded, &context)
.unwrap();
let via_explicit = engine
.render_with_filters(
"t2",
"{{ resource_name_x.callback.ProgressEvent.RequestToken }}",
&context,
)
.unwrap();
let via_shorthand = engine
.render_with_filters("t3", "{{ callback.ProgressEvent.RequestToken }}", &context)
.unwrap();
assert_eq!(via_this, "token-abc");
assert_eq!(
via_this, via_explicit,
"this.callback should equal resource_name_x.callback"
);
assert_eq!(
via_this, via_shorthand,
"this.callback should equal shorthand callback"
);
}
}