merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

commit 28c230f3a099f3e4c478ffa3449cfd8f8d1d5e7b
parent fdf7051569ecc0cf0d60d5cc75c10e760d7a61b5
Author: Christian Grothoff <christian@grothoff.org>
Date:   Wed, 22 Jul 2026 03:22:21 +0200

merchantdb: enforce the naming convention in CI

Diffstat:
Acontrib/check-db-naming.py | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontrib/ci/jobs/6-db-naming/config.ini | 6++++++
Acontrib/ci/jobs/6-db-naming/job.sh | 4++++
3 files changed, 172 insertions(+), 0 deletions(-)

diff --git a/contrib/check-db-naming.py b/contrib/check-db-naming.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Enforce the database layer naming convention. + +See $HOME/database-names.md for the rules themselves; the short version +is that a reader must be able to tell from the name alone whether a call +reads or writes and whether it yields one result or many. + +Rules checked: + + 1. every public function's name starts with the name of the file it lives in + 2. the file's own name uses a prefix from the closed set, or is exempt + 3. iterate_ iff the function takes a caller-supplied callback; get_ iff not + 4. a .h exists beside every .c, and every .sql belongs to a .c + 5. every prepared statement is named after the function preparing it + 6. no prepared statement name is used in two files + 7. no numeric suffixes and no repeated library prefix + 8. merchant-database/all.h lists every header + +Run from the top of the source tree; exits non-zero on any violation. +""" +import re +import sys +import pathlib + +# ---------------------------------------------------------------- CONFIG -- +# (impl dir, header dir, symbol prefix, .c basename prefix) +LAYERS = [ + ("src/backenddb", "src/include/merchant-database", "TALER_MERCHANTDB_", ""), +] + +# Prefixes that describe what the caller gets. +PREFIXES = ("get_", "get_count_", "get_exists_", "iterate_", + "insert_", "update_", "update_to_", "delete_", "do_") + +# Not CRUD: transaction control, DDL and maintenance, events, and the +# session/schema plumbing that switches the per-instance search_path. +# Keep this list short and justified -- every entry is a rule the checker +# stops enforcing. +EXEMPT = { + "start", "start_read_committed", "commit", "rollback", + "preflight", "disconnect", + "create_tables", "drop_tables", "gc", + "event_listen", "event_listen_cancel", "event_notify", + "set_instance", +} + +# Files that are not part of the API surface at all: the connection +# handling, the PREPARE macros, and the free_* helpers. +SKIP = {"pg", "helper", "merchantdb_helper", "template"} + +# .sql files that define shared SQL machinery rather than the body of one +# API function. Everything else must be named after its .c file. +SQL_SKIP_PREFIXES = ("pg_", "example-") +SQL_SKIP = {"future"} +# ------------------------------------------------------------ END CONFIG -- + +PREP = re.compile(r'PREPARE\s*\(\s*\w+\s*,\s*"([A-Za-z0-9_]+)"', re.S) + +errors = [] + + +def check_layer(impl_dir, hdr_dir, prefix, fpfx): + impl, hdr = pathlib.Path(impl_dir), pathlib.Path(hdr_dir) + if not impl.is_dir(): + return + decl = re.compile(r"^" + re.escape(prefix) + r"(\w+)\s*\((.*?)\)\s*;", + re.S | re.M) + stmt_owner = {} + + for c in sorted(impl.glob("*.c")): + if c.stem in SKIP or c.stem.startswith("test_") \ + or c.stem.startswith("plugin_"): + continue + if fpfx and not c.stem.startswith(fpfx): + continue + stem = c.stem[len(fpfx):] if fpfx else c.stem + + # 2. prefix from the closed set, or exempt + if stem not in EXEMPT and not stem.startswith(PREFIXES): + errors.append(f"{c}: '{stem}' uses no approved prefix") + + # 7. numeric suffix + if re.search(r"\d$", stem): + errors.append(f"{c}: '{stem}' ends in a digit") + + # 4. matching header + h = hdr / (stem + ".h") + if not h.exists(): + errors.append(f"{c}: no matching header {h}") + + text = c.read_text(errors="replace") + + # 5. prepared statements named after the function. Exempt entries + # are the documented multi-function modules, where the statement + # belongs to one of several functions in the file. + for s in dict.fromkeys(PREP.findall(text)): + if (stem not in EXEMPT + and s != stem and not s.startswith(stem + "_")): + errors.append( + f"{c}: prepared statement '{s}' is not named after " + f"'{stem}'") + # 6. cross-file uniqueness + if s in stmt_owner and stmt_owner[s] != str(c): + errors.append( + f"{c}: prepared statement '{s}' also prepared in " + f"{stmt_owner[s]}") + stmt_owner[s] = str(c) + + if not h.exists(): + continue + htext = re.sub(r"/\*.*?\*/", "", h.read_text(errors="replace"), + flags=re.S) + for name, params in decl.findall(htext): + # 1. declared names belong to their file. Exempt entries are + # the documented multi-function modules, so this does not + # apply there. + if (stem not in EXEMPT + and name != stem and not name.startswith(stem + "_")): + errors.append( + f"{h}: declares '{name}', which does not belong to " + f"'{stem}'") + # 3. iterate_ iff callback + has_cb = bool(re.search(r"\w*(Callback|Iterator)\s+\w+", params)) + if name.startswith("iterate_") and not has_cb: + errors.append( + f"{h}: '{name}' is iterate_ but takes no callback") + if name.startswith("get_") and has_cb: + errors.append(f"{h}: '{name}' takes a callback; use iterate_") + + # 4. every .sql belongs to a .c of the same name + for s in sorted(impl.glob("*.sql")): + if s.stem in SQL_SKIP or s.stem.startswith(SQL_SKIP_PREFIXES): + continue + if not (impl / (s.stem + ".c")).exists(): + errors.append(f"{s}: no matching {impl / (s.stem + '.c')}") + + # 7. repeated library prefix anywhere in the tree + head = prefix.split("_")[0] + for f in list(impl.glob("*.[ch]")) + list(hdr.glob("*.h")): + if "unc-backup" in f.name: + continue + if re.search(r"\b" + re.escape(head) + r"_" + re.escape(prefix), + f.read_text(errors="replace")): + errors.append(f"{f}: repeated '{prefix}' prefix") + + # 8. the umbrella header lists every header + umbrella = hdr / "all.h" + if umbrella.exists(): + listed = set(re.findall(r'#include\s+"[^"]*/(\w+)\.h"', + umbrella.read_text(errors="replace"))) + for h in sorted(hdr.glob("*.h")): + if h.stem != "all" and h.stem not in listed: + errors.append(f"{umbrella}: does not include {h.name}") + + +for a in LAYERS: + check_layer(*a) + +for e in errors: + print("ERROR:", e) +print(f"\ndb-naming: {len(errors)} violation(s)") +sys.exit(1 if errors else 0) diff --git a/contrib/ci/jobs/6-db-naming/config.ini b/contrib/ci/jobs/6-db-naming/config.ini @@ -0,0 +1,6 @@ +[build] +HALT_ON_FAILURE = True +WARN_ON_FAILURE = False +CONTAINER_BUILD = True +CONTAINER_NAME = localhost/merchant +CONTAINER_ARCH = amd64 diff --git a/contrib/ci/jobs/6-db-naming/job.sh b/contrib/ci/jobs/6-db-naming/job.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -exuo pipefail + +exec ./contrib/check-db-naming.py