mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
539 lines
25 KiB
Python
539 lines
25 KiB
Python
"""记录表的文件层(docs/design/normal-user-apps.md N8、§3.7):一个表版本一个 SQLite 文件,只许追加。
|
||
|
||
**文件。** data/records/<table_key>.v<version>.db,里面只有一张业务表 records:系统列(records.SYSTEM_COLUMNS)、
|
||
业务列(「填什么」卡片生成),外加一列不对外的 _action_key(幂等键,唯一)。查询走连接上的同名临时视图
|
||
(view_select),视图不露 _action_key。
|
||
|
||
**写。** 只有 append 一条路:写锁里开一个短事务(进程里的 _WRITE_LOCK;多进程时调用方另持库里的锁 write_lock(),几个进程
|
||
往同一个文件里写一个接一个——SQLite 文件自己的文件锁在网络文件系统上靠不住),先按 action_key 查——同一个动作第二次来交回
|
||
第一次写下的那一行,不再写;更正是一次新的追加,带 _supersedes 指向被更正的行,原行不改。_seq 是 SQLite 的 rowid
|
||
(INTEGER PRIMARY KEY),只增不减:从不删行,也就不会复用。
|
||
|
||
**读。** 高水位(high_water)是现在最大的 _seq。一次运行在开始时记下它(engine/runner._data_versions),之后这次
|
||
运行查到的都是 _seq ≤ 高水位、在那一刻没被更正过的行:行只追加、不改,所以同一次运行查几遍都是同一份。表单不兼容地改过
|
||
之后,临时视图把比这一版老的各版本(只读挂在连接上)按 UNION ALL 接上,各按自己的高水位(view_prelude 的 older)。
|
||
|
||
这里的函数都是同步的(sqlite3 直连、关 DQS),调用方放进线程。表结构的登记、数据源在 records/tables.py。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from app.core.config import settings
|
||
from app.data.engine import open_checked_sqlite
|
||
from app.db.base import new_id
|
||
from app.records import SYSTEM_COLUMNS
|
||
|
||
#: 文件里那张业务表的名字。看板的 SQL 写 FROM records
|
||
TABLE = "records"
|
||
#: 不对外的幂等键列:视图不露它,写 main.records 的 SQL 被只读守卫拒掉
|
||
ACTION_KEY_COLUMN = "_action_key"
|
||
#: 表单字段类型 → 列类型。多选、文件存 JSON 文字
|
||
COLUMN_TYPES = {"string": "TEXT", "number": "REAL", "integer": "INTEGER", "boolean": "INTEGER", "array": "TEXT",
|
||
"file": "TEXT"}
|
||
#: 列名最长几个字
|
||
NAME_MAX = 64
|
||
|
||
_KEY = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||
#: 列名里不许出现的字符:引号(拼进 SQL 的标识符)、控制字符
|
||
_BAD_NAME = re.compile(r"[\"`\[\]\x00-\x1f\x7f]")
|
||
#: 所有写入(建表、加列、追加)共用的锁。写都是几毫秒的短事务,不按表分锁也不会互相拖住
|
||
_WRITE_LOCK = threading.Lock()
|
||
|
||
|
||
def write_lock() -> Any:
|
||
"""跨进程的写锁(app/cluster/mutex:PostgreSQL 时是咨询锁,SQLite 只有一个进程、就是本进程的锁)。异步的调用方在把写入
|
||
放进线程之前持着它(async with)。"""
|
||
from app.cluster import mutex
|
||
|
||
return mutex.lock("records-store")
|
||
|
||
|
||
class RecordsRefused(ValueError):
|
||
"""这一次写入不收:code 是问题编号(records.*,白话在 app/plain.py),message 直接给人看。"""
|
||
|
||
def __init__(self, code: str, message: str) -> None:
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.message = message
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Column:
|
||
"""一个业务列:名字(就是表单字段名)、中文名、类型(COLUMN_TYPES 的键)、必填、敏感(进数据源的遮罩列)。"""
|
||
|
||
name: str
|
||
label: str = ""
|
||
type: str = "string"
|
||
required: bool = False
|
||
sensitive: bool = False
|
||
|
||
def out(self) -> dict[str, Any]:
|
||
return {"name": self.name, "label": self.label or self.name, "type": self.type, "required": self.required,
|
||
"sensitive": self.sensitive}
|
||
|
||
|
||
def column_problem(name: Any) -> str | None:
|
||
"""列名合不合规:返回原因,合规返回 None。下划线开头的留给系统列。"""
|
||
if not isinstance(name, str) or not name.strip():
|
||
return "字段名不能为空"
|
||
if name != name.strip():
|
||
return f"字段名「{name}」前后不能有空格"
|
||
if len(name) > NAME_MAX:
|
||
return f"字段名「{name[:20]}…」超过 {NAME_MAX} 个字"
|
||
if name.startswith("_"):
|
||
return f"字段名「{name}」不能以下划线开头:下划线开头的留给系统列"
|
||
if _BAD_NAME.search(name):
|
||
return f"字段名「{name}」里不能有引号、方括号或控制字符"
|
||
return None
|
||
|
||
|
||
def parse_columns(raw: Any) -> list[Column]:
|
||
"""records_tables.columns(或调用方交来的列定义)→ Column。类型不认识的、名字不合规的抛 RecordsRefused。"""
|
||
out: list[Column] = []
|
||
seen: set[str] = set()
|
||
for item in raw or []:
|
||
if not isinstance(item, dict):
|
||
raise RecordsRefused("records.column_invalid", "列定义需要是对象")
|
||
name = item.get("name")
|
||
if problem := column_problem(name):
|
||
raise RecordsRefused("records.column_invalid", problem)
|
||
if name.lower() in seen:
|
||
raise RecordsRefused("records.column_invalid", f"字段名「{name}」重复了")
|
||
seen.add(name.lower())
|
||
kind = str(item.get("type") or "string")
|
||
if kind not in COLUMN_TYPES:
|
||
raise RecordsRefused("records.column_invalid", f"字段「{name}」的类型「{kind}」不能存进记录表")
|
||
out.append(Column(name=name, label=str(item.get("label") or name), type=kind,
|
||
required=bool(item.get("required")), sensitive=bool(item.get("sensitive"))))
|
||
return out
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 路径
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def records_root() -> Path:
|
||
"""记录表文件的根目录。按调用时的 settings 现算:测试会换数据目录。"""
|
||
return settings.data_dir / "records"
|
||
|
||
|
||
def table_path(table_key: str, version: int) -> Path:
|
||
"""data/records/<table_key>.v<version>.db。键只认字母、数字、下划线、连字符,挡住路径穿越。"""
|
||
if not _KEY.match(table_key or ""):
|
||
raise ValueError("记录表的键格式不对")
|
||
if not isinstance(version, int) or version < 1:
|
||
raise ValueError("记录表的版本号需要是正整数")
|
||
return records_root() / f"{table_key}.v{version}.db"
|
||
|
||
|
||
def _q(name: str) -> str:
|
||
return '"' + name.replace('"', '""') + '"'
|
||
|
||
|
||
def _lit(value: str) -> str:
|
||
"""SQL 文字字面量。只用于临时视图里拼看的人的名字:视图定义不能带参数。"""
|
||
return "'" + str(value).replace("'", "''") + "'"
|
||
|
||
|
||
def _rw(path: Path) -> sqlite3.Connection:
|
||
if not path.is_file():
|
||
raise RecordsRefused("records.table_missing", "记录表的数据文件不见了,请联系管理员")
|
||
return open_checked_sqlite(str(path), readonly=False)
|
||
|
||
|
||
def _ro(path: Path) -> sqlite3.Connection:
|
||
if not path.is_file():
|
||
raise RecordsRefused("records.table_missing", "记录表的数据文件不见了,请联系管理员")
|
||
return open_checked_sqlite(str(path), readonly=True)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 建表、加列
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def create_file(path: Path, columns: list[Column]) -> None:
|
||
"""新建一个表版本的文件。路径上已有文件时:没有任何行(上次建了一半、登记没提交)就重建,有行就拒绝——
|
||
有数据的文件绝不覆盖。"""
|
||
with _WRITE_LOCK:
|
||
if path.exists():
|
||
if _row_count(path) > 0:
|
||
raise RecordsRefused("records.file_exists", f"记录表文件 {path.name} 已经有数据,不能重建")
|
||
path.unlink()
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = path.with_name(f".{path.name}.tmp-{new_id()[:12]}")
|
||
conn = open_checked_sqlite(str(tmp), readonly=False)
|
||
try:
|
||
business = "".join(f", {_q(c.name)} {COLUMN_TYPES[c.type]}" for c in columns)
|
||
conn.execute(
|
||
f"CREATE TABLE {_q(TABLE)} ("
|
||
"_seq INTEGER PRIMARY KEY, _id TEXT NOT NULL UNIQUE, _submitted_by TEXT, _submitted_at TEXT NOT NULL, "
|
||
"_run_id TEXT, _supersedes TEXT, _schema_version INTEGER NOT NULL, "
|
||
f"{ACTION_KEY_COLUMN} TEXT UNIQUE{business})")
|
||
conn.execute(f"CREATE INDEX ix_records_supersedes ON {_q(TABLE)} (_supersedes)")
|
||
conn.execute(f"CREATE INDEX ix_records_submitted_by ON {_q(TABLE)} (_submitted_by)")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
os.replace(tmp, path)
|
||
|
||
|
||
def add_columns(path: Path, columns: list[Column]) -> None:
|
||
"""兼容的改动(只加可选字段):在同一个版本上 ALTER TABLE ADD COLUMN。已有的列跳过(重做一次也没事)。"""
|
||
if not columns:
|
||
return
|
||
with _WRITE_LOCK:
|
||
conn = _rw(path)
|
||
try:
|
||
have = {str(r[1]).lower() for r in conn.execute(f"PRAGMA table_info({_q(TABLE)})")}
|
||
for c in columns:
|
||
if c.name.lower() not in have:
|
||
conn.execute(f"ALTER TABLE {_q(TABLE)} ADD COLUMN {_q(c.name)} {COLUMN_TYPES[c.type]}")
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _row_count(path: Path) -> int:
|
||
try:
|
||
conn = open_checked_sqlite(str(path), readonly=True)
|
||
except sqlite3.Error:
|
||
return 0
|
||
try:
|
||
return int(conn.execute(f"SELECT count(*) FROM {_q(TABLE)}").fetchone()[0])
|
||
except sqlite3.Error:
|
||
return 0
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 追加
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
_TYPE_TEXT = {"number": "数", "integer": "整数", "boolean": "是或否", "array": "多选(列表)", "file": "文件"}
|
||
_INTEGER = re.compile(r"[+-]?\d+")
|
||
_NUMBER = re.compile(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
||
|
||
|
||
def coerce(column: Column, value: Any) -> Any:
|
||
"""一个值按列类型收好,能存进 SQLite 的样子。模板渲染出来的一律是文字("12.5"、"true"、JSON),在这里收;
|
||
收不成抛 RecordsRefused(records.value_invalid),说清是哪个字段。空值(None、空串)交回 None。"""
|
||
if value is None or (isinstance(value, str) and not value.strip() and column.type != "string"):
|
||
return None
|
||
if isinstance(value, str) and column.type == "string":
|
||
return value
|
||
bad = RecordsRefused("records.value_invalid",
|
||
f"字段「{column.label or column.name}」应为{_TYPE_TEXT.get(column.type, '文字')},"
|
||
f"实际是「{str(value)[:80]}」")
|
||
if column.type == "string":
|
||
if isinstance(value, (dict, list)):
|
||
return json.dumps(value, ensure_ascii=False)
|
||
return str(value)
|
||
if column.type in ("number", "integer"):
|
||
number: Any = None
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
number = value
|
||
elif isinstance(value, str):
|
||
text = value.strip()
|
||
number = int(text) if _INTEGER.fullmatch(text) else float(text) if _NUMBER.fullmatch(text) else None
|
||
if number is None or (isinstance(number, float) and not math.isfinite(number)):
|
||
raise bad
|
||
if column.type == "integer":
|
||
if isinstance(number, float) and not number.is_integer():
|
||
raise bad
|
||
return int(number)
|
||
return number
|
||
if column.type == "boolean":
|
||
if isinstance(value, bool):
|
||
return int(value)
|
||
if isinstance(value, str) and value.strip().lower() in ("true", "false"):
|
||
return int(value.strip().lower() == "true")
|
||
raise bad
|
||
# 多选、文件:存 JSON 文字
|
||
parsed = value
|
||
if isinstance(value, str):
|
||
try:
|
||
parsed = json.loads(value)
|
||
except ValueError:
|
||
raise bad from None
|
||
if column.type == "array" and not isinstance(parsed, list):
|
||
raise bad
|
||
if column.type == "file":
|
||
from app.apps.spec import FILE_REF_KEYS
|
||
|
||
if not (isinstance(parsed, dict) and isinstance(parsed.get("file_id"), str)
|
||
and set(parsed) <= set(FILE_REF_KEYS)):
|
||
raise bad
|
||
return json.dumps(parsed, ensure_ascii=False, sort_keys=True)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Appended:
|
||
seq: int
|
||
row_id: str
|
||
submitted_at: str
|
||
#: 同一个动作键已经写过:交回的是第一次写下的那一行,这次没有再写
|
||
duplicate: bool
|
||
values: dict[str, Any]
|
||
#: 这一行记的提交人(更正行沿用原行的,见 append)
|
||
submitted_by: str | None = None
|
||
#: 更正行:动手更正的人(不记进行里,记进回执、动作记录和审计);不是更正为 None
|
||
corrected_by: str | None = None
|
||
|
||
def receipt(self) -> dict[str, Any]:
|
||
return {"_seq": self.seq, "_id": self.row_id, "_submitted_at": self.submitted_at,
|
||
"_submitted_by": self.submitted_by, "duplicate": self.duplicate,
|
||
**({"corrected_by": self.corrected_by} if self.corrected_by is not None else {})}
|
||
|
||
|
||
def append(path: Path, columns: list[Column], values: Any, *, version: int, run_id: str | None, actor: str | None,
|
||
action_key: str, supersedes: str | None = None) -> Appended:
|
||
"""写一行。同一个 action_key 第二次来交回第一次的那一行(duplicate=True),不再写。
|
||
|
||
values 只认表里的业务列:多出来的字段、必填没填、类型收不成都拒(RecordsRefused),一行都不写。
|
||
supersedes 是被更正的那一行的 _id:它得在这张表里、而且还没被更正过(更正要改最新的那一行)。
|
||
|
||
提交人(_submitted_by):新提交记 actor;更正行沿用原行的提交人——这一条还是他交的那一条,按「只看自己的」看的人
|
||
照样看得见改过的那一行,别人代为更正也不会让它从原提交人眼前消失。动手更正的是 actor,交回在 corrected_by 里,
|
||
由调用方记进动作记录和审计(records/actions.py)。"""
|
||
if not action_key:
|
||
raise RecordsRefused("records.action_key_missing", "写记录表需要动作键,只能经调用工具节点写入")
|
||
if not isinstance(values, dict):
|
||
raise RecordsRefused("records.value_invalid", "要写入的内容需要是一组字段和值")
|
||
by_name = {c.name: c for c in columns}
|
||
unknown = [k for k in values if k not in by_name]
|
||
if unknown:
|
||
raise RecordsRefused("records.column_unknown",
|
||
f"记录表里没有这些字段:{'、'.join(map(str, unknown[:5]))}。表单改过的话,请重新发布应用")
|
||
row = {c.name: coerce(c, values.get(c.name)) for c in columns}
|
||
missing = [c.label or c.name for c in columns if c.required and row[c.name] in (None, "")]
|
||
if missing:
|
||
raise RecordsRefused("records.required_missing", f"必填的字段没有值:{'、'.join(missing)}")
|
||
with _WRITE_LOCK:
|
||
conn = _rw(path)
|
||
try:
|
||
conn.execute("BEGIN IMMEDIATE")
|
||
done = conn.execute(f"SELECT _seq, _id, _submitted_at, _submitted_by, _supersedes FROM {_q(TABLE)} "
|
||
f"WHERE {ACTION_KEY_COLUMN} = ?", (action_key,)).fetchone()
|
||
if done is not None:
|
||
conn.rollback()
|
||
return Appended(seq=int(done[0]), row_id=str(done[1]), submitted_at=str(done[2]), duplicate=True,
|
||
values=_row_values(path, columns, int(done[0])), submitted_by=done[3],
|
||
corrected_by=actor if done[4] else None)
|
||
submitted_by = actor
|
||
if supersedes:
|
||
target = conn.execute(f"SELECT _seq, _submitted_by FROM {_q(TABLE)} WHERE _id = ?",
|
||
(supersedes,)).fetchone()
|
||
if target is None:
|
||
raise RecordsRefused("records.supersede_missing", "要更正的那一行不在这张记录表里")
|
||
later = conn.execute(f"SELECT 1 FROM {_q(TABLE)} WHERE _supersedes = ? LIMIT 1",
|
||
(supersedes,)).fetchone()
|
||
if later is not None:
|
||
raise RecordsRefused("records.supersede_twice", "这一行已经被更正过了,请在最新的那一行上更正")
|
||
submitted_by = target[1]
|
||
row_id, at = new_id(), _now()
|
||
names = ["_id", "_submitted_by", "_submitted_at", "_run_id", "_supersedes", "_schema_version",
|
||
ACTION_KEY_COLUMN, *row]
|
||
params = [row_id, submitted_by, at, run_id, supersedes or None, version, action_key, *row.values()]
|
||
cur = conn.execute(f"INSERT INTO {_q(TABLE)} ({', '.join(_q(n) for n in names)}) "
|
||
f"VALUES ({', '.join('?' for _ in names)})", params)
|
||
seq = int(cur.lastrowid or 0)
|
||
conn.commit()
|
||
except BaseException:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
return Appended(seq=seq, row_id=row_id, submitted_at=at, duplicate=False, values=row, submitted_by=submitted_by,
|
||
corrected_by=actor if supersedes else None)
|
||
|
||
|
||
def _row_values(path: Path, columns: list[Column], seq: int) -> dict[str, Any]:
|
||
conn = _ro(path)
|
||
try:
|
||
names = [c.name for c in columns]
|
||
got = conn.execute(f"SELECT {', '.join(_q(n) for n in names) or '1'} FROM {_q(TABLE)} WHERE _seq = ?",
|
||
(seq,)).fetchone()
|
||
return dict(zip(names, got)) if got is not None and names else {}
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 读
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def high_water(path: Path) -> int:
|
||
"""现在最大的 _seq;空表为 0。"""
|
||
conn = _ro(path)
|
||
try:
|
||
got = conn.execute(f"SELECT max(_seq) FROM {_q(TABLE)}").fetchone()
|
||
return int(got[0] or 0)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def lookup(path: Path, columns: list[Column], action_key: str) -> dict[str, Any] | None:
|
||
"""按动作键找写下的那一行(查询能力 records_lookup 用):没有为 None。"""
|
||
conn = _ro(path)
|
||
try:
|
||
got = conn.execute(f"SELECT _seq, _id, _submitted_at FROM {_q(TABLE)} WHERE {ACTION_KEY_COLUMN} = ?",
|
||
(action_key,)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
if got is None:
|
||
return None
|
||
return {"_seq": int(got[0]), "_id": str(got[1]), "_submitted_at": str(got[2]), "duplicate": True}
|
||
|
||
|
||
def union_compatible(new: Column, old: Column) -> bool:
|
||
"""跨版本汇总时,旧版本的这一列能不能并进新版本的同名列:类型一样,或者整数和数之间(都是数)。别的类型改动(数改成
|
||
文字、文字改成是或否……)意思变了,旧版本那一列补空,不把两种东西混在一列里算。"""
|
||
return new.type == old.type or {new.type, old.type} <= {"number", "integer"}
|
||
|
||
|
||
def view_select(columns: list[Column], *, high_water: int | None, scope: str, viewer: str | None,
|
||
schema: str = "main", present: list[Column] | None = None) -> str:
|
||
"""临时视图(和看板、记录列表)看到的那一份:
|
||
|
||
- _seq ≤ 高水位(None 为不限,现在有的都算);
|
||
- 没被更正过:在高水位之内没有一行指向它(_supersedes)。按高水位判,不靠可改的标记——原行永远不改;
|
||
- 行级过滤:scope=all 全部,mine 只看 _submitted_by = viewer 的,none 一行都不给。
|
||
|
||
不露 _action_key。列名照 SYSTEM_COLUMNS + 业务列的顺序。present 是 schema 那个文件里实际有的业务列(跨版本汇总时
|
||
旧版本的列):columns 里它没有的、类型对不上的(union_compatible)补 NULL;None 为 columns 都在。"""
|
||
have = None if present is None else {c.name.lower(): c for c in present}
|
||
|
||
def pick(c: Column) -> str:
|
||
if have is None:
|
||
return f"r.{_q(c.name)}"
|
||
old = have.get(c.name.lower())
|
||
return f"r.{_q(old.name)} AS {_q(c.name)}" if old is not None and union_compatible(c, old) \
|
||
else f"NULL AS {_q(c.name)}"
|
||
|
||
cols = ", ".join([*(f"r.{_q(n)}" for n in SYSTEM_COLUMNS), *(pick(c) for c in columns)])
|
||
src = f"{_q(schema)}.{_q(TABLE)}"
|
||
where = []
|
||
if high_water is not None:
|
||
seq = int(high_water)
|
||
where.append(f"r._seq <= {seq}")
|
||
newer = f" AND s._seq <= {seq}"
|
||
else:
|
||
newer = ""
|
||
where.append(f"NOT EXISTS (SELECT 1 FROM {src} AS s WHERE s._supersedes = r._id{newer})")
|
||
if scope == "mine":
|
||
where.append(f"r._submitted_by = {_lit(viewer)}" if viewer else "0")
|
||
elif scope != "all":
|
||
where.append("0")
|
||
return f"SELECT {cols} FROM {src} AS r WHERE {' AND '.join(where)}"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OlderVersion:
|
||
"""跨版本汇总时并进来的一个旧表版本:挂在查询连接上的库名(ATTACH … AS schema)、那一版的业务列、这次查询的高水位
|
||
(None 为不限)。"""
|
||
|
||
schema: str
|
||
columns: list[Column]
|
||
high_water: int | None
|
||
|
||
|
||
def view_prelude(columns: list[Column], *, high_water: int | None, scope: str, viewer: str | None,
|
||
older: tuple[OlderVersion, ...] | list[OlderVersion] = ()) -> tuple[str, ...]:
|
||
"""查询连接上建同名临时视图的两条语句。SQLite 解析不带库名的表名时先找 temp 库,于是 FROM records 查到的就是
|
||
这份视图;直接写 main.records 的 SQL 由只读守卫拒掉(data/engine.run_query 的 forbid_schemas)。
|
||
|
||
older 是这张表比这一版老的各版本(只读挂在连接上,records/views.py):视图按 UNION ALL 把它们接在这一版后面,
|
||
列照这一版的来,旧版本没有的列补 NULL;每一版各按自己的高水位、自己文件里的更正判,行级过滤同一个。"""
|
||
parts = [view_select(columns, high_water=high_water, scope=scope, viewer=viewer)]
|
||
parts += [view_select(columns, high_water=o.high_water, scope=scope, viewer=viewer, schema=o.schema,
|
||
present=o.columns) for o in older]
|
||
return (f"DROP VIEW IF EXISTS temp.{_q(TABLE)}",
|
||
f"CREATE TEMP VIEW {_q(TABLE)} AS " + " UNION ALL ".join(parts))
|
||
|
||
|
||
def rows(path: Path, columns: list[Column], *, scope: str, viewer: str | None, history: bool = False,
|
||
before: int | None = None, limit: int = 50) -> tuple[list[dict[str, Any]], bool]:
|
||
"""记录列表(records_list、GET /api/apps/{id}/records):现在的数据,按看的人过滤,_seq 倒序。
|
||
|
||
history=False 只给当前有效的行;True 连被更正过的也给,标 superseded。before 是游标(只要 _seq 更小的)。
|
||
交回 (行, 后面还有没有)。"""
|
||
limit = max(1, min(int(limit), 200))
|
||
names = [c.name for c in columns]
|
||
src = f"main.{_q(TABLE)}"
|
||
where = ["1"]
|
||
params: list[Any] = []
|
||
if scope == "mine":
|
||
where.append("r._submitted_by = ?")
|
||
params.append(viewer)
|
||
elif scope != "all":
|
||
return [], False
|
||
if not history:
|
||
where.append(f"NOT EXISTS (SELECT 1 FROM {src} AS s WHERE s._supersedes = r._id)")
|
||
if before is not None:
|
||
where.append("r._seq < ?")
|
||
params.append(int(before))
|
||
cols = ", ".join(f"r.{_q(n)}" for n in (*SYSTEM_COLUMNS, *names))
|
||
sql = (f"SELECT {cols}, EXISTS (SELECT 1 FROM {src} AS s WHERE s._supersedes = r._id) "
|
||
f"FROM {src} AS r WHERE {' AND '.join(where)} ORDER BY r._seq DESC LIMIT ?")
|
||
conn = _ro(path)
|
||
try:
|
||
got = conn.execute(sql, [*params, limit + 1]).fetchall()
|
||
finally:
|
||
conn.close()
|
||
out: list[dict[str, Any]] = []
|
||
width = len(SYSTEM_COLUMNS)
|
||
for row in got[:limit]:
|
||
system = dict(zip(SYSTEM_COLUMNS, row[:width]))
|
||
values = {n: _shown(c, v) for n, c, v in zip(names, columns, row[width:width + len(names)])}
|
||
out.append({**system, "superseded": bool(row[-1]), "values": values})
|
||
return out, len(got) > limit
|
||
|
||
|
||
def row_by_id(path: Path, columns: list[Column], row_id: str) -> dict[str, Any] | None:
|
||
"""按 _id 取一行(下载附件时核对行级可见用)。"""
|
||
names = [c.name for c in columns]
|
||
cols = ", ".join(_q(n) for n in (*SYSTEM_COLUMNS, *names))
|
||
conn = _ro(path)
|
||
try:
|
||
got = conn.execute(f"SELECT {cols} FROM {_q(TABLE)} WHERE _id = ?", (row_id,)).fetchone()
|
||
finally:
|
||
conn.close()
|
||
if got is None:
|
||
return None
|
||
width = len(SYSTEM_COLUMNS)
|
||
return {**dict(zip(SYSTEM_COLUMNS, got[:width])),
|
||
"values": {n: _shown(c, v) for n, c, v in zip(names, columns, got[width:])}}
|
||
|
||
|
||
def _shown(column: Column, value: Any) -> Any:
|
||
"""存的样子 → 给界面的样子:是或否交回布尔,多选、文件解开 JSON。"""
|
||
if value is None:
|
||
return None
|
||
if column.type == "boolean":
|
||
return bool(value)
|
||
if column.type in ("array", "file") and isinstance(value, str):
|
||
try:
|
||
return json.loads(value)
|
||
except ValueError:
|
||
return value
|
||
return value
|