273 lines
9.7 KiB
Python
273 lines
9.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""测试用假 sqlor(内存表),只实现 sqlor 标准 API 子集:C/U/D/R/I/sqlExe。
|
||
|
||
要点:
|
||
* ``sqlExe`` 先把 ``%s`` 占位按序插值为字面量,再用极简解析器执行
|
||
SELECT / SELECT COUNT(1) / LIMIT / OFFSET / IN / AND 等值条件;
|
||
* world / scene / entity 三张基础域基表**只读**:任何 C/U/D/I 写入都会
|
||
记录到 ``base_write_attempts`` 并抛 AssertionError,用于验证「不改基表」铁律。
|
||
"""
|
||
|
||
import re
|
||
|
||
__all__ = ["FakeSor", "make_fake_sor", "BASE_FIXTURES"]
|
||
|
||
BASE_FIXTURES = {
|
||
"world": [
|
||
{"id": "W1", "tenant_id": "T1", "code": "WLD-001", "name": "火星基地",
|
||
"status": "published", "created_at": "2026-09-01 10:00:00"},
|
||
{"id": "W2", "tenant_id": "T1", "code": "WLD-002", "name": "深海实验室",
|
||
"status": "draft", "created_at": "2026-09-02 10:00:00"},
|
||
{"id": "W3", "tenant_id": "T2", "code": "WLD-101", "name": "他租户世界",
|
||
"status": "draft", "created_at": "2026-09-03 10:00:00"},
|
||
],
|
||
"scene": [
|
||
{"id": "S1", "tenant_id": "T1", "world_id": "W1", "code": "SCN-001",
|
||
"name": "着陆区", "status": "active"},
|
||
{"id": "S2", "tenant_id": "T1", "world_id": "W1", "code": "SCN-002",
|
||
"name": "实验舱", "status": "active"},
|
||
{"id": "S3", "tenant_id": "T1", "world_id": "W2", "code": "SCN-003",
|
||
"name": "深水区", "status": "active"},
|
||
],
|
||
"entity": [
|
||
{"id": "E1", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-001",
|
||
"name": "探测车", "status": "idle"},
|
||
{"id": "E2", "tenant_id": "T1", "scene_id": "S1", "code": "ENT-002",
|
||
"name": "宇航员A", "status": "idle"},
|
||
{"id": "E3", "tenant_id": "T1", "scene_id": "S2", "code": "ENT-003",
|
||
"name": "培养皿", "status": "idle"},
|
||
],
|
||
}
|
||
|
||
_READONLY_TABLES = ("world", "scene", "entity")
|
||
|
||
|
||
def _literal(value):
|
||
"""把绑定参数转成 SQL 字面量(仅测试用)。"""
|
||
if value is None:
|
||
return "NULL"
|
||
if isinstance(value, bool):
|
||
return "1" if value else "0"
|
||
if isinstance(value, (int, float)):
|
||
return str(value)
|
||
return "'%s'" % str(value).replace("\\", "\\\\").replace("'", "''")
|
||
|
||
|
||
def _coerce(token):
|
||
"""把字面量 token 还原成 Python 值。"""
|
||
token = token.strip()
|
||
if token.upper() == "NULL":
|
||
return None
|
||
if len(token) >= 2 and token[0] == "'" and token[-1] == "'":
|
||
return token[1:-1].replace("''", "'").replace("\\\\", "\\")
|
||
try:
|
||
return int(token)
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
return float(token)
|
||
except ValueError:
|
||
return token
|
||
|
||
|
||
def _loose_eq(left, right):
|
||
"""宽松相等:兼容 '0' vs 0、str vs int 的列值比较。"""
|
||
if left == right:
|
||
return True
|
||
if left is None or right is None:
|
||
return False
|
||
try:
|
||
return float(left) == float(right)
|
||
except (TypeError, ValueError):
|
||
return str(left) == str(right)
|
||
|
||
|
||
class FakeSor(object):
|
||
"""内存版 sqlor。"""
|
||
|
||
def __init__(self, tables=None, base_fixtures=None):
|
||
self.tables = {}
|
||
for name, rows in (base_fixtures if base_fixtures is not None
|
||
else BASE_FIXTURES).items():
|
||
self.tables[name] = [dict(r) for r in rows]
|
||
for name, rows in (tables or {}).items():
|
||
self.tables[name] = [dict(r) for r in rows]
|
||
self.tables.setdefault("pbl_domain_ref", [])
|
||
self.sql_log = []
|
||
self.base_write_attempts = []
|
||
|
||
# ---- sqlor 标准 API -------------------------------------------------
|
||
def C(self, tbl, row):
|
||
self._guard_readonly(tbl, "C")
|
||
self.tables.setdefault(tbl, []).append(dict(row))
|
||
self.sql_log.append(("C", tbl, dict(row)))
|
||
return 1
|
||
|
||
def U(self, tbl, row, where):
|
||
self._guard_readonly(tbl, "U")
|
||
hits = 0
|
||
for exist in self.tables.get(tbl, []):
|
||
if self._match(exist, where):
|
||
exist.update(row)
|
||
hits += 1
|
||
self.sql_log.append(("U", tbl, dict(row), dict(where)))
|
||
return hits
|
||
|
||
def D(self, tbl, where):
|
||
self._guard_readonly(tbl, "D")
|
||
keep, hits = [], 0
|
||
for exist in self.tables.get(tbl, []):
|
||
if self._match(exist, where):
|
||
hits += 1
|
||
else:
|
||
keep.append(exist)
|
||
self.tables[tbl] = keep
|
||
self.sql_log.append(("D", tbl, dict(where)))
|
||
return hits
|
||
|
||
def R(self, tbl, where=None, fields=None, order_by=None, limit=None):
|
||
rows = [dict(r) for r in self.tables.get(tbl, [])
|
||
if self._match(r, where or {})]
|
||
if fields:
|
||
rows = [{k: r.get(k) for k in fields} for r in rows]
|
||
if limit:
|
||
rows = rows[:int(limit)]
|
||
return rows
|
||
|
||
def I(self, tbl, rows):
|
||
self._guard_readonly(tbl, "I")
|
||
for row in rows or []:
|
||
self.tables.setdefault(tbl, []).append(dict(row))
|
||
return len(rows or [])
|
||
|
||
def sqlExe(self, sql, args=None):
|
||
args = tuple(args or ())
|
||
self.sql_log.append(("sqlExe", " ".join(sql.split()), args))
|
||
text = self._interpolate(" ".join(sql.split()), args)
|
||
upper = text.upper()
|
||
if upper.startswith("SELECT COUNT(1)"):
|
||
return [{"cnt": len(self._rows(text))}]
|
||
if upper.startswith("SELECT"):
|
||
return self._select(text)
|
||
if upper.startswith("ALTER") or upper.startswith("CREATE"):
|
||
return 0
|
||
raise AssertionError("FakeSor.sqlExe 不支持的语句: %s" % text)
|
||
|
||
# ---- 内部 -----------------------------------------------------------
|
||
def _guard_readonly(self, tbl, op):
|
||
if tbl in _READONLY_TABLES:
|
||
self.base_write_attempts.append((op, tbl))
|
||
raise AssertionError("铁律违规:禁止写基础域基表 %s(op=%s)" % (tbl, op))
|
||
|
||
@staticmethod
|
||
def _interpolate(text, args):
|
||
out, idx, buf = [], 0, []
|
||
i = 0
|
||
while i < len(text):
|
||
if text[i] == "%" and text[i:i + 2] == "%s":
|
||
buf.append(_literal(args[idx]) if idx < len(args) else "NULL")
|
||
idx += 1
|
||
i += 2
|
||
continue
|
||
buf.append(text[i])
|
||
i += 1
|
||
out.append("".join(buf))
|
||
return "".join(out)
|
||
|
||
@staticmethod
|
||
def _match(row, where):
|
||
for key, val in (where or {}).items():
|
||
col = key.strip("`")
|
||
if isinstance(val, (list, tuple, set)):
|
||
if not any(_loose_eq(row.get(col), v) for v in val):
|
||
return False
|
||
elif not _loose_eq(row.get(col), val):
|
||
return False
|
||
return True
|
||
|
||
@staticmethod
|
||
def _table_of(text):
|
||
m = re.search(r"FROM\s+`?(\w+)`?", text, re.IGNORECASE)
|
||
return m.group(1) if m else ""
|
||
|
||
def _conds(self, text):
|
||
m = re.search(r"WHERE\s+(.*?)(?:\s+ORDER\s+BY|\s+LIMIT|\s+GROUP\s+BY|\s*$)",
|
||
text, re.IGNORECASE | re.DOTALL)
|
||
if not m:
|
||
return []
|
||
conds = []
|
||
for part in re.split(r"\s+AND\s+", m.group(1), flags=re.IGNORECASE):
|
||
part = part.strip()
|
||
if not part:
|
||
continue
|
||
in_m = re.match(r"`?(\w+)`?\s+IN\s*\((.*)\)\s*$", part, re.IGNORECASE)
|
||
if in_m:
|
||
vals = [_coerce(v) for v in self._split_top(in_m.group(2))]
|
||
conds.append((in_m.group(1), vals))
|
||
continue
|
||
eq_m = re.match(r"`?(\w+)`?\s*=\s*(.+)$", part)
|
||
if eq_m:
|
||
conds.append((eq_m.group(1), _coerce(eq_m.group(2))))
|
||
return conds
|
||
|
||
@staticmethod
|
||
def _split_top(inner):
|
||
parts, depth, cur, quote = [], 0, [], False
|
||
for ch in inner:
|
||
if ch == "'":
|
||
quote = not quote
|
||
if not quote:
|
||
if ch == "(":
|
||
depth += 1
|
||
elif ch == ")":
|
||
depth -= 1
|
||
elif ch == "," and depth == 0:
|
||
parts.append("".join(cur))
|
||
cur = []
|
||
continue
|
||
cur.append(ch)
|
||
if cur:
|
||
parts.append("".join(cur))
|
||
return [p for p in parts if p.strip() != ""]
|
||
|
||
def _rows(self, text):
|
||
tbl = self._table_of(text)
|
||
if not tbl:
|
||
return []
|
||
conds = self._conds(text)
|
||
out = []
|
||
for row in self.tables.get(tbl, []):
|
||
hit = True
|
||
for col, val in conds:
|
||
if isinstance(val, list):
|
||
if not any(_loose_eq(row.get(col), v) for v in val):
|
||
hit = False
|
||
break
|
||
elif not _loose_eq(row.get(col), val):
|
||
hit = False
|
||
break
|
||
if hit:
|
||
out.append(dict(row))
|
||
return out
|
||
|
||
def _select(self, text):
|
||
rows = self._rows(text)
|
||
limit_m = re.search(r"LIMIT\s+(\d+)", text, re.IGNORECASE)
|
||
offset_m = re.search(r"OFFSET\s+(\d+)", text, re.IGNORECASE)
|
||
if offset_m:
|
||
rows = rows[int(offset_m.group(1)):]
|
||
if limit_m:
|
||
rows = rows[:int(limit_m.group(1))]
|
||
sel_m = re.match(r"SELECT\s+(.*?)\s+FROM\s", text, re.IGNORECASE | re.DOTALL)
|
||
if sel_m and sel_m.group(1).strip() != "*":
|
||
cols = [c.strip().strip("`").split(".")[-1]
|
||
for c in self._split_top(sel_m.group(1))]
|
||
rows = [{c: r.get(c) for c in cols} for r in rows]
|
||
return rows
|
||
|
||
|
||
def make_fake_sor(with_ref_table=True, base_fixtures=None):
|
||
"""构造 FakeSor;with_ref_table=False 时模拟关联表缺失场景。"""
|
||
tables = {"pbl_domain_ref": []} if with_ref_table else {}
|
||
return FakeSor(tables=tables, base_fixtures=base_fixtures)
|