mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
Three follow-ups for the docs regeneration in PR #104287: 1. `docs/en/operations/system-tables/histogram_metric_log.md` had two blank lines between the `## Columns {#columns}` header and the `<!--AUTOGENERATED_START-->` marker, which tripped the markdownlint `MD012/no-multiple-blanks` rule. Drop the extra blank line. 2. `utils/generate-system-tables-docs` produced that double blank line itself: `add_markers_to_file` reads `before = content[:after_header]` and then appends `"\n\n"`, but the header pattern's trailing `\s*$` greedy-consumes the line's terminating newline, leaving `before` with one trailing `\n`. The result was `header\n` + `\n\n` = `header\n\n\n` — two blank lines. Strip trailing newlines from `before` before reattaching the blank-line separator. 3. The autogenerator created a new minimal `delta_lake_metadata_log.md` while the existing `delta_metadata_log.md` already declared `slug: /operations/system-tables/delta_lake_metadata_log` (filename did not match the table name). Two pages with the same slug failed the docusaurus build with `Duplicate routes found!`. Move the rich hand-written content into `delta_lake_metadata_log.md` (matching the table name so future regenerations stay in place) and delete `delta_metadata_log.md`. CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=104287&sha=cf04a7c101f979c2ec406ea755eea5fddeabf2db&name_0=PR&name_1=Docs%20check Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
478 lines
16 KiB
Python
Executable File
478 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate system tables documentation from a ClickHouse binary.
|
|
|
|
Queries system.tables and system.columns via clickhouse-local to extract
|
|
table descriptions and column metadata, then generates/updates markdown
|
|
documentation files.
|
|
|
|
Usage:
|
|
python3 utils/generate-system-tables-docs --binary ./build/programs/clickhouse --docs-dir docs/en/operations/system-tables/
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import io
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
DEFAULT_CONFIG = os.path.join(SCRIPT_DIR, "generate-system-tables-docs-config.xml")
|
|
|
|
AUTOGENERATED_START = "<!--AUTOGENERATED_START-->"
|
|
AUTOGENERATED_END = "<!--AUTOGENERATED_END-->"
|
|
|
|
# Mapping from base ClickHouse type names to documentation slugs.
|
|
TYPE_LINKS = {
|
|
"String": "/sql-reference/data-types/string",
|
|
"FixedString": "/sql-reference/data-types/fixedstring",
|
|
"UInt8": "/sql-reference/data-types/int-uint",
|
|
"UInt16": "/sql-reference/data-types/int-uint",
|
|
"UInt32": "/sql-reference/data-types/int-uint",
|
|
"UInt64": "/sql-reference/data-types/int-uint",
|
|
"UInt128": "/sql-reference/data-types/int-uint",
|
|
"UInt256": "/sql-reference/data-types/int-uint",
|
|
"Int8": "/sql-reference/data-types/int-uint",
|
|
"Int16": "/sql-reference/data-types/int-uint",
|
|
"Int32": "/sql-reference/data-types/int-uint",
|
|
"Int64": "/sql-reference/data-types/int-uint",
|
|
"Int128": "/sql-reference/data-types/int-uint",
|
|
"Int256": "/sql-reference/data-types/int-uint",
|
|
"Float32": "/sql-reference/data-types/float",
|
|
"Float64": "/sql-reference/data-types/float",
|
|
"Decimal": "/sql-reference/data-types/decimal",
|
|
"Decimal32": "/sql-reference/data-types/decimal",
|
|
"Decimal64": "/sql-reference/data-types/decimal",
|
|
"Decimal128": "/sql-reference/data-types/decimal",
|
|
"Decimal256": "/sql-reference/data-types/decimal",
|
|
"Bool": "/sql-reference/data-types/boolean",
|
|
"Date": "/sql-reference/data-types/date",
|
|
"Date32": "/sql-reference/data-types/date32",
|
|
"DateTime": "/sql-reference/data-types/datetime",
|
|
"DateTime64": "/sql-reference/data-types/datetime64",
|
|
"UUID": "/sql-reference/data-types/uuid",
|
|
"Enum8": "/sql-reference/data-types/enum",
|
|
"Enum16": "/sql-reference/data-types/enum",
|
|
"Array": "/sql-reference/data-types/array",
|
|
"Map": "/sql-reference/data-types/map",
|
|
"Tuple": "/sql-reference/data-types/tuple",
|
|
"Nullable": "/sql-reference/data-types/nullable",
|
|
"LowCardinality": "/sql-reference/data-types/lowcardinality",
|
|
"IPv4": "/sql-reference/data-types/ipv4",
|
|
"IPv6": "/sql-reference/data-types/ipv6",
|
|
}
|
|
|
|
# Tables to skip in documentation generation (internal/helper tables).
|
|
SKIP_TABLES = {"transposed_metric_log"}
|
|
|
|
|
|
def unescape_tsv(value):
|
|
"""Unescape TSV escape sequences (\\n, \\t, \\', \\\\).
|
|
|
|
Order matters: backslash must be replaced last to avoid
|
|
double-unescaping (e.g., \\\\n -> \\n -> newline).
|
|
"""
|
|
result = []
|
|
i = 0
|
|
while i < len(value):
|
|
if value[i] == "\\" and i + 1 < len(value):
|
|
next_char = value[i + 1]
|
|
if next_char == "n":
|
|
result.append("\n")
|
|
i += 2
|
|
continue
|
|
elif next_char == "t":
|
|
result.append("\t")
|
|
i += 2
|
|
continue
|
|
elif next_char == "\\":
|
|
result.append("\\")
|
|
i += 2
|
|
continue
|
|
elif next_char == "'":
|
|
result.append("'")
|
|
i += 2
|
|
continue
|
|
result.append(value[i])
|
|
i += 1
|
|
return "".join(result)
|
|
|
|
|
|
def run_query(binary, config, query, use_client=False, extra_args=None):
|
|
"""Run a query via clickhouse-local (or clickhouse-client) and return TSV rows."""
|
|
if use_client:
|
|
cmd = [binary, "client", "--query", query, "--format", "TSVWithNamesAndTypes"]
|
|
else:
|
|
cmd = [binary, "local", "--query", query, "--format", "TSVWithNamesAndTypes"]
|
|
if config and not use_client:
|
|
cmd.extend(["--config-file", config])
|
|
if extra_args:
|
|
cmd.extend(extra_args)
|
|
stdout = ""
|
|
stderr = ""
|
|
returncode = 0
|
|
timed_out = False
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
stdout = result.stdout
|
|
stderr = result.stderr
|
|
returncode = result.returncode
|
|
except subprocess.TimeoutExpired as e:
|
|
def _as_text(x):
|
|
if x is None:
|
|
return ""
|
|
return x if isinstance(x, str) else x.decode("utf-8", errors="replace")
|
|
stdout = _as_text(e.stdout)
|
|
stderr = _as_text(e.stderr)
|
|
returncode = -1
|
|
timed_out = True
|
|
print(f"Error: query timed out after 300 seconds: {query}", file=sys.stderr)
|
|
reader = csv.reader(io.StringIO(stdout), delimiter="\t")
|
|
rows = list(reader)
|
|
# `clickhouse-local` can exit with a non-zero status because of races
|
|
# between system log teardown and `Poco::Application` destruction, even
|
|
# after the query has produced complete output to stdout. Tolerate that
|
|
# specific case only: the process must have exited cleanly (no timeout)
|
|
# and stdout must parse as TSVWithNamesAndTypes (header + types row).
|
|
# Timeouts always propagate, since partial output may be silently accepted
|
|
# otherwise.
|
|
if returncode != 0:
|
|
if not timed_out and len(rows) >= 2:
|
|
print(
|
|
f"Warning: query exited with code {returncode} but"
|
|
f" produced {len(rows)} rows of output, treating as success.",
|
|
file=sys.stderr,
|
|
)
|
|
if stderr:
|
|
print(f"stderr: {stderr}", file=sys.stderr)
|
|
else:
|
|
print(f"Error running query: {query}", file=sys.stderr)
|
|
print(f"stderr: {stderr}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if len(rows) < 2:
|
|
return []
|
|
headers = rows[0]
|
|
# rows[1] is the types row, skip it
|
|
return [
|
|
{k: unescape_tsv(v) for k, v in zip(headers, row)}
|
|
for row in rows[2:]
|
|
]
|
|
|
|
|
|
def format_type_link(type_str):
|
|
"""Format a ClickHouse type as a markdown link.
|
|
|
|
For simple types like 'String', returns '[String](/sql-reference/data-types/string)'.
|
|
For compound types like 'Array(String)', links the outermost type.
|
|
For unknown types, returns the type without a link.
|
|
"""
|
|
# Extract the outermost type name (before any parenthesis).
|
|
match = re.match(r"^(\w+)", type_str)
|
|
if not match:
|
|
return f"`{type_str}`"
|
|
|
|
base_type = match.group(1)
|
|
link = TYPE_LINKS.get(base_type)
|
|
if link:
|
|
return f"[{type_str}]({link})"
|
|
return f"`{type_str}`"
|
|
|
|
|
|
def indent_continuation(text, indent=" "):
|
|
"""Indent continuation lines in a multi-line string for markdown list items."""
|
|
lines = text.split("\n")
|
|
if len(lines) <= 1:
|
|
return text
|
|
# First line stays as-is, subsequent lines get indented.
|
|
result = [lines[0]]
|
|
for line in lines[1:]:
|
|
if line.strip():
|
|
result.append(indent + line)
|
|
else:
|
|
result.append("")
|
|
return "\n".join(result)
|
|
|
|
|
|
def generate_columns_markdown(columns, aliases=None):
|
|
"""Generate markdown for a list of columns, with optional aliases."""
|
|
lines = []
|
|
for col in columns:
|
|
name = col["name"]
|
|
type_str = col["type"]
|
|
comment = col.get("comment", "").strip()
|
|
|
|
type_linked = format_type_link(type_str)
|
|
|
|
if comment:
|
|
formatted_comment = indent_continuation(comment)
|
|
lines.append(f"- `{name}` ({type_linked}) — {formatted_comment}")
|
|
else:
|
|
lines.append(f"- `{name}` ({type_linked})")
|
|
|
|
if aliases:
|
|
lines.append("")
|
|
lines.append("**Aliases:**")
|
|
lines.append("")
|
|
for alias in aliases:
|
|
name = alias["name"]
|
|
target = alias.get("default_expression", "")
|
|
lines.append(f"- `{name}` — Alias for `{target}`.")
|
|
|
|
return "\n".join(lines) + "\n" if lines else ""
|
|
|
|
|
|
def update_doc_file(filepath, columns_md):
|
|
"""Update a doc file by replacing content between AUTOGENERATED markers."""
|
|
with open(filepath, "r") as f:
|
|
content = f.read()
|
|
|
|
if AUTOGENERATED_START in content and AUTOGENERATED_END in content:
|
|
# Replace content between markers.
|
|
pattern = re.compile(
|
|
re.escape(AUTOGENERATED_START) + r".*?" + re.escape(AUTOGENERATED_END),
|
|
re.DOTALL,
|
|
)
|
|
new_content = pattern.sub(
|
|
AUTOGENERATED_START + "\n" + columns_md + AUTOGENERATED_END,
|
|
content,
|
|
count=1,
|
|
)
|
|
else:
|
|
# No markers found. Look for "Columns:" header and add markers.
|
|
new_content = add_markers_to_file(content, columns_md)
|
|
|
|
if new_content != content:
|
|
with open(filepath, "w") as f:
|
|
f.write(new_content)
|
|
return True
|
|
return False
|
|
|
|
|
|
def add_markers_to_file(content, columns_md):
|
|
"""Add AUTOGENERATED markers to a file that doesn't have them.
|
|
|
|
Looks for a "Columns:" line and replaces the column list that follows it
|
|
(up to the next major section or end of file) with autogenerated markers.
|
|
"""
|
|
# Find "Columns:" or "## Columns" or "## Columns {#columns}" header.
|
|
columns_pattern = re.compile(r"^(#{0,3}\s*Columns:?(?:\s*\{#[\w-]+\})?\s*)$", re.MULTILINE)
|
|
match = columns_pattern.search(content)
|
|
|
|
if match:
|
|
after_header = match.end()
|
|
rest = content[after_header:]
|
|
|
|
# Find the next major section boundary after the column list.
|
|
# Major sections are: headings (## or **See Also**, **Example**),
|
|
# or specific patterns that indicate end of column documentation.
|
|
section_pattern = re.compile(
|
|
r"^(?:#{1,3}\s|\*\*(?:See Also|Example|See also)\*\*)",
|
|
re.MULTILINE,
|
|
)
|
|
section_match = section_pattern.search(rest)
|
|
|
|
if section_match:
|
|
# Keep content from the next section onward.
|
|
after_columns = rest[section_match.start():]
|
|
else:
|
|
after_columns = ""
|
|
|
|
# `\s*$` in the columns header pattern can consume the trailing
|
|
# newline, so `before` may or may not end with `\n`. Normalize it
|
|
# before appending the blank line that separates the header from
|
|
# the autogenerated block.
|
|
before = content[:after_header].rstrip("\n")
|
|
new_content = (
|
|
before
|
|
+ "\n\n"
|
|
+ AUTOGENERATED_START
|
|
+ "\n"
|
|
+ columns_md
|
|
+ AUTOGENERATED_END
|
|
+ "\n"
|
|
)
|
|
if after_columns:
|
|
new_content += "\n" + after_columns
|
|
else:
|
|
# No Columns header found; append it.
|
|
new_content = (
|
|
content.rstrip()
|
|
+ "\n\nColumns:\n\n"
|
|
+ AUTOGENERATED_START
|
|
+ "\n"
|
|
+ columns_md
|
|
+ AUTOGENERATED_END
|
|
+ "\n"
|
|
)
|
|
|
|
return new_content
|
|
|
|
|
|
def create_doc_file(filepath, table_name, table_comment, columns_md):
|
|
"""Create a new documentation file for a system table."""
|
|
# Escape single quotes in the description for YAML.
|
|
escaped_comment = table_comment.replace("'", "''")
|
|
|
|
content = f"""---
|
|
description: '{escaped_comment}'
|
|
keywords: ['system table', '{table_name}']
|
|
slug: /operations/system-tables/{table_name}
|
|
title: 'system.{table_name}'
|
|
doc_type: 'reference'
|
|
---
|
|
|
|
{table_comment}
|
|
|
|
Columns:
|
|
|
|
{AUTOGENERATED_START}
|
|
{columns_md}{AUTOGENERATED_END}
|
|
"""
|
|
with open(filepath, "w") as f:
|
|
f.write(content)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate system tables documentation from a ClickHouse binary."
|
|
)
|
|
parser.add_argument(
|
|
"--binary",
|
|
required=True,
|
|
help="Path to the clickhouse binary.",
|
|
)
|
|
parser.add_argument(
|
|
"--docs-dir",
|
|
required=True,
|
|
help="Path to docs/en/operations/system-tables/ directory.",
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
default=DEFAULT_CONFIG,
|
|
help="Path to the config file for clickhouse-local (optional).",
|
|
)
|
|
parser.add_argument(
|
|
"--use-client",
|
|
action="store_true",
|
|
help="Use clickhouse-client instead of clickhouse-local (requires a running server).",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
default=None,
|
|
help="Port for clickhouse-client connection (default: 9000).",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
binary = os.path.abspath(args.binary)
|
|
docs_dir = os.path.abspath(args.docs_dir)
|
|
config = os.path.abspath(args.config) if args.config else None
|
|
use_client = args.use_client
|
|
extra_args = []
|
|
if args.port:
|
|
extra_args.extend(["--port", args.port])
|
|
|
|
if not os.path.isfile(binary):
|
|
print(f"Error: binary not found: {binary}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
if not os.path.isdir(docs_dir):
|
|
print(f"Error: docs directory not found: {docs_dir}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Query table metadata.
|
|
# Filter out rotated log tables (e.g., query_log_0, trace_log_1) and
|
|
# user-created tables by requiring a non-empty comment (all real system
|
|
# tables have comments set during registration).
|
|
print("Querying system tables...")
|
|
tables = run_query(
|
|
binary,
|
|
config,
|
|
"SELECT name, comment FROM system.tables"
|
|
" WHERE database = 'system'"
|
|
" AND comment != ''"
|
|
" AND NOT match(name, '_\\\\d+$')"
|
|
" ORDER BY name",
|
|
use_client,
|
|
extra_args,
|
|
)
|
|
|
|
# Build set of known table names to filter columns.
|
|
known_tables = {t["name"] for t in tables}
|
|
|
|
# Query regular column metadata.
|
|
print("Querying system columns...")
|
|
columns = run_query(
|
|
binary,
|
|
config,
|
|
"SELECT table, name, type, comment, default_kind, default_expression"
|
|
" FROM system.columns WHERE database = 'system'"
|
|
" AND default_kind NOT IN ('ALIAS')"
|
|
" ORDER BY table, position",
|
|
use_client,
|
|
extra_args,
|
|
)
|
|
columns = [c for c in columns if c["table"] in known_tables]
|
|
|
|
# Query alias columns separately.
|
|
print("Querying alias columns...")
|
|
aliases = run_query(
|
|
binary,
|
|
config,
|
|
"SELECT table, name, type, default_expression"
|
|
" FROM system.columns WHERE database = 'system'"
|
|
" AND default_kind = 'ALIAS'"
|
|
" ORDER BY table, position",
|
|
use_client,
|
|
extra_args,
|
|
)
|
|
aliases = [a for a in aliases if a["table"] in known_tables]
|
|
|
|
# Group columns and aliases by table, preserving order.
|
|
table_columns = {}
|
|
for col in columns:
|
|
table_columns.setdefault(col["table"], []).append(col)
|
|
|
|
table_aliases = {}
|
|
for alias in aliases:
|
|
table_aliases.setdefault(alias["table"], []).append(alias)
|
|
|
|
# Build table comment map.
|
|
table_comments = {t["name"]: t["comment"] for t in tables}
|
|
|
|
updated = 0
|
|
created = 0
|
|
skipped = 0
|
|
|
|
for table_name in sorted(table_columns.keys()):
|
|
if table_name in SKIP_TABLES:
|
|
continue
|
|
|
|
cols = table_columns[table_name]
|
|
table_als = table_aliases.get(table_name, [])
|
|
columns_md = generate_columns_markdown(cols, table_als)
|
|
comment = table_comments.get(table_name, "")
|
|
|
|
doc_filename = f"{table_name}.md"
|
|
doc_filepath = os.path.join(docs_dir, doc_filename)
|
|
|
|
if os.path.isfile(doc_filepath):
|
|
if update_doc_file(doc_filepath, columns_md):
|
|
updated += 1
|
|
print(f" Updated: {doc_filename}")
|
|
else:
|
|
skipped += 1
|
|
else:
|
|
if comment:
|
|
create_doc_file(doc_filepath, table_name, comment, columns_md)
|
|
created += 1
|
|
print(f" Created: {doc_filename}")
|
|
else:
|
|
print(f" Skipped (no comment): {table_name}", file=sys.stderr)
|
|
|
|
print(f"\nDone: {updated} updated, {created} created, {skipped} unchanged.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|