Skip to content

Commit 5aa05f5

Browse files
committed
perf(compile): index YAML entry lines once for missing-description locations
Locating each undescribed seed or source rescanned its whole YAML file with a per-name regex, so error-heavy compiles grew quadratically with entries per file. Index every entry name once per file and look names up from that index, preserving the outermost-entry and pattern semantics exactly.
1 parent acce554 commit 5aa05f5

8 files changed

Lines changed: 305 additions & 15 deletions

File tree

‎scripts/cold_compile_performance/_helpers/diagnostic_project.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,34 @@ def write_single_model_diagnostic_project(*, project_dir: Path, diagnostics: int
132132
+ ",\n".join(projections)
133133
+ '\nFROM __ref("orders") AS o\n'
134134
)
135+
136+
137+
def write_undescribed_project(*, project_dir: Path, model_count: int, width: int = 6) -> None:
138+
"""Generate models, seeds, and one large source file whose resources all lack descriptions."""
139+
project_dir.mkdir(parents=True, exist_ok=True)
140+
for folder in ("models", "sources", "seeds"):
141+
(project_dir / folder).mkdir(exist_ok=True)
142+
(project_dir / "sqlbuild_project.toml").write_text(
143+
'name = "undescribed_orders"\nadapter = "duckdb"\n[rules]\nselect = []\n'
144+
)
145+
columns: list[str] = [f"amount_{index:03}" for index in range(width)]
146+
sources: list[str] = ["sources:"]
147+
seeds: list[str] = ["seeds:"]
148+
for index in range(model_count):
149+
sources.extend(
150+
(f" - name: orders_{index:05}", " schema: main", f" table: orders_{index:05}")
151+
)
152+
sources.append(" columns:")
153+
sources.extend(f" - name: {column}\n type: INTEGER" for column in columns)
154+
(project_dir / "models" / f"order_totals_{index:05}.sql").write_text(
155+
"MODEL (materialized view);\n"
156+
f'SELECT {", ".join(columns)} FROM __source("orders_{index:05}")\n'
157+
)
158+
if index % 10 == 0:
159+
seeds.append(f" - name: products_{index:05}\n columns:")
160+
seeds.extend(f" - name: {column}\n type: INTEGER" for column in columns)
161+
(project_dir / "seeds" / f"products_{index:05}.csv").write_text(
162+
",".join(columns) + "\n" + ",".join("1" for _ in columns) + "\n"
163+
)
164+
(project_dir / "sources" / "orders.yml").write_text("\n".join(sources) + "\n")
165+
(project_dir / "seeds" / "products.yml").write_text("\n".join(seeds) + "\n")
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Index the outermost `- name:` entries of authored YAML declaration files."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from collections.abc import Iterator, Mapping
7+
from functools import lru_cache
8+
9+
_ENTRY_PREFIX: re.Pattern[str] = re.compile(r"(?P<indent>\s*)-\s*name:")
10+
_COMMENT_START: str = "#"
11+
_QUOTES: tuple[str, ...] = ("", "'", '"')
12+
13+
14+
@lru_cache(maxsize=16)
15+
def yaml_entry_lines(contents: str) -> Mapping[str, int]:
16+
"""Index every entry name once so per-resource lookups stay linear in file size."""
17+
18+
best: dict[str, tuple[int, int]] = {}
19+
index: int
20+
line: str
21+
for index, line in enumerate(contents.splitlines(), start=1):
22+
prefix: re.Match[str] | None = _ENTRY_PREFIX.match(line)
23+
if prefix is None:
24+
continue
25+
indent: int = len(prefix.group("indent"))
26+
for name in _entry_names(line[prefix.end() :]):
27+
current: tuple[int, int] | None = best.get(name)
28+
if current is None or indent < current[0]:
29+
best[name] = (indent, index)
30+
return {name: line_number for name, (_, line_number) in best.items()}
31+
32+
33+
def _entry_names(value: str) -> Iterator[str]:
34+
"""Yield every name `\\s*(['"]?)<name>\\1\\s*(#.*)?$` accepts for one entry value."""
35+
36+
closings: tuple[int, ...] = _closing_positions(value)
37+
for start in range(_leading_whitespace(text=value, start=0) + 1):
38+
for quote in _QUOTES:
39+
if not value.startswith(quote, start):
40+
continue
41+
body_start: int = start + len(quote)
42+
for closing in closings:
43+
end: int = closing - len(quote)
44+
if end >= body_start and value.startswith(quote, end):
45+
yield value[body_start:end]
46+
47+
48+
def _closing_positions(value: str) -> tuple[int, ...]:
49+
"""Return positions after which only whitespace and an optional comment remain."""
50+
51+
positions: list[int] = []
52+
tail: int = len(value)
53+
while tail >= 0:
54+
position: int = tail
55+
positions.append(position)
56+
while position > 0 and value[position - 1].isspace():
57+
position -= 1
58+
positions.append(position)
59+
tail = value.rfind(_COMMENT_START, 0, tail)
60+
return tuple(sorted(set(positions)))
61+
62+
63+
def _leading_whitespace(*, text: str, start: int) -> int:
64+
position: int = start
65+
while position < len(text) and text[position].isspace():
66+
position += 1
67+
return position

