Commit 7563ac51 authored by Data Governance Dev's avatar Data Governance Dev

feat(backend): 新增 rule_types / like_util / sql_builder + step9 重构支持条件树

自定义规则 v2 后端核心:把单条 LIKE 升级为递归条件树。

- web/core/rule_types.py(新)
  - classify_field(data_type) → text/numeric/date/unknown
  - COMPARATORS_BY_CLASS / DEFAULT_COMPARATOR / INPUT_HINT_BY_CLASS
  - 文本 2 算子(contains / not_contains);数值 3 算子(gt / lt / eq);日期 2 算子(date_before / date_after)

- web/core/like_util.py(新)
  - escape_like(s) + ESCAPE_CHAR = '!'(跨 DB 一致,避开达梦 [CODE:-6106])
  - 从 step9 内嵌函数上提,从此模块 import

- web/core/sql_builder.py(新)
  - build_where(node, bind, column_quoted, db_type) 递归编译 group 树
  - _placeholder(db_type) MySQL %s / 达梦+Oracle ?
  - 顶层 compile_rule(group, column_quoted, db_type) → (clause, params)

- web/core/step_impl/step9_custom_rules.py
  - 主循环重写:每条规则编译 group 树 → 跑 SQL → LIMIT 200 命中
  - _normalize_rule 自动包旧 payload {rule_type, user_input} → 新 group 树,兼容旧调用
  - 摘要字段补齐:total_rules / executed_rules / skipped_empty_keyword / skipped_no_valid_leaf / failed_rules / total_matched_rows / violations / fields_checked
