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)