test: exec

This commit is contained in:
2025-05-17 13:39:40 +00:00
parent 6a2f7cd1fa
commit bcc755d325
8 changed files with 665 additions and 43 deletions

View File

@@ -13,3 +13,9 @@ CREATE FUNCTION "hemar"."jsonb_get_by_path"("json" jsonb, "path" text)
RETURNS jsonb
LANGUAGE C STRICT
AS 'hemar', 'pg_jsonb_get_by_path';
-- Template rendering function
CREATE FUNCTION "hemar"."render"("define" jsonb, "template" text)
RETURNS text
LANGUAGE C STRICT
AS 'hemar', 'pg_template_render';

View File

@@ -21,6 +21,8 @@
/* Forward declarations */
static void template_node_to_string(TemplateNode *node, StringInfo result, int indent);
static void render_template(TemplateNode *node, Jsonb *define, StringInfo result, MemoryContext context);
static void render_execute_tag(const char *code, Jsonb *define, StringInfo result, MemoryContext context);
static const char *
jbt_type_to_string(JsonbIteratorToken type)
@@ -494,9 +496,9 @@ template_parse_section(MemoryContext context, const char **s_ptr,
{
/* This is our matching end tag */
end_tag_start = *s;
break;
}
}
break;
}
}
}
(*s)++;
@@ -679,7 +681,7 @@ template_parse_execute(MemoryContext context, const char **s_ptr,
/* If we've reached the matching closing brace, we're done */
if (brace_level == 0) {
break;
break;
}
*s += strlen(config->Syntax.Braces.close) - 1; /* -1 because we'll increment s below */
@@ -1094,6 +1096,258 @@ template_node_to_string(TemplateNode *node, StringInfo result, int indent)
}
}
/* Template rendering function */
PG_FUNCTION_INFO_V1(pg_template_render);
Datum
pg_template_render(PG_FUNCTION_ARGS)
{
Jsonb *define = PG_GETARG_JSONB_P(0);
text *template_text = PG_GETARG_TEXT_PP(1);
char *template_str = text_to_cstring(template_text);
const char *template_ptr = template_str;
MemoryContext old_context, render_context;
TemplateConfig config;
TemplateNode *root = NULL;
TemplateErrorCode error_code = TEMPLATE_ERROR_NONE;
StringInfoData result;
text *result_text = NULL;
/* Create a memory context for rendering */
render_context = AllocSetContextCreate(CurrentMemoryContext,
"Template Render Context",
ALLOCSET_DEFAULT_SIZES);
/* Switch to the new context for rendering */
old_context = MemoryContextSwitchTo(render_context);
/* Initialize default config */
config = template_default_config(render_context);
PG_TRY();
{
/* Parse the template */
root = template_parse(render_context, &template_ptr, &config, false, &error_code);
/* Check for parsing errors */
if (error_code != TEMPLATE_ERROR_NONE || !root)
{
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
errmsg("Template parsing error: %s", template_error_to_string(error_code, &config))));
}
/* Initialize the result buffer */
initStringInfo(&result);
/* Render the template */
render_template(root, define, &result, render_context);
/* Switch back to the original memory context */
MemoryContextSwitchTo(old_context);
/* Return the result */
result_text = cstring_to_text(result.data);
pfree(result.data);
}
PG_CATCH();
{
/* Switch back to the original memory context for error handling */
MemoryContextSwitchTo(old_context);
/* Clean up */
if (template_str)
pfree(template_str);
/* Delete the render context */
MemoryContextDelete(render_context);
PG_RE_THROW();
}
PG_END_TRY();
/* Clean up */
MemoryContextDelete(render_context);
pfree(template_str);
PG_RETURN_TEXT_P(result_text);
}
/* Helper function to render a template node */
static void
render_template(TemplateNode *node, Jsonb *define, StringInfo result, MemoryContext context)
{
TemplateNode *current = node;
while (current)
{
switch (current->type)
{
case TEMPLATE_NODE_TEXT:
if (current->value->text.content)
appendStringInfoString(result, current->value->text.content);
break;
case TEMPLATE_NODE_EXECUTE:
render_execute_tag(current->value->execute.code, define, result, context);
break;
/* We'll implement these later */
case TEMPLATE_NODE_INTERPOLATE:
case TEMPLATE_NODE_SECTION:
case TEMPLATE_NODE_INCLUDE:
default:
/* Skip for now */
break;
}
current = current->next;
}
}
/* Helper function to calculate a simple hash of a string */
static uint32_t
calculate_string_hash(const char *str)
{
uint32_t hash = 5381;
int c;
while ((c = *str++))
hash = ((hash << 5) + hash) + c; /* hash * 33 + c */
return hash;
}
/* Helper function to render an execute tag */
static void
render_execute_tag(const char *code, Jsonb *define, StringInfo result, MemoryContext context)
{
int ret;
StringInfoData query;
StringInfoData exec_result;
char *trimmed_code;
size_t code_len;
uint32_t code_hash;
char func_name[64];
bool isnull;
bool function_exists;
/* Connect to SPI */
if ((ret = SPI_connect()) < 0)
ereport(ERROR,
(errcode(ERRCODE_CONNECTION_EXCEPTION),
errmsg("SPI_connect failed: %s", SPI_result_code_string(ret))));
/* Create the query with the context variable */
initStringInfo(&query);
initStringInfo(&exec_result);
/* Trim trailing semicolon if present to avoid double semicolons */
code_len = strlen(code);
trimmed_code = pstrdup(code);
while (code_len > 0 && (trimmed_code[code_len-1] == ';' || isspace((unsigned char)trimmed_code[code_len-1]))) {
trimmed_code[--code_len] = '\0';
}
/* Calculate hash of the code */
code_hash = calculate_string_hash(trimmed_code);
snprintf(func_name, sizeof(func_name), "cache-%x", code_hash);
/* Check if function exists */
appendStringInfo(&query,
"SELECT EXISTS (SELECT 1 FROM pg_proc p "
"JOIN pg_namespace n ON p.pronamespace = n.oid "
"WHERE n.nspname = 'hemar' AND p.proname = '%s');",
func_name);
ret = SPI_execute(query.data, true, 0);
if (ret != SPI_OK_SELECT)
{
SPI_finish();
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
errmsg("Failed to check function existence: %s", SPI_result_code_string(ret))));
}
function_exists = DatumGetBool(SPI_getbinval(SPI_tuptable->vals[0], SPI_tuptable->tupdesc, 1, &isnull));
/* Reset query buffer for function creation */
resetStringInfo(&query);
/* Only create function if it doesn't exist */
if (!function_exists)
{
elog(NOTICE, "Caching function %s", func_name);
elog(DEBUG1, "Content: %s", trimmed_code);
appendStringInfo(&query,
"CREATE OR REPLACE FUNCTION \"hemar\".\"%s\"(context jsonb) RETURNS text LANGUAGE plpgsql AS $$ "
"BEGIN "
" %s; "
" RETURN '';" // NOTICE(yukkop): Trailing return in case user does not return anything
"END $$;",
func_name,
trimmed_code);
/* Execute the query */
ret = SPI_execute(query.data, false, 0);
if (ret != SPI_OK_UTILITY)
{
SPI_finish();
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
errmsg("Failed to execute SQL in template: %s", SPI_result_code_string(ret))));
}
}
/* Reset query buffer for function execution */
resetStringInfo(&query);
/* Execute the function */
appendStringInfo(&query, "SELECT \"hemar\".\"%s\"($1);", func_name);
/* Prepare arguments for SPI_execute_with_args */
Oid argtypes[1] = {JSONBOID};
Datum argvalues[1] = {JsonbPGetDatum(define)};
ret = SPI_execute_with_args(query.data, 1, argtypes, argvalues, NULL, true, 0);
if (ret != SPI_OK_SELECT)
{
SPI_finish();
ereport(ERROR,
(errcode(ERRCODE_SYNTAX_ERROR),
errmsg("Failed to execute function: %s", SPI_result_code_string(ret))));
}
/* Get the result */
if (SPI_processed > 0)
{
Datum content = SPI_getbinval(SPI_tuptable->vals[0], SPI_tuptable->tupdesc, 1, &isnull);
if (!isnull)
{
char *content_str = TextDatumGetCString(content);
appendStringInfoString(&exec_result, content_str);
pfree(content_str);
}
}
/* Append any captured output to the result */
if (exec_result.len > 0)
{
appendStringInfoString(result, exec_result.data);
}
/* Clean up */
pfree(query.data);
pfree(exec_result.data);
pfree(trimmed_code);
/* Disconnect from SPI */
SPI_finish();
}
/* Function declarations */
PG_FUNCTION_INFO_V1(pg_jsonb_get_by_path);