parent 02a8c7ab
"""LIKE 模式转义工具(跨 DB 共享)
escape char 用 `!`(单字符),原因:
- MySQL / 达梦 / Oracle 都接受 ESCAPE '<任意单字符>'
- 不需要 SQL 反斜杠转义(避免达梦 [CODE:-6106] 无效的转义字符长度)
- 用户输入里 `!` 极少;万一有,先转义成 `!!`
供 sql_builder / step9 共享,避免再在 step9 内嵌一份。
"""
from __future__ import annotations
ESCAPE_CHAR = "!"
def escape_like(s: str) -> str:
"""对 LIKE 模式里的 ! \\ % _ 做反转义,保证用户输入的字面量不被当通配符。
转义顺序:! 必须最先,否则后面产生的 \\! 会被二次转义成 \\!!。
"""
return (
s.replace("!", "!!") # ! → !! (escape char itself, must first)
.replace("\\", "!\\") # \ → !\
.replace("%", "!%") # % → !%
.replace("_", "!_") # _ → !_
)
\ No newline at end of file
"""字段类型 → 比较符映射(自定义规则 v2)
把 DB 的 data_type 归类到三大类:
- text 字符类:varchar / char / text / clob ...
- numeric 数值类:int / decimal / number ...
- date 日期类:date / datetime / timestamp ...
每类对应一组「叶子比较符」:
text: contains / not_contains
numeric: gt / lt / eq
date: date_before / date_after
前后端共享这套语义:
- 前端 <condition-tree> 组件读 COMPARATORS_BY_CLASS 决定 el-option 列表
- 后端 sql_builder 用 COMPARATOR_TO_SQL 决定 WHERE 片段拼法
"""
from __future__ import annotations
from .db_adapter import DATE_TYPES, NUMERIC_TYPES, TEXT_TYPES
# ── 三大类别 ──────────────────────────────────────────────
TEXT = "text"
NUMERIC = "numeric"
DATE = "date"
UNKNOWN = "unknown"
def classify_field(data_type: str) -> str:
"""把后端的 data_type(varchar / int / datetime 等)归到 text / numeric / date。
优先 numeric(避免 int 同时被识别成 text —— 比如某 DB 把 int 也列在 text 里
的奇怪情况;数值和日期都优先于 text)。
"""
dt = (data_type or "").strip().lower()
if not dt:
return UNKNOWN
if dt in NUMERIC_TYPES:
return NUMERIC
if dt in DATE_TYPES:
return DATE
if dt in TEXT_TYPES:
return TEXT
return UNKNOWN
# ── 比较符列表(前端 el-option 用) ────────────────────────
COMPARATORS_BY_CLASS: dict[str, list[dict]] = {
TEXT: [
{"value": "contains", "label": "包含"},
{"value": "not_contains", "label": "不包含"},
],
NUMERIC: [
{"value": "gt", "label": ">"},
{"value": "lt", "label": "<"},
{"value": "eq", "label": "="},
],
DATE: [
{"value": "date_before", "label": "早于"},
{"value": "date_after", "label": "晚于"},
],
}
# 默认 comparator(每类第一个)
DEFAULT_COMPARATOR: dict[str, str] = {
TEXT: "contains",
NUMERIC: "gt",
DATE: "date_before",
}
# ── 比较符 → SQL 操作符(sql_builder 用) ──────────────────
# contains / not_contains 走 LIKE(特殊)
# gt / lt / eq / date_before / date_after 走标准算术
_COMPARATOR_TO_SQL_OP = {
"gt": ">",
"lt": "<",
"eq": "=",
"date_before": "<",
"date_after": ">",
}
def comparator_to_sql_op(comparator: str) -> str | None:
"""标准算术比较符 → SQL 操作符;LIKE 类(contains / not_contains)返回 None。"""
return _COMPARATOR_TO_SQL_OP.get(comparator)
def is_like_comparator(comparator: str) -> bool:
"""是否是 LIKE 系(contains / not_contains),走 ESCAPE 转义。"""
return comparator in ("contains", "not_contains")
# ── 输入提示(前端 placeholder 用) ────────────────────────
INPUT_HINT_BY_CLASS: dict[str, str] = {
TEXT: "关键字(LIKE %x%)",
NUMERIC: "数值",
DATE: "日期 YYYY-MM-DD",
}
\ No newline at end of file
"""把「条件树」编译成 SQL WHERE 子句 + 绑定参数。
条件树节点(dict)两种形态:
- group: { kind: 'group', op: 'AND' | 'OR', children: [node, ...] }
- leaf: { kind: 'leaf', comparator: 'contains' | 'not_contains' | 'gt' | 'lt'
| 'eq' | 'date_before' | 'date_after',
value: <str|number> }
返回:
build_where(node, bind, column_quoted) -> str
- 递归:group → '(c1 <op> c2 <op> c3)'
- leaf → '<quoted col> <cmp_op> <placeholder>'
bind.params 在递归过程中被 .append() 填充;调用方最后用 tuple(bind.params)
传给 db.fetchall(sql, params) 完成绑定。
跨 DB 占位符:
MySQL → %s (pymysql pyformat)
达梦 / Oracle → ? (dmPython qmark;oracledb thin 自动转 :1)
占位符在每条 leaf 里**单独**取一次(db_type 全局固定),保证整条 SQL 用同一种风格。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .like_util import ESCAPE_CHAR, escape_like
from .rule_types import comparator_to_sql_op, is_like_comparator
# ── 参数绑定容器 ─────────────────────────────────────────
@dataclass
class Bind:
"""递归过程中累积的绑定参数;调用方最后拿 tuple(bind.params) 给 DB 驱动。"""
params: list = field(default_factory=list)
def _placeholder(db_type: str) -> str:
"""跨方言占位符(每个 leaf 调一次,避免组拼出来混用)。"""
return "%s" if (db_type or "").lower() == "mysql" else "?"
# ── 主入口 ────────────────────────────────────────────────
def build_where(
node: dict | None,
bind: Bind,
column_quoted: str,
db_type: str,
) -> str:
"""递归编译条件树 → WHERE 子句片段(不含 WHERE 关键字本身)。
Args:
node: 根节点(group 或 leaf);None / 空组 → 返回 ''
bind: 共享的 Bind 容器,会被 .append() 填充参数
column_quoted: 已 quote 的列名(带反引号 / 双引号),所有 leaf 共用
db_type: 'mysql' | 'dameng' | 'oracle'(决定占位符)
Returns:
SQL WHERE 子句片段;空组返回 ''(调用方自行决定跳过 / 兜底)
"""
if not node:
return ""
kind = node.get("kind")
if kind == "group":
op = (node.get("op") or "AND").upper()
if op not in ("AND", "OR"):
op = "AND"
children = node.get("children") or []
sub_clauses = [
build_where(c, bind, column_quoted, db_type)
for c in children
]
# 过滤空叶子(value 为空 → 编译出 '')
sub_clauses = [s for s in sub_clauses if s]
if not sub_clauses:
return ""
if len(sub_clauses) == 1:
return sub_clauses[0]
return "(" + f" {op} ".join(sub_clauses) + ")"
# kind == 'leaf'
cmp = node.get("comparator")
val = node.get("value")
# 叶子 value 为空 → 返回 '',让外层 group 过滤掉(避免生成 'col LIKE %s' 但 bind 失败)
if val is None or (isinstance(val, str) and not val.strip()):
return ""
# LIKE 系
if is_like_comparator(cmp):
pattern = f"%{escape_like(str(val))}%"
bind.params.append(pattern)
sql_op = "LIKE" if cmp == "contains" else "NOT LIKE"
return f"{column_quoted} {sql_op} {_placeholder(db_type)} ESCAPE '{ESCAPE_CHAR}'"
# 算术系(> / < / = / date_before / date_after)
sql_op = comparator_to_sql_op(cmp)
if sql_op is None:
# 未知 comparator → 跳过(不报错,让上层兜底报「规则无可执行子条件」)
return ""
bind.params.append(val)
return f"{column_quoted} {sql_op} {_placeholder(db_type)}"
# ── 顶层便捷函数 ──────────────────────────────────────────
def compile_rule(
group_node: dict | None,
column_quoted: str,
db_type: str,
) -> tuple[str, list]:
"""单条规则的树 → (where_clause, params)。
Args:
group_node: 一条规则的顶层 group(可能为空)
column_quoted: quote 过的列名
db_type: 数据库方言
Returns:
(where_clause, params_list)
- where_clause 不含 'WHERE' 关键字,可直接拼到 SELECT ... FROM ... WHERE <clause>
- 若 group 为空 / 无有效叶子,返回 ('', [])
"""
bind = Bind()
clause = build_where(group_node, bind, column_quoted, db_type)
return clause, bind.params
\ No newline at end of file
"""Step 8: 自定义规则(用户在前端累积的「字段值包含关键字」类规则)
"""Step 8: 自定义规则(v2 — 字段类型 + 递归条件树)
执行链路:
前端「数据字典浏览器」右侧字段表点 + 号 → 写到自定义规则 card(用户填规则名 + 关键字)
→ 提交时随 ConnectRequest.custom_rules 提交
→ 本 step 按每条规则跑一次 SQL:
前端「数据字典浏览器」右侧字段表点 + 号 → 写到自定义规则 card
→ 提交时随 ConnectRequest.custom_rules 提交(payload = 条件树)
→ 本 step 把每条规则的「条件树」编译成 WHERE 子句 → 跑 SQL:
SELECT `<column>`
FROM `<table>`
WHERE `<column>` LIKE '%<keyword>%' ESCAPE '!'
WHERE <compiled_from_tree>
LIMIT 200
→ SQL 命中的每一行(**不做 distinct**)写入 sections['custom_value_check']
→ 注入 _protocol.tabs:让前端「分析结果」按协议渲染(不注入则该 section 不可见)
→ SQL 命中的每一行(不做 distinct)写入 sections['custom_value_check']
→ 注入 _protocol.tabs:让前端「分析结果」按协议渲染
输出格式(每行 = 一条 SQL 命中行,不去重):
条件树节点两种:
- group: { kind: 'group', op: 'AND'|'OR', children: [group|leaf, ...] }
- leaf: { kind: 'leaf', comparator, value }
{
"summary": {...},
"matches": [
{"rule_name":..., "table_name":..., "column_name":...,
"value":..., "keyword":..., "error":...},
...
],
"_protocol": {...}
}
比较符按字段 data_type 分支(前端在 UI 里按 data_type 过滤 comparator 列表):
- text → contains / not_contains (LIKE 系,ESCAPE '!')
- numeric → gt / lt / eq
- date → date_before ('<') / date_after ('>')
兼容:
- 旧 payload [{rule_type, user_input}] 在入口自动包成
{kind:'group', op:'AND', children:[{kind:'leaf', comparator:'contains', value:kw}]}
- 历史数据 / 旧 API 调用不会因此报 schema 错
注意:
- 关键字 LIKE 转义:用 `!` 作为 escape char(跨 DB 兼容)
! → !! / \\ → !\\ / % → !% / _ → !_
选 `!` 而不是 `\\` 是为了避开 达梦的 [CODE:-6106] 无效的转义字符长度 报错
- 关键字为空 → 跳过(用户没填,不报错,也不输出 match 行)
- 单条规则 SQL 失败 → 写一行 error 字段继续下一条,不中断整个 step
- LIMIT 200:每条规则 SQL 命中上限,避免内存 / UI / 报告撑爆
- **不做 distinct**:用户要求看原始命中行(即便同值多次出现也全展示)
- 规则名 rule_name 是用户在前端输入的展示名(不是规则类型):
为空时自动回落到 "<table>.<column>" 让报告 / 结果页都有可读名字
- 占位符按 db_type 切换:
mysql → %s(pymysql 的 pyformat)
dameng / oracle → ?(dmPython 的 qmark;oracledb thin 模式自动转 :1)
不切换 → dmPython 会把 %s 当字面量并把 %4211% 当作模式片段 → 语法分析出错
- 单条规则 SQL 失败 → 写一行 error 字段继续下一条,不中断整个 step
- 占位符按 db_type 切换(mysql=%s / dmPython=oracledb=?),由 sql_builder 内部处理
- 字段名走 quote_ident 防止注入(call sql_builder 时一次性 quote 整列名)
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from ..db_adapter import DBConfig, open_db, quote_ident
from ..sql_builder import compile_rule
from ..tab_protocol import (
ColumnSpec,
KpiSpec,
......@@ -61,62 +54,64 @@ logger = logging.getLogger(__name__)
_LIMIT = 200 # SQL 单条查询上限(不做 distinct,直接按行输出)
# LIKE 转义字符:选 `!` 是为了跨 DB 一致 + 避开 SQL 字符串字面量转义。
# - MySQL / 达梦 / Oracle 都接受 `ESCAPE '!'`(任意单字符)
# - `!` 不需要 SQL 反斜杠转义(避免达梦 [CODE:-6106] 无效的转义字符长度 报错)
# - 用户关键字里 `!` 极少;万一有,先转义成 `!!`
_ESCAPE_CHAR = "!"
def _escape_like(s: str) -> str:
"""对 LIKE 模式里的 ! \\ % _ 做反转义,保证用户输入的字面量不被当通配符。
# ── payload 兼容:旧 {rule_type, user_input} → 新 {group: ...} ──
def _normalize_rule(rule: dict) -> dict:
"""如果传入的是旧 payload(无 group 字段),自动包成 AND + contains 的 group。
转义顺序:! 必须最先,否则后面产生的 \\! 会被二次转义成 \\!!
保留原 rule 的其他字段(rule_name / table_name / ...)。
"""
return (
s.replace("!", "!!") # ! → !! (escape char itself, must first)
.replace("\\", "!\\") # \ → !\
.replace("%", "!%") # % → !%
.replace("_", "!_") # _ → !_
)
if "group" in rule:
return rule
kw = (rule.get("user_input") or "").strip()
if not kw:
# 没有 group + 没有 user_input:返回原 dict,交给主循环当作空规则跳过
return rule
return {
**rule,
"group": {
"kind": "group",
"op": "AND",
"children": [
{"kind": "leaf", "comparator": "contains", "value": kw},
],
},
}
# ── 主入口 ────────────────────────────────────────────────
def run_step_custom_value_check(
cfg: DBConfig,
custom_rules: list[dict],
log: Callable | None = None,
) -> dict:
"""对每条 user 规则跑一次 LIKE 检索,把每一条 SQL 命中行写入 matches[] + _protocol。
**不做 distinct**(2026-08-12 用户要求):每条 SQL 行 = 一条 entry,重复值也全展示。
"""对每条 user 规则的「条件树」编译成 WHERE → 跑 SQL → 写入 matches[] + _protocol。
Args:
cfg: 数据库连接配置
custom_rules: 前端提交的 [{table_name, column_name, rule_type, user_input,
rule_name?, ...}]
custom_rules: 前端提交的 [{rule_name, table_name, column_name, data_type,
group: {kind:'group', op, children:[...]}, ...}]
或旧 payload [{rule_type, user_input, ...}]
log: 日志回调(orchestrator 注入)
Returns:
{
"summary": {
"total_rules": int,
"executed_rules": int, # user_input 非空 + SQL 成功的条数
"skipped_empty_keyword": int,
"executed_rules": int, # 有非空叶子 + SQL 成功的条数
"skipped_empty_keyword": int, # 无 group + user_input 空(旧 payload)
"skipped_no_valid_leaf": int, # 有 group 但全叶子 value 都空(新 payload)
"failed_rules": int,
"total_matched_rows": int, # SQL 命中行数(distinct 前)= matches[] 的非 error 行数
"violations": int, # = total_matched_rows(前端 overallViolationCount)
"total_matched_rows": int, # SQL 命中行数
"violations": int, # = total_matched_rows
"fields_checked": int, # executed - failed
},
"matches": [
{
"rule_name": str, # 用户输入的展示名;空时回落到 "<table>.<column>"
"table_name", "table_comment", "column_name", "column_comment",
"rule_type", "keyword",
"value": Any, # 该 SQL 行的字段值(不做 distinct,重复值并列出现)
"error": Optional[str],
}
{rule_name, table_name, column_name, data_type, value, error, ...},
...
],
"_protocol": { # 注入协议,让前端「分析结果」tab 渲染该 section
"_protocol": {
"step_id": "custom_value_check",
"tabs": [...]
}
......@@ -124,13 +119,14 @@ def run_step_custom_value_check(
"""
matches_out: list[dict] = []
skipped_empty = 0
skipped_no_valid_leaf = 0
failed = 0
total_matched_rows = 0
if not custom_rules:
if log:
log("INFO", " · 自定义规则列表为空,跳过", step="custom_value_check")
return _build_result([], 0, 0, 0, 0)
return _build_result([], 0, 0, 0, 0, 0)
if log:
log("INFO",
......@@ -138,15 +134,16 @@ def run_step_custom_value_check(
step="custom_value_check")
with open_db(cfg) as db:
for rule in custom_rules:
for raw_rule in custom_rules:
rule = _normalize_rule(raw_rule)
table_name = rule.get("table_name") or ""
column_name = rule.get("column_name") or ""
table_comment = rule.get("table_comment") or ""
column_comment = rule.get("column_comment") or ""
rule_type = rule.get("rule_type") or "contains_keyword"
keyword = (rule.get("user_input") or "").strip()
# 用户在前端「规则名称」列输入的展示名(2026-08-12 新增)
# 为空时回落到 "<table>.<column>",确保报告 / 结果页都有可读名字
data_type = rule.get("data_type") or ""
rule_type = rule.get("rule_type") or "tree"
group_node = rule.get("group")
user_rule_name = (rule.get("rule_name") or "").strip()
display_rule_name = (
user_rule_name
......@@ -160,8 +157,8 @@ def run_step_custom_value_check(
"table_comment": table_comment,
"column_name": column_name,
"column_comment": column_comment,
"data_type": data_type,
"rule_type": rule_type,
"keyword": keyword,
}
if not table_name or not column_name:
......@@ -170,26 +167,37 @@ def run_step_custom_value_check(
failed += 1
continue
if not keyword:
# 用户没填关键字:静默跳过,不计 failed,也不输出 match 行
# 旧 payload 无 group + 无 user_input:跳过
if group_node is None:
skipped_empty += 1
continue
# 编译条件树
try:
# 占位符按 DB 方言选:
# pymysql → pyformat → %s
# dmPython → qmark → ?
# oracledb → named/numbered(这里用 ? 也能跑,thin 模式自动转 :1)
# 不分方言统一 %s 会让 dmPython 报「语法分析出错:near %」
placeholder = "%s" if (cfg.db_type or "").lower() == "mysql" else "?"
sql = (
f"SELECT {quote_ident(column_name, cfg.db_type)} "
f"FROM {quote_ident(table_name, cfg.db_type)} "
f"WHERE {quote_ident(column_name, cfg.db_type)} LIKE {placeholder} ESCAPE '{_ESCAPE_CHAR}' "
f"LIMIT {_LIMIT}"
col_quoted = quote_ident(column_name, cfg.db_type)
where_clause, params = compile_rule(
group_node, col_quoted, cfg.db_type,
)
like_pattern = f"%{_escape_like(keyword)}%"
rows = db.fetchall(sql, (like_pattern,))
except Exception as e:
matches_out.append({**common, "value": None,
"error": f"条件树编译失败: {type(e).__name__}: {e}"})
failed += 1
continue
if not where_clause.strip():
# 整棵树没编译出有效叶子(全部 value 为空)
skipped_no_valid_leaf += 1
continue
sql = (
f"SELECT {col_quoted} "
f"FROM {quote_ident(table_name, cfg.db_type)} "
f"WHERE {where_clause} "
f"LIMIT {_LIMIT}"
)
try:
rows = db.fetchall(sql, tuple(params))
except Exception as e:
err_msg = f"{type(e).__name__}: {e}"
matches_out.append({**common, "value": None,
......@@ -201,7 +209,7 @@ def run_step_custom_value_check(
step="custom_value_check")
continue
# 不做 distinct:每条 SQL 行 = 1 个 entry;NULL 值跳过(避免污染结果)
# 不做 distinct:每条 SQL 行 = 1 个 entry;NULL 值跳过
for r in rows:
v = r.get(column_name.lower())
if v is None:
......@@ -210,29 +218,36 @@ def run_step_custom_value_check(
total_matched_rows += len(rows)
executed = len(custom_rules) - skipped_empty - skipped_no_valid_leaf - failed
if log:
log("INFO",
f" · 完成: 有效 {len(custom_rules) - skipped_empty - failed} 条,"
f"空关键字 {skipped_empty} 条,失败 {failed} 条,"
f" · 完成: 有效 {executed} 条,"
f"空关键字 {skipped_empty} 条,"
f"无可执行子条件 {skipped_no_valid_leaf} 条,"
f"失败 {failed} 条,"
f"命中行 {total_matched_rows}",
step="custom_value_check")
return _build_result(
matches_out,
total_rules=len(custom_rules),
executed=len(custom_rules) - skipped_empty - failed,
executed=executed,
skipped_empty=skipped_empty,
skipped_no_valid_leaf=skipped_no_valid_leaf,
failed=failed,
total_matched_rows=total_matched_rows,
)
# ── 输出组装 ──────────────────────────────────────────────
def _build_result(
matches: list[dict],
*,
total_rules: int,
executed: int,
skipped_empty: int,
skipped_no_valid_leaf: int,
failed: int,
total_matched_rows: int,
) -> dict:
......@@ -246,18 +261,15 @@ def _build_result(
"total_rules": total_rules,
"executed_rules": executed,
"skipped_empty_keyword": skipped_empty,
"skipped_no_valid_leaf": skipped_no_valid_leaf,
"failed_rules": failed,
"total_matched_rows": total_matched_rows,
# violations = 命中行数(不做 distinct,每行 = 一条 entry)
"violations": total_matched_rows,
# fields_checked = 实际跑过的 SQL 条数(executed - failed)
"fields_checked": max(executed - failed, 0),
},
"matches": matches,
}
# ── 注入 _protocol.tabs ─────────────────────────────────────────
# 让前端 displayedTabs 能渲染该 section;不注入 → 整张 tab 不可见
tab = TabProtocol(
key="custom_value_check",
title="自定义规则命中明细",
......@@ -293,18 +305,18 @@ def _build_result(
render=RenderSpec(kind="text")),
ColumnSpec(prop="column_name", label="字段名", min_width=140,
render=RenderSpec(kind="text")),
# 匹配值:每条 SQL 命中行 = 一行(不做 distinct,重复值并列出现)
ColumnSpec(prop="data_type", label="类型", min_width=100,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="value", label="匹配值", min_width=240,
render=RenderSpec(kind="code")),
ColumnSpec(prop="rule_type", label="规则", min_width=120,
align="center",
render=RenderSpec(kind="tag")),
ColumnSpec(prop="keyword", label="关键字", min_width=140,
render=RenderSpec(kind="text")),
ColumnSpec(prop="error", label="错误", min_width=200,
render=RenderSpec(kind="text")),
),
),
),
)
return inject_protocol(data, step_id="custom_value_check", tabs=[tab])
return inject_protocol(data, step_id="custom_value_check", tabs=[tab])
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment