mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-11-01 02:31:46 +08:00
- migrate auth, manager, data, and agentic persistence to R2DBC - hard cut pagination, facade, gateway, web, and CLI contracts - remove legacy database and TSDB adapters and document validation gates BREAKING CHANGE: platform persistence now requires PostgreSQL 18 with TimescaleDB and the new reactive API contracts.
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
|
|
#
|
|
# Copyright 2016-present the IoT DC3 original author or authors.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
CREATE_TABLE_RE = re.compile(
|
|
r"^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+(?P<table>[a-z][a-z0-9_]*)\s*$",
|
|
re.IGNORECASE,
|
|
)
|
|
COMMENT_TABLE_RE = re.compile(
|
|
r"COMMENT\s+ON\s+TABLE\s+(?P<table>[a-z][a-z0-9_]*)\s+IS\s+",
|
|
re.IGNORECASE,
|
|
)
|
|
COMMENT_COLUMN_RE = re.compile(
|
|
r"COMMENT\s+ON\s+COLUMN\s+(?P<table>[a-z][a-z0-9_]*)\.(?P<column>[a-z][a-z0-9_]*)\s+IS\s+",
|
|
re.IGNORECASE,
|
|
)
|
|
COLUMN_RE = re.compile(r"^(?P<column>[a-z][a-z0-9_]*)\s+", re.IGNORECASE)
|
|
TABLE_NAME_RE = re.compile(
|
|
r'@TableName\s*\(\s*(?:value\s*=\s*)?"(?P<table>[a-z][a-z0-9_]*)"',
|
|
re.IGNORECASE,
|
|
)
|
|
MAPPED_COLUMN_RE = re.compile(
|
|
r'@(?:TableField|TableId)\s*\(\s*(?:value\s*=\s*)?"(?P<column>[a-z][a-z0-9_]*)"',
|
|
re.IGNORECASE,
|
|
)
|
|
NON_COLUMN_PREFIXES = {"check", "constraint", "exclude", "foreign", "primary", "references", "unique"}
|
|
LICENSE_MARKER = "Copyright 2016-present the IoT DC3 original author or authors."
|
|
MODULE_SQL_PREFIXES = {
|
|
"dc3-common-agentic": ("06-",),
|
|
"dc3-common-auth": ("02-",),
|
|
"dc3-common-data": ("03-", "05-"),
|
|
"dc3-common-manager": ("04-",),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Table:
|
|
name: str
|
|
line: int
|
|
columns: tuple[str, ...]
|
|
|
|
|
|
def collect_tables(text: str) -> list[Table]:
|
|
lines = text.splitlines()
|
|
tables: list[Table] = []
|
|
|
|
for index, line in enumerate(lines):
|
|
match = CREATE_TABLE_RE.match(line.strip())
|
|
if match is None or index + 1 >= len(lines) or lines[index + 1].strip() != "(":
|
|
continue
|
|
|
|
columns: list[str] = []
|
|
for body_line in lines[index + 2:]:
|
|
stripped = body_line.split("--", maxsplit=1)[0].strip().rstrip(",")
|
|
if stripped == ");":
|
|
break
|
|
column_match = COLUMN_RE.match(stripped)
|
|
if column_match is None:
|
|
continue
|
|
column = column_match.group("column").lower()
|
|
if column not in NON_COLUMN_PREFIXES:
|
|
columns.append(column)
|
|
else:
|
|
raise ValueError(f"unterminated CREATE TABLE at line {index + 1}")
|
|
|
|
tables.append(Table(match.group("table").lower(), index + 1, tuple(columns)))
|
|
|
|
return tables
|
|
|
|
|
|
def validate_file(path: Path) -> tuple[list[str], int, int]:
|
|
text = path.read_text(encoding="utf-8")
|
|
normalized = " ".join(text.split())
|
|
tables = collect_tables(text)
|
|
table_comments = {match.group("table").lower() for match in COMMENT_TABLE_RE.finditer(normalized)}
|
|
column_comments = {
|
|
(match.group("table").lower(), match.group("column").lower())
|
|
for match in COMMENT_COLUMN_RE.finditer(normalized)
|
|
}
|
|
errors: list[str] = []
|
|
|
|
if text.count(LICENSE_MARKER) != 1:
|
|
errors.append(f"{path.name}: expected exactly one license header")
|
|
if re.search(r"=\s+>", text):
|
|
errors.append(f"{path.name}: invalid named-argument token '= >'; use '=>'")
|
|
for table in tables:
|
|
label = f"{path.name}:{table.line} {table.name}"
|
|
if table.name not in table_comments:
|
|
errors.append(f"{label}: missing COMMENT ON TABLE")
|
|
for column in table.columns:
|
|
if (table.name, column) not in column_comments:
|
|
errors.append(f"{label}.{column}: missing COMMENT ON COLUMN")
|
|
|
|
return errors, len(tables), sum(len(table.columns) for table in tables)
|
|
|
|
|
|
def validate_entities(repo_root: Path, tables_by_file: dict[str, list[Table]]) -> tuple[list[str], int]:
|
|
errors: list[str] = []
|
|
entity_count = 0
|
|
|
|
for path in sorted(repo_root.glob("dc3-common/**/src/main/java/**/*.java")):
|
|
text = path.read_text(encoding="utf-8")
|
|
table_match = TABLE_NAME_RE.search(text)
|
|
if table_match is None:
|
|
continue
|
|
|
|
module = next((part for part in path.parts if part in MODULE_SQL_PREFIXES), None)
|
|
if module is None:
|
|
continue
|
|
entity_count += 1
|
|
table_name = table_match.group("table").lower()
|
|
prefixes = MODULE_SQL_PREFIXES[module]
|
|
candidates = [
|
|
table
|
|
for file_name, tables in tables_by_file.items()
|
|
if file_name.startswith(prefixes)
|
|
for table in tables
|
|
if table.name == table_name
|
|
]
|
|
label = str(path.relative_to(repo_root))
|
|
if not candidates:
|
|
errors.append(f"{label}: @TableName references missing initialization table {table_name}")
|
|
continue
|
|
if len(candidates) > 1:
|
|
errors.append(f"{label}: initialization table {table_name} is ambiguous within module schema")
|
|
continue
|
|
|
|
sql_columns = set(candidates[0].columns)
|
|
mapped_columns = {match.group("column").lower() for match in MAPPED_COLUMN_RE.finditer(text)}
|
|
missing_in_sql = sorted(mapped_columns - sql_columns)
|
|
missing_in_entity = sorted(sql_columns - mapped_columns)
|
|
if missing_in_sql:
|
|
errors.append(f"{label}: mapped columns absent from {table_name}: {', '.join(missing_in_sql)}")
|
|
if missing_in_entity:
|
|
errors.append(f"{label}: initialization columns absent from entity: {', '.join(missing_in_entity)}")
|
|
|
|
return errors, entity_count
|
|
|
|
|
|
def main() -> int:
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
init_dir = repo_root / "dc3" / "dependencies" / "postgres" / "initdb"
|
|
sql_files = sorted(init_dir.glob("*.sql"))
|
|
errors: list[str] = []
|
|
table_count = 0
|
|
column_count = 0
|
|
tables_by_file: dict[str, list[Table]] = {}
|
|
|
|
for path in sql_files:
|
|
try:
|
|
file_errors, file_tables, file_columns = validate_file(path)
|
|
tables_by_file[path.name] = collect_tables(path.read_text(encoding="utf-8"))
|
|
except ValueError as error:
|
|
file_errors, file_tables, file_columns = [f"{path.name}: {error}"], 0, 0
|
|
errors.extend(file_errors)
|
|
table_count += file_tables
|
|
column_count += file_columns
|
|
|
|
entity_errors, entity_count = validate_entities(repo_root, tables_by_file)
|
|
errors.extend(entity_errors)
|
|
|
|
if errors:
|
|
print("PostgreSQL initialization policy check failed:", file=sys.stderr)
|
|
for error in errors:
|
|
print(f"- {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(
|
|
"PostgreSQL initialization policy check passed: "
|
|
f"files={len(sql_files)}, tables={table_count}, columns={column_count}, entities={entity_count}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|