diff --git a/HISTORY.asc b/HISTORY.asc index 887bce7..1c3e53b 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -1,3 +1,14 @@ -0.2.1 ------ +STABLE +------ === Fix bug where extension could not be dropped +The extension now requires extension_drop and trunklet 0.3.3 or later, neither of which 0.2.0 required. Updating from trunklet-format 0.2.0 (PostgreSQL 12 or later) requires trunklet >= 0.3.3: + +* If you are on trunklet 0.2.1 or later, run `ALTER EXTENSION trunklet UPDATE;` first, then `CREATE EXTENSION extension_drop CASCADE;` and `ALTER EXTENSION "trunklet-format" UPDATE;`. +* trunklet 0.2.0 has no update path to later versions: drop and recreate it (this also drops trunklet-format and any trunklet data), then install trunklet-format at the stable version. + +=== Add optional parameters +A parameter written as `%name%Os` or `%name%OL` may be absent from the parameters, in which case it is treated as NULL. Identifiers (`I`) cannot be optional. + +=== Other changes +* `extract_parameters()` returns NULL instead of an error when none of the requested parameters are present. +* Template parse errors report the position in the template. diff --git a/META.in.json b/META.in.json index f62b8a1..67e359c 100644 --- a/META.in.json +++ b/META.in.json @@ -16,7 +16,7 @@ "name": "trunklet-format", "X_comment": "REQUIRED. Version of the distribution. http://pgxn.org/spec/#version", - "version": "0.2.0", + "version": "stable", "X_comment": "REQUIRED. Short description of distribution.", "abstract": "A format()-based template language for trunklet", @@ -41,7 +41,7 @@ "file": "sql/trunklet-format.sql", "X_comment": "REQUIRED. Version the extension is at.", - "version": "0.2.0", + "version": "stable", "X_comment": "Optional: \"abstract\": Description of the extension.", "abstract": "A format()-based template language for trunklet", @@ -75,20 +75,20 @@ "X_comment": "Requires JSONB, which was released in 9.4", "X_comment": "trunklet currently requires 9.5", "X_comment": "extension_drop: 0.1.1 fixes bug with dependencies", - "X_comment": "Requires support for droping a language in trunklet; released in 0.3.0", + "X_comment": "trunklet 0.3.3: the drop command uses template_language__remove(ignore_missing_functions), added in 0.3.3", "prereqs": { "build": { "requires": { "PostgreSQL": "9.5.0", "extension_drop": ">= 0.1.1", - "trunklet": ">= 0.3.0" + "trunklet": ">= 0.3.3" } }, "runtime": { "requires": { "plpgsql": 0, - "trunklet": ">= 0.2.0" + "trunklet": ">= 0.3.3" } }, "test": { diff --git a/META.json b/META.json index 94b02ca..4475eb6 100644 --- a/META.json +++ b/META.json @@ -16,7 +16,7 @@ "name": "trunklet-format", "X_comment": "REQUIRED. Version of the distribution. http://pgxn.org/spec/#version", - "version": "0.2.0", + "version": "stable", "X_comment": "REQUIRED. Short description of distribution.", "abstract": "A format()-based template language for trunklet", @@ -41,7 +41,7 @@ "file": "sql/trunklet-format.sql", "X_comment": "REQUIRED. Version the extension is at.", - "version": "0.2.0", + "version": "stable", "X_comment": "Optional: \"abstract\": Description of the extension.", "abstract": "A format()-based template language for trunklet", @@ -75,20 +75,20 @@ "X_comment": "Requires JSONB, which was released in 9.4", "X_comment": "trunklet currently requires 9.5", "X_comment": "extension_drop: 0.1.1 fixes bug with dependencies", - "X_comment": "Requires support for droping a language in trunklet; released in 0.3.0", + "X_comment": "trunklet 0.3.3: the drop command uses template_language__remove(ignore_missing_functions), added in 0.3.3", "prereqs": { "build": { "requires": { "PostgreSQL": "9.5.0", "extension_drop": ">= 0.1.1", - "trunklet": ">= 0.3.0" + "trunklet": ">= 0.3.3" } }, "runtime": { "requires": { "plpgsql": 0, - "trunklet": ">= 0.2.0" + "trunklet": ">= 0.3.3" } }, "test": { diff --git a/README.md b/README.md index af242ac..9db7b39 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,18 @@ super user and running: CREATE EXTENSION "trunklet-format"; +Updating an existing trunklet-format 0.2.0 install (PostgreSQL 12 or later) +requires trunklet >= 0.3.3 and extension_drop, neither of which 0.2.0 required. +If you are on trunklet 0.2.1 or later, run: + + ALTER EXTENSION trunklet UPDATE; + CREATE EXTENSION extension_drop CASCADE; + ALTER EXTENSION "trunklet-format" UPDATE; + +trunklet 0.2.0 has no update path to later versions: drop and recreate it (this +also drops trunklet-format and any trunklet data), then install trunklet-format +at the stable version. + If you want to install trunklet-format and all of its supporting objects into a specific schema, use the `PGOPTIONS` environment variable to specify the schema, like so: diff --git a/sql/trunklet-format--0.2.0--stable.sql b/sql/trunklet-format--0.2.0--stable.sql new file mode 100644 index 0000000..2f24aa9 --- /dev/null +++ b/sql/trunklet-format--0.2.0--stable.sql @@ -0,0 +1,321 @@ +-- The drop command below needs trunklet 0.3.3 (ignore_missing_functions); a non-numeric version such as stable passes +DO $$ +DECLARE + v_version CONSTANT text := (SELECT extversion FROM pg_catalog.pg_extension WHERE extname = 'trunklet'); +BEGIN + IF v_version ~ '^[0-9]+(\.[0-9]+)*$' AND string_to_array(v_version, '.')::int[] < '{0,3,3}' THEN + RAISE EXCEPTION 'trunklet >= 0.3.3 required; found %', v_version + USING ERRCODE = 'object_not_in_prerequisite_state' + , HINT = 'On trunklet 0.2.1 or later, run ALTER EXTENSION trunklet UPDATE first. trunklet 0.2.0 has no update path; see the trunklet-format README.' + ; + END IF; +END +$$; + +/* + * 0.2.0 could not be dropped. Register the same drop command the install + * script does; this is why extension_drop is now a required extension. + */ +SELECT extension_drop__add( + 'trunklet-format' + -- WARNING! Our language name is format, not trunklet-format! + , $sql$SELECT trunklet.template_language__remove('format', ignore_missing_functions := true)$sql$ +); + +/* + * trunklet has no public API to replace a language, and removing then + * re-adding it fails on stored templates (or, with cascade, deletes them), so + * update trunklet's private tables and functions the way + * template_language__add() does. + */ +SELECT _trunklet.language__get_id('format'); -- Throws if the language is missing + +UPDATE _trunklet.language + SET process_function_body = $process_body$ +DECLARE + c_param CONSTANT jsonb := parameters::jsonb; + v_return text; +BEGIN + -- DUPLICATED IN BOTH FUNCTIONS + IF jsonb_typeof(c_param) <> 'object' THEN + RAISE EXCEPTION 'parameters must be a JSON object, not %', jsonb_typeof(c_param) + USING DETAIL = format('parameters = %L', c_param) + ; + END IF; + + -- Sanity-check parameters + -- DUPLICATED IN BOTH FUNCTIONS + DECLARE + parameter_name text; + value text; + v_type text; + BEGIN + FOR parameter_name, value IN SELECT * FROM jsonb_each( c_param ) + LOOP + IF parameter_name ~ '%' THEN + RAISE EXCEPTION + USING DETAIL = 'parameter_name ' || parameter_name + -- MESSAGE instead of trying to escape % + , MESSAGE = 'parameter names may not contain "%"' + ; + END IF; + + v_type := jsonb_typeof( c_param->parameter_name ); + IF v_type IN ( 'array', 'object' ) THEN + RAISE EXCEPTION '% is not supported as a parameter type', v_type + USING DETAIL = format( $$paramater %s = %s$$, parameter_name, value ) + ; + END IF; + END LOOP; + END; + + /* + * Look for things to replace + */ + DECLARE + c_parse CONSTANT text[] := string_to_array( template::text, '%' ); + c_alen CONSTANT int := array_length( c_parse, 1 ); + + /* + * TODO: Instead of trying to track exact template position, it would be + * easier to just track the accumulated length of every c_parse element + * we've seen. That can be converted to template position by simply adding + * (v_pos-1) * length('%'). + */ + v_template_pos int := 1 + length(c_parse[1]); -- Start at 1 and account for c_parse[1] + + v_pos int := 2; -- We handle first element specially + v_format_option_position int; + v_last_parameter_position int; + v_cur text = ''; -- Needs to start at '' for template position tracking + v_parameter text; + v_optional boolean; + v_format_option text; + v_value text; + BEGIN + /* + * We have to special-case the first element because we know it must be + * plain text with no paramaters, or empty if the template starts with '%%'. + */ + v_return := c_parse[1]; + WHILE v_pos <= c_alen LOOP + v_template_pos := v_template_pos + 1; -- We just consumed a % + v_cur := c_parse[v_pos]; + --RAISE WARNING 'v_template_pos = %, v_pos = %, v_cur = %', v_template_pos, v_pos, v_cur; + + /* + * The %% escape gives us an empty v_cur. + */ + IF v_cur = '' THEN + /* + * If we have the template '1 %% 2 %param%s' then when we hit this + * condition we're 'inside' the %% (v_pos = 2, v_cur = ''). The code + * below this IF block depends on v_cur being a parameter name. This + * means we have to consume the NEXT array element (v_pos = 3) before + * continuing, because it could ALSO be empty (ie: '%%%param%s'). It + * could NOT be a parameter though. + */ + + -- Update our position + v_template_pos = v_template_pos + length(c_parse[v_pos + 1]) + 1; + -- v_cur will immediately be reset + + -- Append the % and the next array element + v_return := v_return || '%' || c_parse[v_pos + 1]; + v_pos := v_pos + 2; + CONTINUE; + END IF; + + /* + * Since we don't allow % in a parameter name, we know that v_cur IS THE NAME OF A PARAMETER. + */ + v_last_parameter_position := v_template_pos - 1; -- -1 for the '%' + v_parameter := v_cur; + v_pos := v_pos + 1; + IF v_pos > c_alen THEN + RAISE EXCEPTION 'Reached end of template while processing parameters' + USING HINT = E'Parameters must match the format "%parameter_name%[O]T" where T is s, I or L\n' + || 'and O (if present) means the parameter is optional.' + ; + END IF; + + v_template_pos = v_template_pos + length(v_cur) + 1; + v_cur := c_parse[v_pos]; + --RAISE WARNING 'v_template_pos = %, v_pos = %, v_cur = %', v_template_pos, v_pos, v_cur; + /* + * At this point v_cur IS THE OPTIONS SPECIFIER + */ + + -- This is where to insert support for other format option handling + -- These ugly shenanigans are to avoid copying a potentially large string multiple times + v_optional := substr( v_cur, 1, 1 ) = 'O'; + v_format_option_position := CASE v_optional WHEN true THEN 2 WHEN false THEN 1 END; + v_template_pos = v_template_pos + v_format_option_position - 1; -- -1 because we haven't technically consumed the format specifier yet + v_format_option = substr( v_cur, v_format_option_position, 1 ); + --RAISE WARNING 'FORMAT OPTION %, v_template_pos = %, v_pos = %, v_format_option_position = %, v_cur = %', v_format_option, v_template_pos, v_pos, v_format_option_position, v_cur; + + --RAISE WARNING 'v_pos = %, v_cur = %, v_format_option = %', v_pos, v_cur, v_format_option; + IF v_format_option NOT IN ( 's', 'I', 'L' ) THEN + RAISE EXCEPTION 'Unexpected character "%" in format specifier "%"' + , v_format_option + , substr(v_cur, 1, v_format_option_position ) + USING DETAIL = format( 'parameter "%s" at template position %s', v_parameter, v_last_parameter_position ) + ; + END IF; + + IF v_optional THEN + IF v_format_option = 'I' THEN + RAISE EXCEPTION 'SQL identifier format option ("I") not allowed with optional parameters' + USING ERRCODE = 'null_value_not_allowed' + , HINT = 'SQL identifiers can not be NULL or empty, so identifier formats may not be optional.' + , DETAIL = format( 'parameter "%s" at template position %s', v_parameter, v_last_parameter_position ) + ; + END IF; + -- c_param->v_parameter will ONLY be NULL if v_parameter doesn't exist in the document + ELSIF (c_param->v_parameter) IS NULL THEN + RAISE EXCEPTION 'parameter "%" not found', v_parameter + USING DETAIL = format( 'at template position %s', v_last_parameter_position ) + ; + END IF; + + + -- Now that we've consumed the format specifier, update v_template_pos and grab our value + v_template_pos := v_template_pos + 1; + v_value := c_param->>v_parameter; + + /* + RAISE WARNING 'v_template_pos = %, v_pos = %, v_parameter = %, v_format_option = %, v_value = %, v_format_option_position = %' + , v_template_pos, v_pos, v_parameter, v_format_option, v_value, v_format_option_position + USING DETAIL = format( + 'prior "%s", next: "%s"' + , substr(template, v_template_pos-20, 20) + , substr(template, v_template_pos, 20) + ) + ; + */ + v_return := v_return + -- This is the actual replacement + || format( + '%' || v_format_option + , v_value + ) + + -- Remainder of document, up to next '%' + || substr( v_cur, v_format_option_position + 1 ) + ; + v_template_pos := v_template_pos + length(v_cur) - v_format_option_position; + v_pos := v_pos + 1; + END LOOP; + END; + + RETURN v_return; +END +$process_body$ + , extract_parameters_body = $extract_body$ +DECLARE + c_param CONSTANT jsonb := parameters::jsonb; + a_as text[]; + sql text; + r record; +BEGIN + -- DUPLICATED IN BOTH FUNCTIONS + IF jsonb_typeof(c_param) <> 'object' THEN + RAISE EXCEPTION 'parameters must be a JSON object, not %', jsonb_typeof(c_param) + USING DETAIL = format('parameters = %L', c_param) + ; + END IF; + + -- Sanity-check parameters + -- DUPLICATED IN BOTH FUNCTIONS + DECLARE + parameter_name text; + value text; + v_type text; + BEGIN + FOR parameter_name, value IN SELECT * FROM jsonb_each( c_param ) + LOOP + IF parameter_name ~ '%' THEN + RAISE EXCEPTION + USING DETAIL = 'parameter_name ' || parameter_name + -- MESSAGE instead of trying to escape % + , MESSAGE = 'parameter names may not contain "%"' + ; + END IF; + + v_type := jsonb_typeof( c_param->parameter_name ); + IF v_type IN ( 'array', 'object' ) THEN + RAISE EXCEPTION '% is not supported as a parameter type', v_type + USING DETAIL = format( $$paramater %s = %s$$, parameter_name, value ) + ; + END IF; + + -- THIS IS SPECIFIC TO extract_parametrs + IF parameter_name = ANY( extract_list ) THEN + a_as := a_as || format( + '%I %s' + , parameter_name + , CASE + WHEN v_type IN( 'string', 'null' ) THEN 'text' + WHEN v_type = 'number' THEN 'numeric' + -- array and object can't happen + ELSE v_type + END + ); + END IF; + END LOOP; + END; + + -- Nothing matched + IF a_as IS NULL THEN + RETURN NULL; + END IF; + + sql := 'SELECT * FROM jsonb_to_record($1) AS j(' || array_to_string( a_as, ', ' ) || ')'; + RAISE DEBUG 'sql = %', sql; + EXECUTE sql INTO r USING c_param; + RETURN row_to_json(r)::jsonb; +END +$extract_body$ + WHERE language_name = 'format' +; + +SELECT _trunklet._language_function__create( + language_id + , language_name + , 'process' + , 'text' + , format( + $args$ + template %s + , parameters %s +$args$ + , template_type + , parameter_type + ) + , process_function_options + , process_function_body + ) + FROM _trunklet.language + WHERE language_name = 'format' +; + +SELECT _trunklet._language_function__create( + language_id + , language_name + , 'extract_parameters' + , parameter_type::text + , format( + $args$ + parameters %s + , extract_list text[] +$args$ + , parameter_type + ) + , extract_parameters_options + , extract_parameters_body + ) + FROM _trunklet.language + WHERE language_name = 'format' +; + +-- vi: expandtab ts=2 sw=2 diff --git a/sql/trunklet-format--0.2.0.sql b/sql/trunklet-format--0.2.0.sql new file mode 100644 index 0000000..c3bb967 --- /dev/null +++ b/sql/trunklet-format--0.2.0.sql @@ -0,0 +1,177 @@ +SELECT trunklet.template_language__add( + language_name := 'format' + , parameter_type := 'jsonb' + , template_type := 'text' + , process_function_options := 'LANGUAGE plpgsql' + , process_function_body := $process_body$ +DECLARE + c_param CONSTANT jsonb := parameters::jsonb; + v_return text; +BEGIN + -- DUPLICATED IN BOTH FUNCTIONS + IF jsonb_typeof(c_param) <> 'object' THEN + RAISE EXCEPTION 'parameters must be a JSON object, not %', jsonb_typeof(c_param) + USING DETAIL = 'parameters = ' || c_param + ; + END IF; + + -- Sanity-check parameters + -- DUPLICATED IN BOTH FUNCTIONS + DECLARE + parameter_name text; + value text; + v_type text; + BEGIN + FOR parameter_name, value IN SELECT * FROM jsonb_each( c_param ) + LOOP + IF parameter_name ~ '%' THEN + RAISE EXCEPTION + USING DETAIL = 'parameter_name ' || parameter_name + -- MESSAGE instead of trying to escape % + , MESSAGE = 'parameter names may not contain "%"' + ; + END IF; + + v_type := jsonb_typeof( c_param->parameter_name ); + IF v_type IN ( 'array', 'object' ) THEN + RAISE EXCEPTION '% is not supported as a parameter type', v_type + USING DETAIL = format( $$paramater %s = %s$$, parameter_name, value ) + ; + END IF; + END LOOP; + END; + + /* + * Look for things to replace + */ + DECLARE + c_parse CONSTANT text[] := string_to_array( template::text, '%' ); + c_alen CONSTANT int := array_length( c_parse, 1 ); + v_pos int := 2; -- We handle first element specially + v_cur text; + v_first text; + v_value text; + BEGIN + /* + * We have to special-case the first element because we know it must be + * plain text with no paramaters, or empty if the template starts with '%%'. + */ + v_return := c_parse[1]; + WHILE v_pos <= c_alen LOOP + v_cur := c_parse[v_pos]; + --RAISE WARNING 'v_pos = %, v_cur = %', v_pos, v_cur; + + /* + * The %% escape gives us an empty v_cur. + */ + IF v_cur = '' THEN + -- The next parse element won't be a + /* + * If we have the template '1 %% 2 %param%s' then when we hid this + * condition we're 'inside' the %% (v_pos = 2, v_cur = ''). The code + * below this IF block depends on v_cur being a parameter name. This + * means we have to consume v_pos = 3 before continuing. + */ + v_return := v_return || '%' || c_parse[v_pos + 1]; + v_pos := v_pos + 2; + CONTINUE; + END IF; + + /* + * Since we don't allow % in a parameter name, we know that v_cur is the name of a parameter. + */ + IF (c_param->v_cur) IS NULL THEN + RAISE EXCEPTION 'parameter "%" not found', v_cur + USING DETAIL = 'parse position ' || v_pos + ; + END IF; + v_value := CASE WHEN jsonb_typeof(c_param->v_cur) = 'null' THEN NULL ELSE c_param->>v_cur END; + v_pos := v_pos + 1; + IF v_pos > c_alen THEN + RAISE EXCEPTION 'Reached end of template while processing parameters' + USING HINT = 'Parameters must match the format "%parameter_name%T" where T is s, I or L.' + ; + END IF; + v_cur := c_parse[v_pos]; + + -- This is where to insert support for other format option handling + v_first := substr( v_cur, 1, 1 ); + --RAISE WARNING 'v_pos = %, v_cur = %, v_first = %', v_pos, v_cur, v_first; + IF v_first NOT IN ( 's', 'I', 'L' ) THEN + RAISE EXCEPTION 'Unexpected character "%" trailing parameter "%"', v_first, c_parse[v_pos - 1] + USING DETAIL = format( 'parse position %s, parameter name %s', v_pos, c_parse[v_pos - 1] ) + ; + END IF; + + v_return := v_return || format( '%' || v_first, v_value ) || substr( v_cur, 2 ); + v_pos := v_pos + 1; + END LOOP; + END; + + RETURN v_return; +END +$process_body$ + , extract_parameters_options := 'LANGUAGE plpgsql' + , extract_parameters_body := $extract_body$ +DECLARE + c_param CONSTANT jsonb := parameters::jsonb; + a_as text[]; + sql text; + r record; +BEGIN + -- DUPLICATED IN BOTH FUNCTIONS + IF jsonb_typeof(c_param) <> 'object' THEN + RAISE EXCEPTION 'parameters must be a JSON object, not %', jsonb_typeof(c_param) + USING DETAIL = 'parameters = ' || c_param + ; + END IF; + + -- Sanity-check parameters + -- DUPLICATED IN BOTH FUNCTIONS + DECLARE + parameter_name text; + value text; + v_type text; + BEGIN + FOR parameter_name, value IN SELECT * FROM jsonb_each( c_param ) + LOOP + IF parameter_name ~ '%' THEN + RAISE EXCEPTION + USING DETAIL = 'parameter_name ' || parameter_name + -- MESSAGE instead of trying to escape % + , MESSAGE = 'parameter names may not contain "%"' + ; + END IF; + + v_type := jsonb_typeof( c_param->parameter_name ); + IF v_type IN ( 'array', 'object' ) THEN + RAISE EXCEPTION '% is not supported as a parameter type', v_type + USING DETAIL = format( $$paramater %s = %s$$, parameter_name, value ) + ; + END IF; + + -- THIS IS SPECIFIC TO extract_parametrs + IF parameter_name = ANY( extract_list ) THEN + a_as := a_as || format( + '%I %s' + , parameter_name + , CASE + WHEN v_type IN( 'string', 'null' ) THEN 'text' + WHEN v_type = 'number' THEN 'numeric' + -- array and object can't happen + ELSE v_type + END + ); + END IF; + END LOOP; + END; + + sql := 'SELECT * FROM jsonb_to_record($1) AS j(' || array_to_string( a_as, ', ' ) || ')'; + RAISE DEBUG 'sql = %', sql; + EXECUTE sql INTO r USING c_param; + RETURN row_to_json(r)::jsonb; +END +$extract_body$ +); + +-- vi: expandtab ts=2 sw=2 diff --git a/trunklet-format.control b/trunklet-format.control index fe292bf..4faee9a 100644 --- a/trunklet-format.control +++ b/trunklet-format.control @@ -1,5 +1,5 @@ # trunklet-format extension comment = 'A format()-based template language for trunklet' -default_version = '0.2.0' +default_version = 'stable' relocatable = true requires = 'trunklet, extension_drop'