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:
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