Skip to content

Commit ccd51eb

Browse files
authored
Merge pull request #9 from stackql-labs/claude/resource-name-template-access-9Zz51
Add resource_name variable to template context
2 parents 7202202 + 8f94265 commit ccd51eb

1 file changed

Lines changed: 198 additions & 2 deletions

File tree

src/core/config.rs

Lines changed: 198 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ pub fn render_properties(
284284

285285
/// Build the full context for a resource by merging global context with resource properties.
286286
/// Matches Python's `get_full_context`.
287+
///
288+
/// Injects `resource_name` as a special variable (like `stack_name` and `stack_env`)
289+
/// containing the current resource's name. Any global values that contain deferred
290+
/// template expressions (e.g., `{{ resource_name }}`) are re-rendered at this point.
287291
pub fn get_full_context(
288292
engine: &TemplateEngine,
289293
global_context: &HashMap<String, String>,
@@ -292,9 +296,19 @@ pub fn get_full_context(
292296
) -> HashMap<String, String> {
293297
debug!("Getting full context for {}...", resource.name);
294298

295-
let prop_context = render_properties(engine, &resource.props, global_context, stack_env);
299+
// Inject resource_name into the context so it's available in props and re-rendered globals
300+
let mut context_with_resource_name = global_context.clone();
301+
context_with_resource_name.insert("resource_name".to_string(), resource.name.clone());
296302

297-
let mut full_context = global_context.clone();
303+
// Re-render any global values that contain deferred template expressions.
304+
// This allows globals (e.g., global_tags) to use {{ resource_name }} which couldn't
305+
// be resolved at global rendering time since the resource wasn't known yet.
306+
let resolved_context =
307+
re_render_context_with_deferred_vars(engine, &context_with_resource_name);
308+
309+
let prop_context = render_properties(engine, &resource.props, &resolved_context, stack_env);
310+
311+
let mut full_context = resolved_context;
298312
for (k, v) in prop_context {
299313
full_context.insert(k, v);
300314
}
@@ -303,6 +317,39 @@ pub fn get_full_context(
303317
full_context
304318
}
305319

320+
/// Re-render context values that contain deferred template expressions (`{{ ... }}`).
321+
/// This is used to resolve variables like `resource_name` that weren't available
322+
/// when globals were initially rendered.
323+
fn re_render_context_with_deferred_vars(
324+
engine: &TemplateEngine,
325+
context: &HashMap<String, String>,
326+
) -> HashMap<String, String> {
327+
let mut result = context.clone();
328+
329+
for (key, value) in context {
330+
if value.contains("{{") {
331+
match engine.render(value, context) {
332+
Ok(rendered) => {
333+
let rendered = rendered.replace("True", "true").replace("False", "false");
334+
debug!(
335+
"Re-rendered deferred global [{}]: {} -> {}",
336+
key, value, rendered
337+
);
338+
result.insert(key.clone(), rendered);
339+
}
340+
Err(e) => {
341+
debug!(
342+
"Warning: could not re-render deferred global '{}': {}",
343+
key, e
344+
);
345+
}
346+
}
347+
}
348+
}
349+
350+
result
351+
}
352+
306353
/// Prepare context for SQL query rendering.
307354
/// JSON string values are re-serialized to ensure proper format (compact, lowercase bools).
308355
/// Matches Python's `render_queries` context preparation.
@@ -351,3 +398,152 @@ pub fn is_json(s: &str) -> bool {
351398
Err(_) => false,
352399
}
353400
}
401+
402+
#[cfg(test)]
403+
mod tests {
404+
use super::*;
405+
use crate::resource::manifest::{Property, Resource};
406+
407+
/// Helper to create a minimal Resource for testing.
408+
fn make_resource(name: &str, props: Vec<Property>) -> Resource {
409+
Resource {
410+
name: name.to_string(),
411+
r#type: "resource".to_string(),
412+
file: None,
413+
sql: None,
414+
run: None,
415+
props,
416+
exports: vec![],
417+
protected: vec![],
418+
description: String::new(),
419+
r#if: None,
420+
skip_validation: None,
421+
auth: None,
422+
}
423+
}
424+
425+
/// Helper to create a Property with a simple string value.
426+
fn make_prop(name: &str, value: &str) -> Property {
427+
Property {
428+
name: name.to_string(),
429+
value: Some(serde_yaml::Value::String(value.to_string())),
430+
values: None,
431+
description: String::new(),
432+
merge: None,
433+
}
434+
}
435+
436+
#[test]
437+
fn test_resource_name_available_in_full_context() {
438+
let engine = TemplateEngine::new();
439+
let mut global_context = HashMap::new();
440+
global_context.insert("stack_name".to_string(), "my-stack".to_string());
441+
global_context.insert("stack_env".to_string(), "dev".to_string());
442+
443+
let resource = make_resource("cross_account_role", vec![]);
444+
445+
let ctx = get_full_context(&engine, &global_context, &resource, "dev");
446+
447+
assert_eq!(ctx.get("resource_name").unwrap(), "cross_account_role");
448+
// Existing variables still present
449+
assert_eq!(ctx.get("stack_name").unwrap(), "my-stack");
450+
assert_eq!(ctx.get("stack_env").unwrap(), "dev");
451+
}
452+
453+
#[test]
454+
fn test_resource_name_usable_in_props() {
455+
let engine = TemplateEngine::new();
456+
let mut global_context = HashMap::new();
457+
global_context.insert("stack_name".to_string(), "my-stack".to_string());
458+
global_context.insert("stack_env".to_string(), "dev".to_string());
459+
460+
let resource = make_resource(
461+
"cross_account_role",
462+
vec![make_prop("tag_value", "{{ resource_name }}")],
463+
);
464+
465+
let ctx = get_full_context(&engine, &global_context, &resource, "dev");
466+
467+
assert_eq!(ctx.get("tag_value").unwrap(), "cross_account_role");
468+
}
469+
470+
#[test]
471+
fn test_resource_name_resolves_in_deferred_globals() {
472+
let engine = TemplateEngine::new();
473+
let mut global_context = HashMap::new();
474+
global_context.insert("stack_name".to_string(), "my-stack".to_string());
475+
global_context.insert("stack_env".to_string(), "dev".to_string());
476+
// Simulate a global that was rendered at startup but contained {{ resource_name }}
477+
// which couldn't be resolved then, so it's preserved as a literal.
478+
global_context.insert(
479+
"global_tags".to_string(),
480+
r#"[{"Key":"stackql:resource-name","Value":"{{ resource_name }}"}]"#.to_string(),
481+
);
482+
483+
let resource = make_resource("cross_account_role", vec![]);
484+
485+
let ctx = get_full_context(&engine, &global_context, &resource, "dev");
486+
487+
let global_tags = ctx.get("global_tags").unwrap();
488+
assert!(
489+
global_tags.contains("cross_account_role"),
490+
"global_tags should contain the resolved resource name, got: {}",
491+
global_tags
492+
);
493+
assert!(
494+
!global_tags.contains("{{ resource_name }}"),
495+
"global_tags should not contain unresolved template expression"
496+
);
497+
}
498+
499+
#[test]
500+
fn test_resource_name_varies_per_resource() {
501+
let engine = TemplateEngine::new();
502+
let mut global_context = HashMap::new();
503+
global_context.insert("stack_name".to_string(), "my-stack".to_string());
504+
global_context.insert("stack_env".to_string(), "dev".to_string());
505+
global_context.insert(
506+
"global_tags".to_string(),
507+
r#"[{"Key":"res","Value":"{{ resource_name }}"}]"#.to_string(),
508+
);
509+
510+
let res1 = make_resource("vpc_network", vec![]);
511+
let res2 = make_resource("storage_bucket", vec![]);
512+
513+
let ctx1 = get_full_context(&engine, &global_context, &res1, "dev");
514+
let ctx2 = get_full_context(&engine, &global_context, &res2, "dev");
515+
516+
assert_eq!(ctx1.get("resource_name").unwrap(), "vpc_network");
517+
assert_eq!(ctx2.get("resource_name").unwrap(), "storage_bucket");
518+
assert!(ctx1.get("global_tags").unwrap().contains("vpc_network"));
519+
assert!(ctx2.get("global_tags").unwrap().contains("storage_bucket"));
520+
}
521+
522+
#[test]
523+
fn test_re_render_context_no_templates_is_noop() {
524+
let engine = TemplateEngine::new();
525+
let mut context = HashMap::new();
526+
context.insert("stack_name".to_string(), "my-stack".to_string());
527+
context.insert("plain_value".to_string(), "no templates here".to_string());
528+
529+
let result = re_render_context_with_deferred_vars(&engine, &context);
530+
531+
assert_eq!(result.get("stack_name").unwrap(), "my-stack");
532+
assert_eq!(result.get("plain_value").unwrap(), "no templates here");
533+
}
534+
535+
#[test]
536+
fn test_re_render_context_resolves_deferred_vars() {
537+
let engine = TemplateEngine::new();
538+
let mut context = HashMap::new();
539+
context.insert("resource_name".to_string(), "my_resource".to_string());
540+
context.insert(
541+
"tag".to_string(),
542+
"resource:{{ resource_name }}".to_string(),
543+
);
544+
545+
let result = re_render_context_with_deferred_vars(&engine, &context);
546+
547+
assert_eq!(result.get("tag").unwrap(), "resource:my_resource");
548+
}
549+
}

0 commit comments

Comments
 (0)