‎src/sqlbuild/compiler/discovery/main/_yaml_entry_line.py‎

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,10 @@
22

33
from __future__ import annotations
44

5-
import re
5+
from sqlbuild.compiler.discovery._helpers.yml.entry_lines import yaml_entry_lines
66

77

88
def yaml_entry_line(*, contents: str, name: str) -> int | None:
99
"""Return the 1-based line of the outermost `- name: <name>` entry, if present."""
1010

11-
pattern: re.Pattern[str] = re.compile(
12-
rf"^(?P<indent>\s*)-\s*name:\s*(?P<quote>['\"]?){re.escape(name)}(?P=quote)\s*(#.*)?$"
13-
)
14-
best: tuple[int, int] | None = None
15-
index: int
16-
line: str
17-
for index, line in enumerate(contents.splitlines(), start=1):
18-
match: re.Match[str] | None = pattern.match(line)
19-
if match is None:
20-
continue
21-
indent: int = len(match.group("indent"))
22-
if best is None or indent < best[0]:
23-
best = (indent, index)
24-
return None if best is None else best[1]
11+
return yaml_entry_lines(contents).get(name)

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/_test_types.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,14 @@ class RequiredDescriptionAggregateCase:
517517
expected_kinds: frozenset[str]
518518

519519

520+
@dataclass(frozen=True)
521+
class RequiredDescriptionExactOutputCase:
522+
description: str
523+
files: tuple[tuple[str, str], ...]
524+
expected_diagnostics: tuple[tuple[str, str, str, int, int], ...]
525+
expected_help: tuple[str, ...]
526+
527+
520528
@dataclass(frozen=True)
521529
class RequiredDescriptionPlanCase:
522530
description: str

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/test_diagnostic_compile_performance.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
write_diagnostic_project,
1919
write_function_name_project,
2020
write_single_model_diagnostic_project,
21+
write_undescribed_project,
2122
)
2223
from tests.e2e.src.sqlbuild.cli.commands.main.compile._test_types import DiagnosticPerformanceCase
2324

@@ -102,6 +103,40 @@ def test_given_one_model_with_many_diagnostics_when_compiling_then_scales_linear
102103
assert timings[1] <= timings[0] * 4.5 + 1.0
103104

104105

