test: exec
This commit is contained in:
28
flake.nix
28
flake.nix
@@ -177,6 +177,9 @@
|
|||||||
jq
|
jq
|
||||||
yq-go
|
yq-go
|
||||||
curl
|
curl
|
||||||
|
(writeScriptBin "hemar-check" ''
|
||||||
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null vm-postgres 'zsh -c check'
|
||||||
|
'')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
# environment
|
# environment
|
||||||
@@ -324,18 +327,29 @@
|
|||||||
ALTER DATABASE postgres SET client_min_messages TO DEBUG1;
|
ALTER DATABASE postgres SET client_min_messages TO DEBUG1;
|
||||||
CREATE EXTENSION "hemar";
|
CREATE EXTENSION "hemar";
|
||||||
|
|
||||||
-- SELECT hemar.parse('{% zalupa %}');
|
\i ${./package/c/hemar/test/mod.sql}
|
||||||
SELECT hemar.render('{"a": "b"}'::JSONB, 'a {% a %}');
|
|
||||||
SELECT hemar.render('{"a": ["b", "c"]}'::JSONB, 'a {% for i in a do text %}');
|
-- SELECT hemar.parse('{{ zalupa }}');
|
||||||
SELECT hemar.render('{"a": {"g": ["b", "c"]}}'::JSONB, 'a {% for i in a.g do {% i %} %}');
|
-- SELECT hemar.render('{"a": "b"}'::JSONB, 'a {{ a }}');
|
||||||
SELECT hemar.render('{"a": {"g": ["b", "c"], "b": [{"c": "a"}, {"c": "b"}]}}'::JSONB, 'a {% for i in a.b do {% i.c %} %}');
|
-- SELECT hemar.render('{"a": ["b", "c"]}'::JSONB, 'a {{ for i in a do text }}');
|
||||||
|
-- SELECT hemar.render('{"a": {"g": ["b", "c"]}}'::JSONB, 'a {{ for i in a.g do {{ i }} }}');
|
||||||
|
-- SELECT hemar.render('{"a": {"b": [{"c": "a"}, {"c": "b"}]}}'::JSONB, 'a {{ for i in a.b do text }}');
|
||||||
|
-- SELECT hemar.render('{"a": {"g": ["b", "c"], "b": [{"c": "a"}, {"c": "b"}]}}'::JSONB, 'a {{ for i in a.g do text }}');
|
||||||
|
-- SELECT hemar.render('{"a": {"g": ["b", "c"]}}'::JSONB, 'a {{ for i in a.g do text {{ i }} text }}');
|
||||||
|
-- SELECT hemar.render('{"a": {"g": ["b", "c"]}}'::JSONB, 'a {{ a.g[1] }} {{ a.g[0] }}');
|
||||||
|
-- SELECT hemar.render('{"a": {"g": ["b", ["c", "d", "g"]]}}'::JSONB, 'a {{ a.g[1][2] }} {{ a.g[1][1] }} {{ a.g[1][0] }} {{ a.g[0] }}');
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [ gdb ];
|
environment.systemPackages = with pkgs; [
|
||||||
|
gdb
|
||||||
|
hectic.nvim-pager
|
||||||
|
(writeScriptBin "check" ''
|
||||||
|
journalctl -u postgresql.service | grep postgresql-post-start | sed 's|psql:/nix/store/[^:]*:[0-9]*: ||' | sed 's|^[^:]*:[^:]*:[^:]*: ||' | grep -v '^\[.*\]' | ${hectic.prettify-log}/bin/prettify-log --color-output
|
||||||
|
'')
|
||||||
|
];
|
||||||
programs.zsh.shellAliases = self.lib.sharedShellAliases // {
|
programs.zsh.shellAliases = self.lib.sharedShellAliases // {
|
||||||
conn = "sudo su postgres -c 'psql -p 64317'";
|
conn = "sudo su postgres -c 'psql -p 64317'";
|
||||||
check = "journalctl -u postgresql";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
virtualisation = {
|
virtualisation = {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ The templating engine supports flexible customization of tag syntax parameters.
|
|||||||
|
|
||||||
- **Open Brace**
|
- **Open Brace**
|
||||||
A non-empty string marking the beginning of a tag.
|
A non-empty string marking the beginning of a tag.
|
||||||
*Example:* `{%`
|
*Example:* `{{`
|
||||||
|
|
||||||
- **Close Brace**
|
- **Close Brace**
|
||||||
A non-empty string marking the end of a tag.
|
A non-empty string marking the end of a tag.
|
||||||
*Example:* `%}`
|
*Example:* `}}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -31,22 +31,22 @@ Parameters defining syntax for blocks controlling loops or nested structures.
|
|||||||
*Example:* ` in ` | `#`
|
*Example:* ` in ` | `#`
|
||||||
|
|
||||||
- **Post-Suffix**
|
- **Post-Suffix**
|
||||||
Finalizes the section declaration block.
|
Finalizes the section block.
|
||||||
*Example:* `do ` | `:`
|
*Example:* `end ` | `/`
|
||||||
|
|
||||||
*Example*
|
*Example*
|
||||||
|
|
||||||
*Section Example:*
|
*Section Example:*
|
||||||
```tpl
|
```tpl
|
||||||
{% for item in items do
|
{{ for item in items }}
|
||||||
{% item.name %}
|
{{ item.name }}
|
||||||
some text
|
some text
|
||||||
{% for inner_item in item.inner_items join '\n' do
|
{{ for inner_item in item.inner_items join '\n' }}
|
||||||
<p>some other text</p>
|
<p>some other text</p>
|
||||||
{% inner_item %}
|
{{ inner_item }}
|
||||||
%}
|
{{ end }}
|
||||||
\n
|
\n
|
||||||
%}
|
{{ end }}
|
||||||
```
|
```
|
||||||
*Context Example:*
|
*Context Example:*
|
||||||
```json
|
```json
|
||||||
@@ -68,7 +68,7 @@ Inserts variable values or expression results directly into templates.
|
|||||||
|
|
||||||
*Interpolation Example:*
|
*Interpolation Example:*
|
||||||
```tpl
|
```tpl
|
||||||
{% interpolation_field %}
|
{{ interpolation_field }}
|
||||||
```
|
```
|
||||||
*Context Example:*
|
*Context Example:*
|
||||||
```json
|
```json
|
||||||
@@ -87,7 +87,7 @@ Includes content from other templates.
|
|||||||
*Include Example:*
|
*Include Example:*
|
||||||
```tpl
|
```tpl
|
||||||
text before
|
text before
|
||||||
{% include inner_template %}
|
{{ include inner_template }}
|
||||||
<div id="footer">...</div>
|
<div id="footer">...</div>
|
||||||
```
|
```
|
||||||
*Context Examples:*
|
*Context Examples:*
|
||||||
@@ -96,7 +96,7 @@ Includes content from other templates.
|
|||||||
{
|
{
|
||||||
"include inner_template": [
|
"include inner_template": [
|
||||||
{
|
{
|
||||||
"template": "{% field %}",
|
"template": "{{ field }}",
|
||||||
"context": { "field": "value" }
|
"context": { "field": "value" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -107,7 +107,7 @@ Includes content from other templates.
|
|||||||
"field": "value",
|
"field": "value",
|
||||||
"include inner_template": [
|
"include inner_template": [
|
||||||
{
|
{
|
||||||
"template": "{% field %}"
|
"template": "{{ field }}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ Includes content from other templates.
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Execution Tags
|
## Execution Tags
|
||||||
**Note:** Currently not included in C library; implemented as a wrapper on applicable platforms.
|
**Note:** implemented as a wrapper on applicable platforms, in that case must evel Postgresql functions.
|
||||||
Enables calling functions with arguments, or execute code. Have hardcoded context var - alows use template context
|
Enables calling functions with arguments, or execute code. Have hardcoded context var - alows use template context
|
||||||
- **Prefix**
|
- **Prefix**
|
||||||
Denotes a function call.
|
Denotes a function call.
|
||||||
@@ -132,8 +132,8 @@ Enables calling functions with arguments, or execute code. Have hardcoded contex
|
|||||||
|
|
||||||
*Function Example:*
|
*Function Example:*
|
||||||
```tpl
|
```tpl
|
||||||
{% exec RETURN my_function(context->arg1, context->arg2, 'literal') %}
|
{{ exec RETURN my_function(context->arg1, context->arg2, 'literal') }}
|
||||||
{% exec RETURN 'aaaaa' %}
|
{{ exec RETURN 'aaaaa' }}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
@@ -149,17 +149,17 @@ Enables calling functions with arguments, or execute code. Have hardcoded contex
|
|||||||
```tpl
|
```tpl
|
||||||
<div>text before<div>
|
<div>text before<div>
|
||||||
|
|
||||||
{% include inner_template %}
|
{{ include inner_template }}
|
||||||
|
|
||||||
{% name %}
|
{{ name }}
|
||||||
|
|
||||||
{% for item in array do
|
{{ for item in array }}
|
||||||
some text: {% name2 %}
|
some text: {{ name2 }}
|
||||||
{% item.name %}
|
{{ item.name }}
|
||||||
%}
|
{{ end }}
|
||||||
|
|
||||||
<div>code insertion:</div>
|
<div>code insertion:</div>
|
||||||
{% execute
|
{{ execute
|
||||||
context + '{"name3": "zalupa"}';
|
context + '{"name3": "zalupa"}';
|
||||||
|
|
||||||
IF context->condition THEN
|
IF context->condition THEN
|
||||||
@@ -168,7 +168,7 @@ Enables calling functions with arguments, or execute code. Have hardcoded contex
|
|||||||
RETURN 'some text';
|
RETURN 'some text';
|
||||||
END
|
END
|
||||||
RETURN 'some other text';
|
RETURN 'some other text';
|
||||||
%}
|
}}
|
||||||
|
|
||||||
<div id="footer">...</div>
|
<div id="footer">...</div>
|
||||||
```
|
```
|
||||||
@@ -13,3 +13,9 @@ CREATE FUNCTION "hemar"."jsonb_get_by_path"("json" jsonb, "path" text)
|
|||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE C STRICT
|
LANGUAGE C STRICT
|
||||||
AS 'hemar', 'pg_jsonb_get_by_path';
|
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';
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
/* Forward declarations */
|
/* Forward declarations */
|
||||||
static void template_node_to_string(TemplateNode *node, StringInfo result, int indent);
|
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 *
|
static const char *
|
||||||
jbt_type_to_string(JsonbIteratorToken type)
|
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 */
|
/* This is our matching end tag */
|
||||||
end_tag_start = *s;
|
end_tag_start = *s;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(*s)++;
|
(*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 we've reached the matching closing brace, we're done */
|
||||||
if (brace_level == 0) {
|
if (brace_level == 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
*s += strlen(config->Syntax.Braces.close) - 1; /* -1 because we'll increment s below */
|
*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 */
|
/* Function declarations */
|
||||||
PG_FUNCTION_INFO_V1(pg_jsonb_get_by_path);
|
PG_FUNCTION_INFO_V1(pg_jsonb_get_by_path);
|
||||||
|
|
||||||
|
|||||||
5
package/c/hemar/test/mod.sql
Executable file
5
package/c/hemar/test/mod.sql
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
\ir test_jsonb_path.sql
|
||||||
|
\ir test_template_parser.sql
|
||||||
|
\ir test_render_exec.sql
|
||||||
|
ROLLBACK;
|
||||||
@@ -482,12 +482,12 @@ BEGIN
|
|||||||
-- Print summary
|
-- Print summary
|
||||||
IF passed_tests = total_tests THEN
|
IF passed_tests = total_tests THEN
|
||||||
RAISE NOTICE '------------------------------------';
|
RAISE NOTICE '------------------------------------';
|
||||||
RAISE NOTICE 'SUMMARY: % of % tests passed (100%%)',
|
RAISE NOTICE 'SUMMARY: % of % jsonb_get_by_path tests passed (100%%)',
|
||||||
passed_tests, total_tests;
|
passed_tests, total_tests;
|
||||||
RAISE NOTICE '------------------------------------';
|
RAISE NOTICE '------------------------------------';
|
||||||
ELSE
|
ELSE
|
||||||
RAISE WARNING '------------------------------------';
|
RAISE WARNING '------------------------------------';
|
||||||
RAISE WARNING 'SUMMARY: % of % tests passed (%)',
|
RAISE WARNING 'SUMMARY: % of % jsonb_get_by_path tests passed (%)',
|
||||||
passed_tests,
|
passed_tests,
|
||||||
total_tests,
|
total_tests,
|
||||||
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
|
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
|
||||||
@@ -495,6 +495,6 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF passed_tests != total_tests THEN
|
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 IF;
|
||||||
END $$;
|
END $$;
|
||||||
343
package/c/hemar/test/test_render_exec.sql
Executable file
343
package/c/hemar/test/test_render_exec.sql
Executable 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 $$;
|
||||||
@@ -1064,12 +1064,12 @@ SECTION: iterator="section", collection="page.sections"$expected40$
|
|||||||
-- Print summary
|
-- Print summary
|
||||||
IF passed_tests = total_tests THEN
|
IF passed_tests = total_tests THEN
|
||||||
RAISE NOTICE '------------------------------------';
|
RAISE NOTICE '------------------------------------';
|
||||||
RAISE NOTICE 'SUMMARY: % of % tests passed (100%%)',
|
RAISE NOTICE 'SUMMARY: % of % template parser tests passed (100%%)',
|
||||||
passed_tests, total_tests;
|
passed_tests, total_tests;
|
||||||
RAISE NOTICE '------------------------------------';
|
RAISE NOTICE '------------------------------------';
|
||||||
ELSE
|
ELSE
|
||||||
RAISE WARNING '------------------------------------';
|
RAISE WARNING '------------------------------------';
|
||||||
RAISE WARNING 'SUMMARY: % of % tests passed (%)',
|
RAISE WARNING 'SUMMARY: % of % template parser tests passed (%)',
|
||||||
passed_tests,
|
passed_tests,
|
||||||
total_tests,
|
total_tests,
|
||||||
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
|
round((passed_tests::numeric / total_tests::numeric) * 100, 2) || '%';
|
||||||
@@ -1077,6 +1077,6 @@ SECTION: iterator="section", collection="page.sections"$expected40$
|
|||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF passed_tests != total_tests THEN
|
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 IF;
|
||||||
END $$;
|
END $$;
|
||||||
Reference in New Issue
Block a user