challenger

OAuth 2.0-based authentication service that validates user can receive messages at a certain address
Log | Files | Refs | Submodules | README | LICENSE

check-db-naming.py (5743B)


      1 #!/usr/bin/env python3
      2 """Enforce the database layer naming convention.
      3 
      4 Rules checked:
      5 
      6   1. every public function's name starts with the name of the file it lives in
      7   2. the file's own name uses a prefix from the closed set, or is exempt
      8   3. iterate_ iff the function takes a caller-supplied callback; get_ iff not
      9   4. a .h exists beside every .c, and every .sql that is not schema or
     10      boilerplate is named after the .c that runs it
     11   5. every prepared statement is named after the function preparing it
     12   6. no prepared statement name is used in two files
     13   7. no numeric suffixes and no repeated library prefix
     14 
     15 Run from the top of the source tree; exits non-zero on any violation.
     16 """
     17 import re
     18 import sys
     19 import pathlib
     20 
     21 # ---------------------------------------------------------------- CONFIG --
     22 # (impl dir, header dir, symbol prefix, .c basename prefix)
     23 LAYERS = [
     24     ("src/challengerdb", "src/include/challenger-database", "CHALLENGERDB_",
     25      ""),
     26 ]
     27 
     28 # Prefixes that describe what the caller gets.
     29 PREFIXES = ("get_", "get_count_", "get_exists_", "iterate_",
     30             "insert_", "update_", "update_to_", "delete_", "do_")
     31 
     32 # Not CRUD: DDL and maintenance.  Keep this list short and justified -- every
     33 # entry is a rule the checker stops enforcing.  Connection handling
     34 # (CHALLENGERDB_connect/_connect_admin/_disconnect) lives in pg.c, which is
     35 # skipped below, and challenger has no explicit transaction control.
     36 EXEMPT = {
     37     "create_tables", "drop_tables", "gc", "preflight",
     38 }
     39 
     40 # Files that are not part of the API surface at all: the connection handling,
     41 # the shared PREPARE macro, the copy-me template, and the dbinit binary.
     42 SKIP = {"pg", "pg_helper", "template", "challenger-dbinit"}
     43 
     44 # SQL that belongs to the schema rather than to one API function.
     45 SQL_NOT_A_FUNCTION = {"versioning", "drop", "commit", "procedures-prelude"}
     46 SQL_SCHEMA_RE = re.compile(r"^challenger-\d+$")
     47 # ------------------------------------------------------------ END CONFIG --
     48 
     49 PREP = re.compile(r'PREPARE\s*\(\s*\w+\s*,\s*"([A-Za-z0-9_]+)"', re.S)
     50 
     51 errors = []
     52 
     53 
     54 def check_layer(impl_dir, hdr_dir, prefix, fpfx):
     55     impl, hdr = pathlib.Path(impl_dir), pathlib.Path(hdr_dir)
     56     if not impl.is_dir():
     57         return
     58     decl = re.compile(r"^" + re.escape(prefix) + r"(\w+)\s*\((.*?)\)\s*;",
     59                       re.S | re.M)
     60     stmt_owner = {}
     61     stems = set()
     62 
     63     for c in sorted(impl.glob("*.c")):
     64         if c.stem in SKIP or c.stem.startswith("test_") \
     65            or c.stem.startswith("plugin_"):
     66             continue
     67         if fpfx and not c.stem.startswith(fpfx):
     68             continue
     69         stem = c.stem[len(fpfx):] if fpfx else c.stem
     70         stems.add(stem)
     71 
     72         # 2. prefix from the closed set, or exempt
     73         if stem not in EXEMPT and not stem.startswith(PREFIXES):
     74             errors.append(f"{c}: '{stem}' uses no approved prefix")
     75 
     76         # 7. numeric suffix
     77         if re.search(r"\d$", stem):
     78             errors.append(f"{c}: '{stem}' ends in a digit")
     79 
     80         # 4. matching header
     81         h = hdr / (stem + ".h")
     82         if stem not in EXEMPT and not h.exists():
     83             errors.append(f"{c}: no matching header {h}")
     84 
     85         text = c.read_text(errors="replace")
     86 
     87         # 5. prepared statements named after the function
     88         for s in dict.fromkeys(PREP.findall(text)):
     89             if s != stem and not s.startswith(stem + "_"):
     90                 errors.append(
     91                     f"{c}: prepared statement '{s}' is not named after "
     92                     f"'{stem}'")
     93             # 6. cross-file uniqueness
     94             if s in stmt_owner and stmt_owner[s] != str(c):
     95                 errors.append(
     96                     f"{c}: prepared statement '{s}' also prepared in "
     97                     f"{stmt_owner[s]}")
     98             stmt_owner[s] = str(c)
     99 
    100         if not h.exists():
    101             continue
    102         htext = re.sub(r"/\*.*?\*/", "", h.read_text(errors="replace"),
    103                        flags=re.S)
    104         for name, params in decl.findall(htext):
    105             # 1. declared names belong to their file.  Exempt entries are the
    106             # documented multi-function modules, so this does not apply there.
    107             if (stem not in EXEMPT
    108                     and name != stem and not name.startswith(stem + "_")):
    109                 errors.append(
    110                     f"{h}: declares '{name}', which does not belong to "
    111                     f"'{stem}'")
    112             # 3. iterate_ iff callback
    113             has_cb = bool(re.search(r"\w*(Callback|Iterator)\s+\w+", params))
    114             if name.startswith("iterate_") and not has_cb:
    115                 errors.append(
    116                     f"{h}: '{name}' is iterate_ but takes no callback")
    117             if name.startswith("get_") and has_cb:
    118                 errors.append(f"{h}: '{name}' takes a callback; use iterate_")
    119 
    120     # 4. an amalgamated .sql is named after the .c that runs it, so that a
    121     # renamed function cannot leave its stored procedure behind.
    122     for s in sorted(impl.glob("*.sql")):
    123         if s.stem in SQL_NOT_A_FUNCTION or SQL_SCHEMA_RE.match(s.stem):
    124             continue
    125         if s.stem not in stems:
    126             errors.append(f"{s}: no {impl}/{s.stem}.c runs this SQL")
    127 
    128     # 7. repeated library prefix anywhere in the tree
    129     head = prefix.split("_")[0]
    130     for f in list(impl.glob("*.[ch]")) + list(hdr.glob("*.h")):
    131         if "unc-backup" in f.name:
    132             continue
    133         if re.search(r"\b" + re.escape(head) + r"_" + re.escape(prefix),
    134                      f.read_text(errors="replace")):
    135             errors.append(f"{f}: repeated '{prefix}' prefix")
    136 
    137 
    138 for a in LAYERS:
    139     check_layer(*a)
    140 
    141 for e in errors:
    142     print("ERROR:", e)
    143 print(f"\ndb-naming: {len(errors)} violation(s)")
    144 sys.exit(1 if errors else 0)