5
package/c/hemar/test/mod.sql Executable file
View File

@@ -0,0 +1,5 @@
BEGIN;
\ir test_jsonb_path.sql
\ir test_template_parser.sql
\ir test_render_exec.sql
ROLLBACK;

View File

@@ -482,12 +482,12 @@ BEGIN
-- Print summary
IF passed_tests = total_tests THEN
RAISE NOTICE '------------------------------------';
RAISE NOTICE 'SUMMARY: % of % tests passed (100%%)',
RAISE NOTICE 'SUMMARY: % of % jsonb_get_by_path tests passed (100%%)',
passed_tests, total_tests;
RAISE NOTICE '------------------------------------';
ELSE
RAISE WARNING '------------------------------------';
RAISE WARNING 'SUMMARY: % of % tests passed (%)',
RAISE WARNING 'SUMMARY: % of % jsonb_get_by_path tests passed (%)',
passed_tests,
total_tests,
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
@@ -495,6 +495,6 @@ BEGIN
END IF;
IF passed_tests != total_tests THEN
RAISE EXCEPTION 'Tests failed: % of % tests did not pass', (total_tests - passed_tests), total_tests;
RAISE EXCEPTION 'Tests failed: % of % jsonb_get_by_path tests did not pass', (total_tests - passed_tests), total_tests;
END IF;
END $$;
END $$;