106+
@pytest.mark.parametrize(
107+
"test_case",
108+
[
109+
DiagnosticPerformanceCase(
110+
"many_undescribed_resources",
111+
diagnostic_count=500,
112+
expected_max_wall_seconds=15.0,
113+
)
114+
],
115+
ids=lambda case: case.description,
116+
)
117+
def test_given_many_undescribed_resources_when_compiling_then_scales_linearly(
118+
test_case: DiagnosticPerformanceCase, tmp_path: Path
119+
) -> None:
120+
timings: list[float] = []
121+
for multiplier in (1, 4):
122+
project: Path = tmp_path / f"undescribed_{multiplier}"
123+
count: int = test_case.diagnostic_count * multiplier
124+
write_undescribed_project(project_dir=project, model_count=count)
125+
elapsed, payload = measure_diagnostic_compile(
126+
project_dir=project, timeout=test_case.expected_timeout_seconds
127+
)
128+
kinds: Counter[tuple[str, str]] = Counter(
129+
(item["code"], item["resource_type"]) for item in payload["diagnostics"]
130+
)
131+
assert kinds == Counter(
132+
{("P010", "model"): count, ("P010", "source"): count, ("P010", "seed"): count // 10}
133+
)
134+
timings.append(elapsed)
135+
_LOGGER.info("undescribed resources 1x/4x wall=%s", timings)
136+
assert max(timings) < test_case.expected_max_wall_seconds
137+
assert timings[1] <= timings[0] * 5.0 + 1.0
138+
139+
105140
@pytest.mark.parametrize(
106141
"test_case",
107142
[

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/test_required_descriptions.py‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
PathDefaultDescriptionCase,
1818
RequiredDescriptionAggregateCase,
1919
RequiredDescriptionCase,
20+
RequiredDescriptionExactOutputCase,
2021
RequiredDescriptionPlanCase,
2122
)
2223
from tests.e2e.src.sqlbuild.cli.commands.main.compile.helpers import (
@@ -43,6 +44,27 @@
4344
)
4445

4546

47+
_SHADOWED_SOURCES_YAML: str = (
48+
"sources:\n"
49+
" - name: raw_orders\n"
50+
" schema: main\n"
51+
" table: orders\n"
52+
" columns:\n"
53+
" - name: raw_refunds\n"
54+
" type: INTEGER\n"
55+
' - name: "raw_refunds" # refunds feed\n'
56+
" schema: main\n"
57+
" table: refunds\n"
58+
" - name: 'raw_customers'\n"
59+
" schema: main\n"
60+
" table: customers\n"
61+
)
62+
_TWO_SEEDS_YAML: str = (
63+
"seeds:\n"
64+
" - name: product_types\n" + _SEED_COLUMNS + " - name: product_labels\n" + _SEED_COLUMNS
65+
)
66+
67+
4668
_PROVIDER_DESCRIBED: str = (
4769
"from sqlbuild.providers import Provider\n\n\n"
4870
"class OrdersApi(Provider):\n"
@@ -534,6 +556,89 @@ def test_given_every_resource_kind_undescribed_when_compiling_then_one_run_repor
534556
assert kinds == test_case.expected_kinds
535557

536558

559+
@pytest.mark.parametrize(
560+
"test_case",
561+
[
562+
RequiredDescriptionExactOutputCase(
563+
description="seed and source entries located at their outermost declarations",
564+
files=(
565+
("models/order_summary.sql", "MODEL ();\nSELECT 1 AS order_count\n"),
566+
("seeds/product_types.csv", _SEED_CSV),
567+
("seeds/product_labels.csv", _SEED_CSV),
568+
("seeds/products.yml", _TWO_SEEDS_YAML),
569+
("sources/raw.yml", _SHADOWED_SOURCES_YAML),
570+
),
571+
expected_diagnostics=(
572+
(
573+
"P010",
574+
"model 'order_summary' has no description",
575+
"models/order_summary.sql",
576+
1,
577+
1,
578+
),
579+
("P010", "seed 'product_types' has no description", "seeds/products.yml", 2, 1),
580+
("P010", "seed 'product_labels' has no description", "seeds/products.yml", 8, 1),
581+
("P010", "source 'raw_orders' has no description", "sources/raw.yml", 2, 1),
582+
("P010", "source 'raw_refunds' has no description", "sources/raw.yml", 8, 1),
583+
("P010", "source 'raw_customers' has no description", "sources/raw.yml", 11, 1),
584+
),
585+
expected_help=(
586+
"to describe model 'order_summary', add this to the MODEL header in "
587+
"models/order_summary.sql:\n"
588+
" MODEL (\n"
589+
' description "What one row of order_summary represents",\n'
590+
" ...\n"
591+
" );",
592+
(
593+
"to describe seed 'product_types', add `description` to its entry in seeds/products.yml:\n"
594+
" - name: product_types\n"
595+
" description: What one row of product_types represents"
596+
),
597+
(
598+
"to describe seed 'product_labels', add `description` to its entry in seeds/products.yml:\n"
599+
" - name: product_labels\n"
600+
" description: What one row of product_labels represents"
601+
),
602+
(
603+
"to describe source 'raw_orders', add `description` to its entry in sources/raw.yml:\n"
604+
" - name: raw_orders\n"
605+
" description: What one row of raw_orders represents"
606+
),
607+
(
608+
"to describe source 'raw_refunds', add `description` to its entry in sources/raw.yml:\n"
609+
" - name: raw_refunds\n"
610+
" description: What one row of raw_refunds represents"
611+
),
612+
(
613+
"to describe source 'raw_customers', add `description` to its entry in sources/raw.yml:\n"
614+
" - name: raw_customers\n"
615+
" description: What one row of raw_customers represents"
616+
),
617+
),
618+
),
619+
],
620+
ids=lambda case: case.description,
621+
)
622+
def test_given_undescribed_yaml_entries_when_compiling_then_reports_exact_ordered_diagnostics(
623+
test_case: RequiredDescriptionExactOutputCase, tmp_path: Path
624+
) -> None:
625+
result: subprocess.CompletedProcess[str] = compile_inline_files(
626+
tmp_path=tmp_path, files=test_case.files
627+
)
628+
629+
payload: dict[str, Any] = json.loads(result.stdout)
630+
diagnostics: list[dict[str, Any]] = payload["diagnostics"]
631+
assert result.returncode == 1, result.stdout + result.stderr
632+
assert (
633+
tuple(
634+
(item["code"], item["message"], item["path"], item["line"], item["column"])
635+
for item in diagnostics
636+
)
637+
== test_case.expected_diagnostics
638+
)
639+
assert tuple(item["help"] for item in diagnostics) == test_case.expected_help
640+
641+
537642
@pytest.mark.parametrize(
538643
"test_case",
539644
[

‎tests/unit/src/sqlbuild/compiler/discovery/main/_test_types.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,11 @@ class SerializeHookEntriesTestCase:
88
sql_fields: tuple[str, ...]
99
python_hook_fields: dict[str, dict[str, object]]
1010
expected_hooks: list[dict[str, object]]
11+
12+
13+
@dataclass(frozen=True)
14+
class YamlEntryLineTestCase:
15+
description: str
16+
contents: str
17+
name: str
18+
expected_line: int | None
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Unit tests for locating named entries in authored YAML declaration files."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from sqlbuild.compiler.discovery.main._yaml_entry_line import yaml_entry_line
8+
from tests.unit.src.sqlbuild.compiler.discovery.main._test_types import YamlEntryLineTestCase
9+
10+
_SOURCES: str = (
11+
"sources:\n"
12+
" - name: raw_orders\n"
13+
" columns:\n"
14+
" - name: raw_refunds\n"
15+
' - name: "raw_refunds" # refunds feed\n'
16+
" - name: 'raw_customers'\n"
17+
" - name:raw_products # products\n"
18+
" - name: raw_orders\n"
19+
)
20+
21+
22+
@pytest.mark.parametrize(
23+
"test_case",
24+
(
25+
YamlEntryLineTestCase("plain entry", _SOURCES, "raw_orders", 2),
26+
YamlEntryLineTestCase(
27+
"outermost wins over earlier nested column", _SOURCES, "raw_refunds", 5
28+
),
29+
YamlEntryLineTestCase("single quoted entry", _SOURCES, "raw_customers", 6),
30+
YamlEntryLineTestCase("compact entry with trailing comment", _SOURCES, "raw_products", 7),
31+
YamlEntryLineTestCase("comment text is not a name", _SOURCES, "products", None),
32+
YamlEntryLineTestCase(
33+
"unquoted line also matches its comment", _SOURCES, "raw_products # products", 7
34+
),
35+
YamlEntryLineTestCase("missing entry", _SOURCES, "raw_shipments", None),
36+
YamlEntryLineTestCase("mismatched quotes", "- name: \"raw_orders'\n", "raw_orders", None),
37+
),
38+
ids=lambda case: case.description,
39+
)
40+
def test_given_yaml_declarations_when_locating_entry_then_returns_outermost_line(
41+
test_case: YamlEntryLineTestCase,
42+
) -> None:
43+
assert yaml_entry_line(contents=test_case.contents, name=test_case.name) == (
44+
test_case.expected_line
45+
)
46+
47+
48+
if __name__ == "__main__":
49+
pytest.main([__file__, "-vv"])

0 commit comments

Comments
 (0)