commit 851774b596b11b5d5620f38f13fc2957345e6b2d
parent a6092c90e099d447ae6a7464bcad5443252cfe17
Author: Jan Pobříslo <ccx@te2000.cz>
Date: Mon, 4 Dec 2023 22:50:15 +0100
Prototype of apply_patchset script in zsh.
Diffstat:
5 files changed, 297 insertions(+), 0 deletions(-)
diff --git a/patch_file_format.md b/patch_file_format.md
@@ -0,0 +1,26 @@
+# Purpose
+
+The patch format for `apply_patchset` script slightly differs from regular **Versioning** patch format.
+The reason for this is to make the patch metadata easier to parse and thus allow reasoning about them before patch
+application, as well as validation of the format and removing boilerplate such as transaction management.
+
+This format equates filename with patch name.
+While it's less flexible than being able to assign arbitrary names to patches it greatly simplifies the automation
+required to get the correct patches applied.
+
+# Format
+
+Every patch file has a header which starts with `-- VPATCH` and ends with empty line (required).
+In between those lines you can put dependency and conflict lines in arbitrary order, such as:
+
+```
+-- VPATCH
+-- VDEP:000-base.sql
+-- VDEP:001-extensions.sql
+-- VCON:009-special.sql
+
+create table foo (foo_id serial primary key, bar text);
+```
+
+Note the lack of transaction management and calls to `_v.patch()`.
+That is all handled by the script applying the patches so you can have just the relevant SQL code below the header.
diff --git a/t/patches/000-base.sql b/t/patches/000-base.sql
@@ -0,0 +1,3 @@
+-- VPATCH
+
+create table users (id serial primary key, username text);
diff --git a/t/patches/001-users.sql b/t/patches/001-users.sql
@@ -0,0 +1,4 @@
+-- VPATCH
+-- VDEP:000-base.sql
+
+insert into users (username) values ('depesz');
diff --git a/tools/apply_patchset b/tools/apply_patchset
@@ -0,0 +1,191 @@
+#!/bin/zsh
+. $0:h/common.zsh || exit $?
+
+main() {
+ if (( $# < 1 )); then
+ warn_msg usage: "cd <patchdir> && apply_patchset <one or more patch names>"
+ exit 100
+ fi
+ local patch
+ for patch in "$@"; do
+ if [[ $patch == *[\\\']* ]]; then
+ err_msg ERROR: "disallowed character in patch name: $1"
+ exit 100
+ fi
+ done
+ - parse_patch_set $@
+ - load_installed_patches
+ - verify_patch_set $@
+ - install_patch_set $@
+}
+
+# ---
+
+load_installed_patches() {
+ typeset -ga installed_patches conflicting_patches
+ installed_patches=( ${(f)"$( psql -AP tuples_only=on -c 'select patch_name from _v.patches' )"} ) || \
+ exit $?
+ conflicting_patches=( ${(f)"$( psql -AP tuples_only=on -c 'select distinct con from _v.patches, lateral unnest(conflicts) con;' )"} ) || \
+ exit $?
+}
+
+# ---
+
+parse_patch_set() {
+ typeset -gA patch_parsed patch_deps patch_cons
+ local patch
+ for patch in "$@"; do
+ - parse_patch $patch
+ done
+}
+
+parse_patch() {
+ (( $+patch_parsed[$1] )) && return 0
+ if [[ $1 == *[\\\']* ]]; then
+ printf >&2 'ERROR: disallowed character in patch name: %s\n' $1
+ exit 1
+ fi
+
+ local line have_start=false
+ local -a deps cons
+ <$1 while IFS= read line; do
+ if ! $have_start; then
+ if [[ $line == "-- VPATCH" ]]; then
+ have_start=true
+ continue
+ else
+ die "$1 doesn't have VPATCH header"
+ fi
+ else
+ if [[ $line == '' ]]; then
+ break
+ fi
+ line=${line%%--[[:space:]]#}
+ case $line in
+ (VDEP:*) deps+=${line#VDEP:};;
+ (VCON:*) cons+=${line#VCON:};;
+ (*) die "unexpected line in $1: ${(qqq)line}";
+ esac
+ fi
+ done
+
+ patch_deps[$1]=${(pj:\0:)deps}
+ patch_cons[$1]=${(pj:\0:)cons}
+ patch_parsed[$1]=1
+
+ local dep
+ for dep in $deps; do
+ - parse_patch $dep
+ done
+}
+
+# ---
+
+verify_patch_set() {
+ unset verify_install
+ unset verify_conflict
+ typeset -gA verify_install
+ typeset -gA verify_conflict
+ local patch
+ for patch in "$@"; do
+ - verify_patch $patch '<commandline>'
+ done
+}
+
+print_dep_traceback() {
+ local dep indent
+ indent=${1:->}
+ (($+verify_install[$1])) || return
+ for dep in ${(f)verify_install[$1]}; do
+ - warn_msg $indent patch ${(qqq)1} was pulled in by ${(qqq)dep}
+ - print_dep_traceback $dep "$indent >"
+ done
+}
+
+print_con_traceback() {
+ local dep indent
+ indent=${1:-!}
+ (($+verify_install[$1])) || return
+ for dep in ${(f)verify_install[$1]}; do
+ - warn_msg $indent conflict with ${(qqq)1} is caused by ${(qqq)dep}
+ - print_dep_traceback $dep "$indent >"
+ done
+}
+
+extend_install_list() {
+ [[ $# == 2 ]] || die "exactly two arguments required"
+ verify_install[$1]+=$2$'\n'
+ if (( ${conflicting_patches[(I)$1]} )); then
+ - err_msg ERROR cannot install patch $1 as it conflicts with current database
+ - print_dep_traceback $1
+ exit 3
+ elif (( $+verify_conflict[$1] )); then
+ - err_msg ERROR cannot install patch $1 as it conflicts with another patch
+ - print_con_traceback $1
+ - print_dep_traceback $1
+ exit 3
+ fi
+}
+
+extend_conflict_list() {
+ [[ $# == 2 ]] || die "exactly two arguments required"
+ verify_conflict[$1]+=$2$'\n'
+ if (( ${installed_patches[(I)$1]} )); then
+ - err_msg ERROR cannot install patch $2 as it creates conflict with $1 in the current database
+ - print_con_traceback $1
+ exit 3
+ elif (( $+verify_patch[$1] )); then
+ - err_msg ERROR cannot install patch $2 as it creates conflict with another patch
+ - print_con_traceback $1
+ - print_dep_traceback $1
+ exit 3
+ fi
+}
+
+verify_patch() {
+ [[ $# == 2 ]] || die "exactly two arguments required"
+ # (( ${installed_patches[(I)$1]} )) && return 0
+ $+verify_patch[$1] && return 0
+ local -a deps cons
+ local dep con
+ extend_install_list $1 $2
+ for dep in ${(ps:\0:)patch_deps[$1]}; do
+ - verify_patch $dep $1
+ done
+ for con in ${(ps:\0:)patch_cons[$1]}; do
+ - extend_conflict_list $con $1
+ done
+}
+
+# ---
+
+install_patch_set() {
+ local patch
+ for patch in "$@"; do
+ - install_patch $patch
+ done
+}
+
+install_patch() {
+ (( ${installed_patches[(I)$1]} )) && return 0
+ if [[ $1 == *[\\\']* ]]; then
+ printf >&2 'ERROR: disallowed character in patch name: %s\n' $1
+ exit 1
+ fi
+
+ local dep
+ for dep in ${(ps:\0:)patch_deps[$1]}; do
+ - install_patch $dep
+ done
+
+ printf '%s\n' \
+ '\set ON_ERROR_STOP' \
+ "select _v.register_patch('$1', NULL, NULL);" \
+ "\i $1" \
+ | psql -1 -e -f - || die ERROR: "installing patch ${(qqq)1} failed with exitcode $#"
+ installed_patches+=$1
+}
+
+# ---
+
+main "$@"
diff --git a/tools/common.zsh b/tools/common.zsh
@@ -0,0 +1,73 @@
+setopt no_unset warn_create_global # be strict
+setopt extended_glob # allow glob specifiers
+
+# nice PS4 with ellapsed seconds (to two decimal places) {{{1
+zmodload zsh/system # to get actual pid
+setopt PROMPT_SUBST
+if [[ ${TERM:-dumb} == (xterm|rxvt|screen|linux|console|Eterm|putty)* ]]; then
+ # first color is for main shell process, second is subshell
+ PS4_PID_COLORS=(cyan magenta)
+ PS4='+%B${SECONDS} %F{${PS4_PID_COLORS[1+($$ != ${sysparams[pid]})]}}%N%f:%F{yellow}%i%f>%b '
+else
+ PS4='+%B${SECONDS} ${sysparams[pid]} %N:%i>%b '
+fi
+
+# two digits after the decimal point
+typeset -g -F 2 SECONDS
+
+### Color and text definition {{{1
+
+typeset -g hl_fatal hl_reset
+
+# die messages
+if (( $terminfo[colors] >= 8 )); then
+ hl_fatal='%F{red}%B'; hl_fatal=${(%)hl_fatal}
+ hl_warn='%F{yellow}%B'; hl_warn=${(%)hl_warn}
+ hl_reset='%b%f'; hl_reset=${(%)hl_reset}
+fi
+
+### Utility functions {{{1
+err_msg() {
+ local first=$1
+ shift
+ printf >&2 '%s%s%s%s\n' "$hl_fatal" "$first" "$hl_reset" "$*"
+}
+
+warn_msg() {
+ local first=$1
+ shift
+ printf >&2 '%s%s%s%s\n' "$hl_warn" "$first" "$hl_reset" "$*"
+}
+
+# helper that prints out stack, error message and exits
+die_ret() {
+ set +x
+ local ret n
+ ret=$1
+ shift
+ print -r - >&2 "${hl_fatal}Fatal$hl_reset error occurend in:"
+ for n in {${#funcfiletrace}..1}; do
+ printf >&2 '%d> %s (%s)\n' $n "$funcfiletrace[$n]" "$functrace[$n]"
+ done
+ printf >&2 '%s\n' "${hl_fatal}*$hl_reset $^@"
+ exit $ret
+}
+
+die() {
+ set +x
+ die_ret 1 "$@"
+}
+die100() { # 100: wrong usage
+ set +x
+ die_ret 100 "$@"
+}
+die111() { # 111: system call failed
+ set +x
+ die_ret 111 "$@"
+}
+
+-() { # Run command and die on nonzero exitcode
+ "$@" || die_ret $? "command failed with exitcode $?: ${(j: :)${(q)@}}"
+}
+
+