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)