merchant

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

check-db-naming.py (6496B)


      1 #!/usr/bin/env python3
      2 """Enforce the database layer naming convention.
      3 
      4 See $HOME/database-names.md for the rules themselves; the short version
      5 is that a reader must be able to tell from the name alone whether a call
      6 reads or writes and whether it yields one result or many.
      7 
      8 Rules checked:
      9 
     10   1. every public function's name starts with the name of the file it lives in
     11   2. the file's own name uses a prefix from the closed set, or is exempt
     12   3. iterate_ iff the function takes a caller-supplied callback; get_ iff not
     13   4. a .h exists beside every .c, and every .sql belongs to a .c
     14   5. every prepared statement is named after the function preparing it
     15   6. no prepared statement name is used in two files
     16   7. no numeric suffixes and no repeated library prefix
     17   8. merchant-database/all.h lists every header
     18 
     19 Run from the top of the source tree; exits non-zero on any violation.
     20 """
     21 import re
     22 import sys
     23 import pathlib
     24 
     25 # ---------------------------------------------------------------- CONFIG --
     26 # (impl dir, header dir, symbol prefix, .c basename prefix)
     27 LAYERS = [
     28     ("src/backenddb", "src/include/merchant-database", "TALER_MERCHANTDB_", ""),
     29 ]
     30 
     31 # Prefixes that describe what the caller gets.
     32 PREFIXES = ("get_", "get_count_", "get_exists_", "iterate_",
     33             "insert_", "update_", "update_to_", "delete_", "do_")
     34 
     35 # Not CRUD: transaction control, DDL and maintenance, events, and the
     36 # session/schema plumbing that switches the per-instance search_path.
     37 # Keep this list short and justified -- every entry is a rule the checker
     38 # stops enforcing.
     39 EXEMPT = {
     40     "start", "start_read_committed", "commit", "rollback",
     41     "preflight", "disconnect",
     42     "create_tables", "drop_tables", "gc",
     43     "event_listen", "event_listen_cancel", "event_notify",
     44     "set_instance",
     45 }
     46 
     47 # Files that are not part of the API surface at all: the connection
     48 # handling, the PREPARE macros, and the free_* helpers.
     49 SKIP = {"pg", "helper", "merchantdb_helper", "template"}
     50 
     51 # .sql files that define shared SQL machinery rather than the body of one
     52 # API function.  Everything else must be named after its .c file.
     53 SQL_SKIP_PREFIXES = ("pg_", "example-")
     54 SQL_SKIP = {"future"}
     55 # ------------------------------------------------------------ END CONFIG --
     56 
     57 PREP = re.compile(r'PREPARE\s*\(\s*\w+\s*,\s*"([A-Za-z0-9_]+)"', re.S)
     58 
     59 errors = []
     60 
     61 
     62 def check_layer(impl_dir, hdr_dir, prefix, fpfx):
     63     impl, hdr = pathlib.Path(impl_dir), pathlib.Path(hdr_dir)
     64     if not impl.is_dir():
     65         return
     66     decl = re.compile(r"^" + re.escape(prefix) + r"(\w+)\s*\((.*?)\)\s*;",
     67                       re.S | re.M)
     68     stmt_owner = {}
     69 
     70     for c in sorted(impl.glob("*.c")):
     71         if c.stem in SKIP or c.stem.startswith("test_") \
     72            or c.stem.startswith("plugin_"):
     73             continue
     74         if fpfx and not c.stem.startswith(fpfx):
     75             continue
     76         stem = c.stem[len(fpfx):] if fpfx else c.stem
     77 
     78         # 2. prefix from the closed set, or exempt
     79         if stem not in EXEMPT and not stem.startswith(PREFIXES):
     80             errors.append(f"{c}: '{stem}' uses no approved prefix")
     81 
     82         # 7. numeric suffix
     83         if re.search(r"\d$", stem):
     84             errors.append(f"{c}: '{stem}' ends in a digit")
     85 
     86         # 4. matching header
     87         h = hdr / (stem + ".h")
     88         if not h.exists():
     89             errors.append(f"{c}: no matching header {h}")
     90 
     91         text = c.read_text(errors="replace")
     92 
     93         # 5. prepared statements named after the function.  Exempt entries
     94         # are the documented multi-function modules, where the statement
     95         # belongs to one of several functions in the file.
     96         for s in dict.fromkeys(PREP.findall(text)):
     97             if (stem not in EXEMPT
     98                     and s != stem and not s.startswith(stem + "_")):
     99                 errors.append(
    100                     f"{c}: prepared statement '{s}' is not named after "
    101                     f"'{stem}'")
    102             # 6. cross-file uniqueness
    103             if s in stmt_owner and stmt_owner[s] != str(c):
    104                 errors.append(
    105                     f"{c}: prepared statement '{s}' also prepared in "
    106                     f"{stmt_owner[s]}")
    107             stmt_owner[s] = str(c)
    108 
    109         if not h.exists():
    110             continue
    111         htext = re.sub(r"/\*.*?\*/", "", h.read_text(errors="replace"),
    112                        flags=re.S)
    113         for name, params in decl.findall(htext):
    114             # 1. declared names belong to their file.  Exempt entries are
    115             # the documented multi-function modules, so this does not
    116             # apply there.
    117             if (stem not in EXEMPT
    118                     and name != stem and not name.startswith(stem + "_")):
    119                 errors.append(
    120                     f"{h}: declares '{name}', which does not belong to "
    121                     f"'{stem}'")
    122             # 3. iterate_ iff callback
    123             has_cb = bool(re.search(r"\w*(Callback|Iterator)\s+\w+", params))
    124             if name.startswith("iterate_") and not has_cb:
    125                 errors.append(
    126                     f"{h}: '{name}' is iterate_ but takes no callback")
    127             if name.startswith("get_") and has_cb:
    128                 errors.append(f"{h}: '{name}' takes a callback; use iterate_")
    129 
    130     # 4. every .sql belongs to a .c of the same name
    131     for s in sorted(impl.glob("*.sql")):
    132         if s.stem in SQL_SKIP or s.stem.startswith(SQL_SKIP_PREFIXES):
    133             continue
    134         if not (impl / (s.stem + ".c")).exists():
    135             errors.append(f"{s}: no matching {impl / (s.stem + '.c')}")
    136 
    137     # 7. repeated library prefix anywhere in the tree
    138     head = prefix.split("_")[0]
    139     for f in list(impl.glob("*.[ch]")) + list(hdr.glob("*.h")):
    140         if "unc-backup" in f.name:
    141             continue
    142         if re.search(r"\b" + re.escape(head) + r"_" + re.escape(prefix),
    143                      f.read_text(errors="replace")):
    144             errors.append(f"{f}: repeated '{prefix}' prefix")
    145 
    146     # 8. the umbrella header lists every header
    147     umbrella = hdr / "all.h"
    148     if umbrella.exists():
    149         listed = set(re.findall(r'#include\s+"[^"]*/(\w+)\.h"',
    150                                 umbrella.read_text(errors="replace")))
    151         for h in sorted(hdr.glob("*.h")):
    152             if h.stem != "all" and h.stem not in listed:
    153                 errors.append(f"{umbrella}: does not include {h.name}")
    154 
    155 
    156 for a in LAYERS:
    157     check_layer(*a)
    158 
    159 for e in errors:
    160     print("ERROR:", e)
    161 print(f"\ndb-naming: {len(errors)} violation(s)")
    162 sys.exit(1 if errors else 0)