View File

@@ -0,0 +1,343 @@
-- Test the render function with execute tags
CREATE EXTENSION IF NOT EXISTS hemar;
DO $$
DECLARE
total_tests INT := 0;
passed_tests INT := 0;
test_result TEXT;
expected TEXT;
passed BOOLEAN;
BEGIN
-- Test 1: Simple execute tag that sets a variable
total_tests := total_tests + 1;
test_result := hemar.render(
'{"name": "John", "age": 30}'::jsonb,
'Hello {{ exec PERFORM 1; }}'
);
expected := 'Hello ';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Simple execute tag: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Simple execute tag: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 2: Execute tag with context access
total_tests := total_tests + 1;
DROP TABLE IF EXISTS test_output;
CREATE TEMP TABLE test_output (value TEXT);
test_result := hemar.render(
'{"name": "John", "age": 30}'::jsonb,
$expected$Hello {{ exec INSERT INTO test_output VALUES (context->'name'); }}$expected$
);
SELECT value INTO expected FROM test_output;
passed := expected = '"John"';
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with context access: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with context access: FAILED. Expected "John", got "%"',
total_tests, expected;
END IF;
-- Test 3: Execute tag with quotes and complex SQL
total_tests := total_tests + 1;
DROP TABLE IF EXISTS test_output;
CREATE TEMP TABLE test_output (value TEXT);
test_result := hemar.render(
'{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]}'::jsonb,
$expected$Items: {{ exec
INSERT INTO test_output
SELECT jsonb_array_elements(context->'items')->>'name';
}}$expected$
);
SELECT string_agg(value, ', ' ORDER BY value) INTO expected FROM test_output;
passed := expected = 'Item 1, Item 2';
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with complex SQL: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with complex SQL: FAILED. Expected "Item 1, Item 2", got "%"',
total_tests, expected;
END IF;
-- Test 4: Execute tag with output capture
total_tests := total_tests + 1;
test_result := hemar.render(
'{"name": "John", "age": 30}'::jsonb,
$expected$Hello {{ exec RETURN context->>'name'; }}$expected$
);
expected := 'Hello John';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with output capture: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with output capture: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 5: Execute tag with complex output
total_tests := total_tests + 1;
test_result := hemar.render(
'{"items": [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]}'::jsonb,
$expected$Items: {{ exec
RETURN (SELECT string_agg(value, ', ')
FROM (
SELECT jsonb_array_elements(context->'items')->>'name' as value
) t);
}}$expected$
);
expected := 'Items: Item 1, Item 2';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with complex output: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with complex output: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 6: Execute tag with multiple statements
total_tests := total_tests + 1;
test_result := hemar.render(
'{"name": "John", "age": 30}'::jsonb,
$expected$Hello {{ exec
DECLARE
v_name TEXT;
BEGIN
v_name := context->>'name';
RETURN v_name;
END;
}}$expected$
);
expected := 'Hello John';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with multiple statements: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with multiple statements: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 7: Execute tag with array operations
total_tests := total_tests + 1;
test_result := hemar.render(
'{"numbers": [1, 2, 3, 4, 5]}'::jsonb,
$expected$Sum: {{ exec
RETURN (SELECT sum(value::int)
FROM jsonb_array_elements_text(context->'numbers') as value);
}}$expected$
);
expected := 'Sum: 15';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with array operations: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with array operations: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 8: Execute tag with nested JSON operations
total_tests := total_tests + 1;
test_result := hemar.render(
'{"user": {"profile": {"settings": {"theme": "dark", "notifications": true}}}}'::jsonb,
$expected$Settings: {{ exec
RETURN context->'user'->'profile'->'settings'->>'theme';
}}$expected$
);
expected := 'Settings: dark';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with nested JSON operations: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with nested JSON operations: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 9: Execute tag with conditional logic
total_tests := total_tests + 1;
test_result := hemar.render(
'{"age": 25, "country": "US"}'::jsonb,
$expected$Status: {{ exec
DECLARE
v_status TEXT;
BEGIN
IF (context->>'age')::int >= 21 AND context->>'country' = 'US' THEN
v_status := 'Adult in US';
ELSE
v_status := 'Other';
END IF;
RETURN v_status;
END;
}}$expected$
);
expected := 'Status: Adult in US';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with conditional logic: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with conditional logic: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 10: Execute tag with string manipulation
total_tests := total_tests + 1;
test_result := hemar.render(
'{"text": "hello world"}'::jsonb,
$expected$Text: {{ exec
RETURN upper(context->>'text');
}}$expected$
);
expected := 'Text: HELLO WORLD';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with string manipulation: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with string manipulation: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 11: Execute tag with date operations
total_tests := total_tests + 1;
test_result := hemar.render(
'{"date": "2024-03-15"}'::jsonb,
$expected$Date: {{ exec
RETURN to_char((context->>'date')::date, 'Month DD, YYYY');
}}$expected$
);
expected := 'Date: March 15, 2024';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with date operations: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with date operations: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 12: Execute tag with aggregation
total_tests := total_tests + 1;
test_result := hemar.render(
'{"scores": [85, 92, 78, 95, 88]}'::jsonb,
$expected$Stats: {{ exec
RETURN (SELECT format('Avg: %s, Max: %s',
round(avg(value::float)::numeric, 1),
max(value::int))
FROM jsonb_array_elements_text(context->'scores') as value);
}}$expected$
);
expected := 'Stats: Avg: 87.6, Max: 95';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with aggregation: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with aggregation: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 13: Execute tag with error handling
total_tests := total_tests + 1;
test_result := hemar.render(
'{"value": "not_a_number"}'::jsonb,
$expected$Result: {{ exec
BEGIN
RETURN (context->>'value')::int::text;
EXCEPTION WHEN OTHERS THEN
RETURN 'Error: Invalid number';
END;
}}$expected$
);
expected := 'Result: Error: Invalid number';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with error handling: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with error handling: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 14: Execute tag with complex JSON transformation
total_tests := total_tests + 1;
test_result := hemar.render(
'{"users": [{"name": "Alice", "roles": ["admin", "user"]}, {"name": "Bob", "roles": ["user"]}]}'::jsonb,
$expected$Users: {{ exec
RETURN (SELECT string_agg(
format('%s (%s)',
user_data->>'name',
(SELECT string_agg(role, ', ')
FROM jsonb_array_elements_text(user_data->'roles') as role)
),
'; '
)
FROM jsonb_array_elements(context->'users') as user_data);
}}$expected$
);
expected := 'Users: Alice (admin, user); Bob (user)';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with complex JSON transformation: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with complex JSON transformation: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Test 15: Execute tag with empty/null handling
total_tests := total_tests + 1;
test_result := hemar.render(
'{"name": null, "items": []}'::jsonb,
$expected$Result: {{ exec
DECLARE
v_name TEXT;
v_count INT;
BEGIN
v_name := COALESCE(context->>'name', 'Unknown');
v_count := jsonb_array_length(context->'items');
RETURN format('Name: %s, Items: %s', v_name, v_count);
END;
}}$expected$
);
expected := 'Result: Name: Unknown, Items: 0';
passed := test_result = expected;
passed_tests := passed_tests + (CASE WHEN passed THEN 1 ELSE 0 END);
IF passed THEN
RAISE NOTICE 'Test %: Execute tag with empty/null handling: PASSED', total_tests;
ELSE
RAISE WARNING 'Test %: Execute tag with empty/null handling: FAILED. Expected "%", got "%"',
total_tests, expected, test_result;
END IF;
-- Print summary
IF passed_tests = total_tests THEN
RAISE NOTICE '------------------------------------';
RAISE NOTICE 'SUMMARY: % of % render exec tests passed (100%%)',
passed_tests, total_tests;
RAISE NOTICE '------------------------------------';
ELSE
RAISE WARNING '------------------------------------';
RAISE WARNING 'SUMMARY: % of % render exec tests passed (%)',
passed_tests,
total_tests,
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
RAISE WARNING '------------------------------------';
END IF;
IF passed_tests != total_tests THEN
RAISE EXCEPTION 'Tests failed: % of % render exec tests did not pass', (total_tests - passed_tests), total_tests;
END IF;
END $$;

View File

@@ -1064,12 +1064,12 @@ SECTION: iterator="section", collection="page.sections"$expected40$
-- Print summary
IF passed_tests = total_tests THEN
RAISE NOTICE '------------------------------------';
RAISE NOTICE 'SUMMARY: % of % tests passed (100%%)',
RAISE NOTICE 'SUMMARY: % of % template parser tests passed (100%%)',
passed_tests, total_tests;
RAISE NOTICE '------------------------------------';
ELSE
RAISE WARNING '------------------------------------';
RAISE WARNING 'SUMMARY: % of % tests passed (%)',
RAISE WARNING 'SUMMARY: % of % template parser tests passed (%)',
passed_tests,
total_tests,
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
@@ -1077,6 +1077,6 @@ SECTION: iterator="section", collection="page.sections"$expected40$
END IF;
IF passed_tests != total_tests THEN
RAISE EXCEPTION 'Tests failed: % of % tests did not pass', (total_tests - passed_tests), total_tests;
RAISE EXCEPTION 'Tests failed: % of % template parser tests did not pass', (total_tests - passed_tests), total_tests;
END IF;
END $$;