Initial SQL-only 1C adapter baseline

This commit is contained in:
2026-07-22 03:03:47 +03:00
commit e2503b77e7
545 changed files with 184711 additions and 0 deletions
+267
View File
@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""Inspect exported 1C SQL Config/Params files without semantic guessing.
This script operates on files exported from Config/ConfigSave/ConfigCAS/
ConfigCASSave/Params tables. It only performs mechanical steps:
1. try known compression envelopes;
2. try common text encodings;
3. parse the brace-based 1C serialized value syntax into a generic tree;
4. report structural shape, strings, GUIDs, and selected tree paths.
It deliberately does not map SQLKind codes to 1C object types.
"""
from __future__ import annotations
import argparse
import gzip
import json
import re
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
GUID_RE = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
def try_decompress(data: bytes) -> tuple[bytes, str]:
attempts = (
("raw_deflate", lambda value: zlib.decompress(value, -15)),
("zlib", zlib.decompress),
("gzip", gzip.decompress),
)
for name, func in attempts:
try:
return func(data), name
except Exception:
pass
return data, "none"
def try_decode(data: bytes) -> tuple[str | None, str | None]:
candidates = ("utf-8-sig", "utf-16-le", "utf-16-be", "cp1251")
best: tuple[str | None, str | None, int] = (None, None, -1)
for encoding in candidates:
try:
text = data.decode(encoding)
except UnicodeDecodeError:
continue
printable = sum(1 for char in text[:20000] if char.isprintable() or char in "\r\n\t")
nul_count = text[:20000].count("\x00")
score = printable - nul_count * 10
if score > best[2]:
best = (text, encoding, score)
return best[0], best[1]
@dataclass
class Token:
kind: str
value: str
pos: int
class Lexer:
def __init__(self, text: str) -> None:
self.text = text
self.pos = 0
def tokens(self) -> list[Token]:
result: list[Token] = []
while self.pos < len(self.text):
char = self.text[self.pos]
if char.isspace():
self.pos += 1
continue
if char in "{},:":
result.append(Token(char, char, self.pos))
self.pos += 1
continue
if char == '"':
result.append(self._string())
continue
result.append(self._atom())
result.append(Token("EOF", "", self.pos))
return result
def _string(self) -> Token:
start = self.pos
self.pos += 1
chars: list[str] = []
while self.pos < len(self.text):
char = self.text[self.pos]
self.pos += 1
if char == '"':
if self.pos < len(self.text) and self.text[self.pos] == '"':
chars.append('"')
self.pos += 1
continue
break
chars.append(char)
return Token("string", "".join(chars), start)
def _atom(self) -> Token:
start = self.pos
while self.pos < len(self.text):
char = self.text[self.pos]
if char.isspace() or char in "{},:":
break
self.pos += 1
return Token("atom", self.text[start : self.pos], start)
class Parser:
def __init__(self, tokens: list[Token]) -> None:
self.tokens = tokens
self.index = 0
def parse(self) -> Any:
values = []
while not self._peek("EOF"):
if self._peek(","):
self.index += 1
continue
values.append(self._value())
if len(values) == 1:
return values[0]
return {"type": "sequence", "items": values}
def _value(self) -> Any:
if self._peek("{"):
return self._list()
token = self._next()
if token.kind == "string":
return {"type": "string", "value": token.value}
if token.kind == "atom":
return {"type": "atom", "value": token.value}
return {"type": "token", "kind": token.kind, "value": token.value}
def _list(self) -> Any:
start = self._next()
items = []
while not self._peek("EOF") and not self._peek("}"):
if self._peek(","):
self.index += 1
continue
items.append(self._value())
if self._peek("}"):
self.index += 1
return {"type": "list", "pos": start.pos, "items": items}
def _peek(self, kind: str) -> bool:
return self.tokens[self.index].kind == kind
def _next(self) -> Token:
token = self.tokens[self.index]
self.index += 1
return token
def tree_shape(value: Any, depth: int = 0, max_depth: int = 4) -> Any:
if depth >= max_depth:
return "..."
if isinstance(value, dict) and value.get("type") == "list":
items = value.get("items") or []
return {
"type": "list",
"len": len(items),
"items": [tree_shape(item, depth + 1, max_depth) for item in items[:12]],
}
if isinstance(value, dict) and value.get("type") in {"string", "atom"}:
raw = str(value.get("value") or "")
return {"type": value["type"], "value": raw[:120], "len": len(raw)}
if isinstance(value, dict) and value.get("type") == "sequence":
items = value.get("items") or []
return {
"type": "sequence",
"len": len(items),
"items": [tree_shape(item, depth + 1, max_depth) for item in items[:12]],
}
return str(value)[:120]
def collect_strings(value: Any, limit: int = 200) -> list[str]:
result: list[str] = []
def walk(node: Any) -> None:
if len(result) >= limit:
return
if isinstance(node, dict) and node.get("type") == "string":
text = str(node.get("value") or "")
if text:
result.append(text)
return
if isinstance(node, dict):
for child in node.get("items") or []:
walk(child)
walk(value)
return result
def inspect_file(path: Path, *, parse_limit_chars: int) -> dict[str, Any]:
original = path.read_bytes()
payload, compression = try_decompress(original)
text, encoding = try_decode(payload)
result: dict[str, Any] = {
"file_name": path.name,
"bytes": len(original),
"payload_bytes": len(payload),
"compression": compression,
"encoding": encoding,
"guids": [],
"strings": [],
"parse_status": "not_text",
}
if text is None:
return result
clean = text.replace("\x00", "").replace("\ufeff", "")
result["text_preview"] = clean[:500]
result["guids"] = sorted(set(match.lower() for match in GUID_RE.findall(clean)))[:200]
if "{" not in clean:
result["parse_status"] = "text_no_braces"
return result
parse_text = clean[:parse_limit_chars]
try:
parsed = Parser(Lexer(parse_text).tokens()).parse()
except Exception as exc:
result["parse_status"] = "parse_error"
result["parse_error"] = str(exc)
return result
result["parse_status"] = "parsed_prefix" if len(clean) > parse_limit_chars else "parsed"
result["shape"] = tree_shape(parsed)
result["strings"] = collect_strings(parsed)
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect exported 1C SQL files mechanically.")
parser.add_argument("input", type=Path, help="Directory with exported SQL files")
parser.add_argument("--output", type=Path, required=True, help="JSON report path")
parser.add_argument("--parse-limit-chars", type=int, default=2_000_000)
parser.add_argument("--limit", type=int, default=0)
args = parser.parse_args()
files = sorted(path for path in args.input.iterdir() if path.is_file())
if args.limit > 0:
files = files[: args.limit]
report = {
"schema": "onec_sql_file_inspection.v1",
"input": str(args.input),
"file_count": len(files),
"files": [inspect_file(path, parse_limit_chars=args.parse_limit_chars) for path in files],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"output": str(args.output), "files": len(files)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())