dataface 0.1.6.dev345__py3-none-any.whl → 0.1.6.dev360__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,7 @@ AuthoredFace (dataface) definition from YAML.
31
31
  | `height` | str \| int | ✓ | Height when nested (e.g., '300px' or an integer in pixels). |
32
32
  | `visible` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Controls whether this layout item is rendered. Accepts a bool, variable name, Jinja expression, or {query, column} probe. |
33
33
  | `theme` | str | ✓ | Theme name (e.g., 'editorial', 'cream', 'stark'). |
34
+ | `auto_link` | bool | ✓ | When True, table charts with no explicit link: automatically link each row to its canonical /data/<source>/<schema>/<table>/detail/ page. Default off. Explicit link: always wins; set link: ~ to suppress per chart. |
34
35
 
35
36
  <a id="sourcessection"></a>
36
37
  ## SourcesSection
@@ -4,11 +4,11 @@ Before writing SQL, write a short query plan:
4
4
 
5
5
  - **OUTPUT**: the columns to return, with clear aliases that describe the value
6
6
  - **FROM/JOIN**: the tables and the join keys between them
7
- - **GRAIN**: what one row represents after the joins; guard against fan-out / double-counting on the non-unique side of a join — if a join multiplies rows, aggregate before joining or add a GROUP BY
7
+ - **GRAIN**: what one row represents after the joins; guard against fan-out / double-counting on the non-unique side of a join — if a join multiplies rows, aggregate before joining or add a GROUP BY. If the schema context already states a join's multiplicity (e.g. one-to-many) or a column's uniqueness, use that to decide grain — don't spend a query probing for what the schema already tells you
8
8
  - **FILTERS**: each WHERE condition; verify the exact stored value before filtering (stored values may differ from display labels — e.g. `'LA'` not `'Los Angeles'`)
9
9
  - **SORT/LIMIT**: include if the question asks to rank, sort, or return a top-N
10
10
 
11
- Then translate the plan faithfully into SQL. Verify by executing the query and checking that shape and values match the plan.
11
+ Then translate the plan faithfully into SQL. Verify by executing the query and checking that shape and values match the plan. When you run a query, heed any diagnostics it returns — a `fanout_risk`, `missing_join_predicate`, or `reaggregation` warning means the join or aggregate is probably double-counting even though the query ran; fix the grain before trusting the numbers.
12
12
 
13
13
  Additional rules:
14
14
 
@@ -609,6 +609,19 @@ class AuthoredFace(BaseModel):
609
609
  description="Theme name (e.g., 'editorial', 'cream', 'stark').",
610
610
  )
611
611
 
612
+ # Auto-link: synthesize a detail-page link for table charts when no explicit
613
+ # link: is set. Default off — opt in at the face level (per-face override).
614
+ # Explicit link: always wins; link: none suppresses per chart.
615
+ # dft serve-scoped for Phase 1; project-level opt-in is deferred.
616
+ auto_link: bool = Field(
617
+ default=False,
618
+ description=(
619
+ "When True, table charts with no explicit link: automatically link each "
620
+ "row to its canonical /data/<source>/<schema>/<table>/detail/ page. "
621
+ "Default off. Explicit link: always wins; set link: ~ to suppress per chart."
622
+ ),
623
+ )
624
+
612
625
  @model_validator(mode="before")
613
626
  @classmethod
614
627
  def reject_face_key(cls, data: Any) -> Any:
@@ -381,6 +381,15 @@ class Face(BaseModel):
381
381
  description="When True, adds gap between cards and adjusts page margin.",
382
382
  )
383
383
 
384
+ # Auto-link: synthesize detail-page links for table charts with no explicit link:
385
+ auto_link: bool = Field(
386
+ default=False,
387
+ description=(
388
+ "When True, table charts with no explicit link: automatically link each "
389
+ "row to its canonical /data/<source>/<schema>/<table>/detail/ page."
390
+ ),
391
+ )
392
+
384
393
  # Semantic heading level: count of titled ancestors (not structural nesting depth).
385
394
  # A titled root face is level=1; a titled face nested under a bare wrapper is still
386
395
  # level=2 (only one titled ancestor). Bare wrappers without a title do not advance
@@ -503,6 +503,7 @@ def normalize_face(
503
503
  layout=layout,
504
504
  variable_defaults=variable_defaults, # Local variable defaults
505
505
  card_gap=face.card_gap,
506
+ auto_link=face.auto_link,
506
507
  authored_style=face.style,
507
508
  theme=theme,
508
509
  resolved_style=resolved_style,
@@ -36,10 +36,6 @@ Template globals:
36
36
  param (integers, exact decimals, strings). The detail view and the
37
37
  index→detail link use this so a numeric ``id`` primary key actually selects one
38
38
  row, where the browse-filter set would have dropped it.
39
- - ``plan_link_keys(rows)`` — the minimal, URL-safe id-like key for the index→detail
40
- *link* (a single ``id``, else ``*_id`` columns; integers/exact-decimals only).
41
- Narrower than ``plan_key_variables`` so the link can't drop on a NULL non-key
42
- column or be corrupted by an unencoded string value.
43
39
  - ``sql_identifier(name)`` — returns a safely ANSI-double-quoted SQL identifier
44
40
  (``"`` chars inside ``name`` are escaped as ``""``). Use for any path param that
45
41
  appears inside a SQL ``FROM`` or column reference to prevent SQL injection.
@@ -170,40 +166,6 @@ def _plan_key_variables(
170
166
  _TEMPLATE_ENV.globals["plan_key_variables"] = _plan_key_variables
171
167
 
172
168
 
173
- def _plan_link_keys(
174
- rows: list[dict[str, Any]],
175
- ) -> list[dict[str, str]]:
176
- """Pick the minimal, URL-safe row-identity key for the index→detail link.
177
-
178
- The cell-link renderer drops the WHOLE link if any referenced column is NULL
179
- and does no percent-encoding, so the link key must be both non-null-by-
180
- convention and URL-safe. We use the id naming convention as the proxy for a
181
- primary key: a single exact ``id`` column when present, else every ``*_id``
182
- column (composite foreign-key identity). Restricted to URL-safe numeric types
183
- (``is_url_safe_key``) — a string/UUID id would need encoding the renderer does
184
- not do, so such tables get no auto-link rather than a corruptible one.
185
-
186
- Returns a list of ``{"name": col}`` (the link only needs names); empty when no
187
- id-like URL-safe column exists.
188
- """
189
- from dataface.core.registered_views.variable_planner import PlannerColumn
190
-
191
- cols = [PlannerColumn(name=r["name"], actual_type=r["actual_type"]) for r in rows]
192
- id_like = [
193
- c
194
- for c in cols
195
- if c.is_valid_variable_id
196
- and c.is_url_safe_key
197
- and (c.name == "id" or c.name.endswith("_id"))
198
- ]
199
- exact = [c for c in id_like if c.name == "id"]
200
- chosen = exact if exact else id_like
201
- return [{"name": c.name} for c in chosen]
202
-
203
-
204
- _TEMPLATE_ENV.globals["plan_link_keys"] = _plan_link_keys
205
-
206
-
207
169
  def _sql_identifier(name: str) -> str:
208
170
  """Return a safely double-quoted SQL identifier.
209
171
 
@@ -0,0 +1,42 @@
1
+ """Shared key-selection helper for auto-link and registered-view templates.
2
+
3
+ A single copy lives here; both the Jinja template global (expander.py) and the
4
+ render-time auto-link resolver (render/chart/auto_link.py) call this function
5
+ so the selection logic cannot drift between the two.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ def plan_link_keys(rows: list[dict[str, str]]) -> list[dict[str, str]]:
12
+ """Pick the minimal, URL-safe row-identity key for the index→detail link.
13
+
14
+ The cell-link renderer drops the WHOLE link if any referenced column is NULL
15
+ and does no percent-encoding, so the link key must be both non-null-by-
16
+ convention and URL-safe. Uses the id naming convention as the proxy for a
17
+ primary key: a single exact ``id`` column when present, else every ``*_id``
18
+ column (composite foreign-key identity). Restricted to URL-safe numeric types
19
+ (``is_url_safe_key``) — a string/UUID id would need encoding the renderer does
20
+ not do, so such tables get no auto-link rather than a corruptible one.
21
+
22
+ Args:
23
+ rows: Column metadata rows, each with ``name`` and ``actual_type`` keys.
24
+ Matches the format returned by the schema profiler / ViewQueryResult.
25
+
26
+ Returns:
27
+ A list of ``{"name": col}`` dicts (the link only needs names); empty when no
28
+ id-like URL-safe column exists.
29
+ """
30
+ from dataface.core.registered_views.variable_planner import PlannerColumn
31
+
32
+ cols = [PlannerColumn(name=r["name"], actual_type=r["actual_type"]) for r in rows]
33
+ id_like = [
34
+ c
35
+ for c in cols
36
+ if c.is_valid_variable_id
37
+ and c.is_url_safe_key
38
+ and (c.name == "id" or c.name.endswith("_id"))
39
+ ]
40
+ exact = [c for c in id_like if c.name == "id"]
41
+ chosen = exact if exact else id_like
42
+ return [{"name": c.name} for c in chosen]
@@ -1,5 +1,9 @@
1
1
  title: [[ (path.source ~ "/" ~ path.schema ~ "/" ~ path.table) | tojson ]]
2
2
 
3
+ # The auto_link resolver fires at render time and synthesizes the detail-page
4
+ # link for each row using plan_link_keys — no hand-rolled link: needed here.
5
+ auto_link: true
6
+
3
7
  [% set vars = plan_variables(queries.columns.rows) %]
4
8
  [% if vars %]
5
9
  variables:
@@ -10,15 +14,6 @@ variables:
10
14
  [% endfor %]
11
15
  [% endif %]
12
16
 
13
- # The detail link carries only the minimal, URL-safe id-like key (see
14
- # plan_link_keys): never a nullable non-key column (the renderer drops the whole
15
- # link if any referenced column is NULL) and never an unencoded string value.
16
- [% set link_keys = plan_link_keys(queries.columns.rows) %]
17
- [% set ns = namespace(qs='') %]
18
- [% for k in link_keys %]
19
- [% set ns.qs = ns.qs ~ ('?' if loop.first else '&') ~ k.name ~ '={{ ' ~ k.name ~ ' }}' %]
20
- [% endfor %]
21
-
22
17
  queries:
23
18
  rows:
24
19
  type: sql
@@ -34,8 +29,6 @@ charts:
34
29
  title: [[ path.table | tojson ]]
35
30
  type: table
36
31
  query: rows
37
- [% if link_keys %] link: [[ ("/data/" ~ path.source ~ "/" ~ path.schema ~ "/" ~ path.table ~ "/detail/" ~ ns.qs) | tojson ]]
38
- [% endif %]
39
32
 
40
33
  rows:
41
34
  - row_data
@@ -44,20 +44,25 @@ class LinkContext:
44
44
  """Runtime context for resolving board links.
45
45
 
46
46
  Attributes:
47
- runtime: ``"serve"`` or ``"cloud"``.
47
+ runtime: ``"serve"`` for local dft serve; ``"cloud"`` for Cloud surfaces.
48
+ Only the ``"serve"`` runtime triggers the ``file://`` fallback for
49
+ relative links that leave the served faces tree (requires
50
+ ``faces_root`` + ``board_file_path``).
48
51
  current_board_slug: Author-space slug of the board being rendered
49
52
  (e.g. ``"zendesk/tickets/list"``).
50
53
  storage_prefix: On-disk directory prefix for serve mode (default ``"faces"``).
51
- org_slug: Cloud organization slug (Cloud only).
52
- project_slug: Cloud project slug (Cloud only).
53
- branch: Current branch name to merge into outbound links (Cloud only).
54
+ url_prefix: When non-empty, root-relative slug paths are prefixed with
55
+ this string instead of ``storage_prefix``. Cloud callers set this to
56
+ ``/{org}/{project}``. Dashboard slugs get a ``/d/`` infix and trailing
57
+ slash (``/{org}/{project}/d/zendesk/overview/``); system-view slugs
58
+ (``data/``) are emitted bare (``/{org}/{project}/data/src/schema/``).
59
+ branch: Current branch name to merge into outbound links.
54
60
  """
55
61
 
56
62
  runtime: str # "serve" | "cloud"
57
63
  current_board_slug: str = ""
58
64
  storage_prefix: str = "faces"
59
- org_slug: str = ""
60
- project_slug: str = ""
65
+ url_prefix: str = ""
61
66
  branch: str = ""
62
67
  # Serve-only: the on-disk root that slugs resolve against (the served faces
63
68
  # dir) and the current board's file path. Together they let a relative link
@@ -75,7 +80,8 @@ def resolve_href(href: str, ctx: LinkContext) -> str:
75
80
  - Root-relative (``/path``) maps from author namespace.
76
81
  - Relative (``../``, ``./``, bare) resolves against current board directory.
77
82
  - ``.md`` / ``.yml`` / ``.yaml`` suffixes are stripped.
78
- - Cloud URLs get ``/{org}/{project}/d/{slug}/`` shape + branch merge.
83
+ - When ``url_prefix`` is set, URLs are prefixed with it (e.g. Cloud sets
84
+ ``/{org}/{project}``).
79
85
  - Serve URLs get ``/{storage_prefix}/{slug}`` shape.
80
86
  """
81
87
  for prefix in _PASSTHROUGH_PREFIXES:
@@ -113,8 +119,8 @@ def resolve_href(href: str, ctx: LinkContext) -> str:
113
119
  ):
114
120
  return _build_file_url(ctx.board_file_path, path, query, fragment)
115
121
 
116
- if ctx.runtime == "cloud":
117
- url = _build_cloud_url(resolved, query, ctx)
122
+ if ctx.url_prefix:
123
+ url = _build_prefixed_url(resolved, query, ctx)
118
124
  else:
119
125
  url = _build_serve_url(resolved, query, ctx)
120
126
  return f"{url}#{fragment}" if fragment else url
@@ -171,8 +177,24 @@ def _build_serve_url(slug: str, query: str, ctx: LinkContext) -> str:
171
177
  return url
172
178
 
173
179
 
174
- def _build_cloud_url(slug: str, query: str, ctx: LinkContext) -> str:
175
- base = f"/{ctx.org_slug}/{ctx.project_slug}/d/{slug}/"
180
+ # System-view route prefixes that Cloud serves outside the /d/<slug>/ dashboard
181
+ # namespace. Slugs starting with these prefixes are emitted as bare project-
182
+ # relative paths (e.g. /org/proj/data/src/schema/); all other slugs are wrapped
183
+ # in the Cloud dashboard infix /d/<slug>/.
184
+ # Only include prefixes with a wired Cloud route — inspector/ is excluded until
185
+ # its Cloud route is added.
186
+ _SYSTEM_VIEW_PREFIXES = ("data/",)
187
+
188
+
189
+ def _build_prefixed_url(slug: str, query: str, ctx: LinkContext) -> str:
190
+ # System-view slugs (data/, inspector/) live outside the /d/ dashboard
191
+ # namespace and are served directly under the project prefix.
192
+ if any(slug.startswith(p) for p in _SYSTEM_VIEW_PREFIXES):
193
+ # Preserve the trailing slash — the registered-view router requires it.
194
+ url = f"{ctx.url_prefix}/{slug}"
195
+ else:
196
+ # Dashboard slugs use the Cloud routing infix: /d/<slug>/
197
+ url = f"{ctx.url_prefix}/d/{slug}/"
176
198
 
177
199
  # Merge branch if present in context and not already in query
178
200
  if ctx.branch and not _query_has_key(query, "branch"):
@@ -180,8 +202,8 @@ def _build_cloud_url(slug: str, query: str, ctx: LinkContext) -> str:
180
202
  query = f"{query}{separator}branch={ctx.branch}"
181
203
 
182
204
  if query:
183
- base = f"{base}?{query}"
184
- return base
205
+ url = f"{url}?{query}"
206
+ return url
185
207
 
186
208
 
187
209
  def _query_has_key(query: str, key: str) -> bool:
@@ -0,0 +1,187 @@
1
+ """Auto-link resolver for row-grain table charts (Phase 1).
2
+
3
+ Purpose: When a table chart has no explicit ``link:`` and the face has
4
+ ``auto_link: true``, synthesize the canonical
5
+ ``/data/<source>/<schema>/<table>/detail/?<key>={{ key }}`` link template so
6
+ the existing cell-link renderer can carry it without any new rendering path.
7
+
8
+ This is render/serve-time (not compile-time) because it needs schema metadata
9
+ (column types from the query result) to select URL-safe keys.
10
+
11
+ Entry points:
12
+ - ``resolve_auto_link(chart, query, column_rows, auto_link=False)`` — pure
13
+ resolver logic, callable from tests without the context machinery.
14
+ - ``set_auto_link_context(enabled)`` / ``get_auto_link_context()`` — set/get
15
+ the per-render-pass flag via a ContextVar so the renderer can flip it on
16
+ once per face without threading the bool through every call-site signature.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from contextvars import ContextVar
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from dataface.core.compile.models.chart.normalized import Chart
27
+ from dataface.core.compile.models.query.normalized import AnyQuery
28
+
29
+ # Per-render-pass flag: True when the current face has auto_link=True.
30
+ # Set by the renderer at the start of a render pass, cleared in finally.
31
+ _auto_link_ctx: ContextVar[bool] = ContextVar("_auto_link_ctx", default=False)
32
+
33
+ # Sentinel used by private helpers to signal "bail — no link possible".
34
+ # Callers check `if not result` to detect it.
35
+ _BAIL: tuple[()] = ()
36
+
37
+
38
+ def set_auto_link_context(enabled: bool) -> None:
39
+ """Set the auto_link flag for the current render pass."""
40
+ _auto_link_ctx.set(enabled)
41
+
42
+
43
+ def get_auto_link_context() -> bool:
44
+ """Return the auto_link flag for the current render pass."""
45
+ return _auto_link_ctx.get()
46
+
47
+
48
+ def _extract_single_base_table(sql: str) -> tuple[str, str] | tuple[()]:
49
+ """Extract (schema, table) from SQL with exactly one base table, or _BAIL.
50
+
51
+ Bails on joins, CTEs, subqueries, aggregation (GROUP BY / DISTINCT /
52
+ aggregate functions), catalog-qualified 3-part names, missing schema
53
+ prefix, or parse error.
54
+
55
+ Aggregation is rejected because the resulting rows are not row-grain: a
56
+ link to a per-row detail page would be semantically wrong (the "row-grain"
57
+ contract in the module docstring). Catalog-qualified names are rejected
58
+ because the catalog component would be silently dropped from the URL.
59
+ """
60
+ import sqlglot
61
+ import sqlglot.errors
62
+ from sqlglot import exp
63
+
64
+ # Strip Jinja tokens so sqlglot can parse the structural skeleton.
65
+ stripped = re.sub(r"\{\{.*?\}\}", "1", sql, flags=re.DOTALL)
66
+ stripped = re.sub(r"\{%.*?%\}", "", stripped, flags=re.DOTALL)
67
+
68
+ try:
69
+ tree = sqlglot.parse_one(stripped)
70
+ except sqlglot.errors.ParseError:
71
+ return _BAIL
72
+
73
+ if list(tree.find_all(exp.CTE)):
74
+ return _BAIL
75
+ if list(tree.find_all(exp.Join)):
76
+ return _BAIL
77
+ if list(tree.find_all(exp.Subquery)):
78
+ return _BAIL
79
+ # Aggregation collapses grain — link would point at a wrong detail page.
80
+ if list(tree.find_all(exp.Group)):
81
+ return _BAIL
82
+ if list(tree.find_all(exp.Distinct)):
83
+ return _BAIL
84
+ if list(tree.find_all(exp.AggFunc)):
85
+ return _BAIL
86
+
87
+ tables = list(tree.find_all(exp.Table))
88
+ if len(tables) != 1:
89
+ return _BAIL
90
+
91
+ t = tables[0]
92
+ table_name = t.name
93
+ schema_name = t.db # sqlglot calls the schema qualifier `.db`
94
+ catalog_name = t.catalog # non-empty for mydb.schema.table 3-part names
95
+
96
+ if not table_name or not schema_name:
97
+ return _BAIL
98
+ # Bail on catalog-qualified names — the catalog would be silently dropped
99
+ # from the URL, producing an ambiguous path.
100
+ if catalog_name:
101
+ return _BAIL
102
+
103
+ return schema_name, table_name
104
+
105
+
106
+ def _source_schema_table(query: AnyQuery) -> tuple[str, str, str] | tuple[()]:
107
+ """Extract (source, schema, table) from a query, or _BAIL."""
108
+ from dataface.core.compile.models.query.normalized import (
109
+ is_schema_query,
110
+ is_sql_query,
111
+ )
112
+
113
+ if is_schema_query(query):
114
+ if not query.source or not query.schema_name or not query.table:
115
+ return _BAIL
116
+ return query.source, query.schema_name, query.table
117
+
118
+ if is_sql_query(query):
119
+ raw_source = query.source
120
+ if not isinstance(raw_source, str) or not raw_source:
121
+ return _BAIL
122
+ result = _extract_single_base_table(query.sql)
123
+ if not result:
124
+ return _BAIL
125
+ schema, table = result
126
+ return raw_source, schema, table
127
+
128
+ return _BAIL
129
+
130
+
131
+ def resolve_auto_link(
132
+ chart: Chart,
133
+ query: AnyQuery,
134
+ column_rows: list[dict[str, str]],
135
+ auto_link: bool = False,
136
+ ) -> str:
137
+ """Synthesize the detail-page link template for a row-grain table chart.
138
+
139
+ Called at render time after query execution when the chart has no explicit
140
+ ``link:`` and ``auto_link`` is enabled. Returns the synthesized link string
141
+ (e.g. ``/data/dw/analytics/orders/detail/?id={{ id }}``) or an empty string
142
+ to signal "emit no link" (caller checks ``if result:``).
143
+
144
+ Bails (returns ``""``) on:
145
+ - ``auto_link=False``
146
+ - Chart already has an explicit ``link:`` (caller must not override it)
147
+ - Chart type is not ``table``
148
+ - Query is not a ``SchemaQuery`` or ``SqlQuery``
149
+ - SQL query has joins, CTEs, subqueries, or no schema prefix
150
+ - No URL-safe id-like key columns in ``column_rows``
151
+ - Any ambiguity — a wrong link is worse than no link
152
+
153
+ Callers must guard ``query is not None`` before calling — ``query`` must be
154
+ a resolved query (``SchemaQuery`` or ``SqlQuery``). The function does not
155
+ accept ``None`` to avoid ``| None`` in the signature (type-state ratchet).
156
+
157
+ Args:
158
+ chart: Normalized chart (must be ``type=table`` with ``link=None``).
159
+ query: The chart's resolved query. Only ``SchemaQuery`` and ``SqlQuery``
160
+ are handled; all other query types bail silently.
161
+ column_rows: Column metadata from the query result, each with ``name``
162
+ and ``actual_type`` keys (same format as the schema profiler).
163
+ auto_link: Whether auto-linking is enabled. Default ``False`` (opt-in).
164
+
165
+ Returns:
166
+ A Jinja link template string, or ``""`` when no link should be emitted.
167
+ """
168
+ if not auto_link:
169
+ return ""
170
+ if chart.link is not None:
171
+ return ""
172
+ if chart.type != "table":
173
+ return ""
174
+
175
+ loc = _source_schema_table(query)
176
+ if not loc:
177
+ return ""
178
+ source, schema, table = loc
179
+
180
+ from dataface.core.registered_views.link_keys import plan_link_keys
181
+
182
+ keys = plan_link_keys(column_rows)
183
+ if not keys:
184
+ return ""
185
+
186
+ qs = "&".join(f"{k['name']}={{{{ {k['name']} }}}}" for k in keys)
187
+ return f"/data/{source}/{schema}/{table}/detail/?{qs}"
@@ -89,6 +89,38 @@ def _resolve_chart_for_render(
89
89
  resolved_chart = chart.model_copy(update=updates) if updates else chart
90
90
  resolved_chart = resolve_field_names(resolved_chart, data)
91
91
 
92
+ # Auto-link: synthesize a detail-page link for row-grain table charts when
93
+ # auto_link is enabled for this render pass and no explicit link: is set.
94
+ from dataface.core.render.chart.auto_link import (
95
+ get_auto_link_context,
96
+ resolve_auto_link,
97
+ )
98
+
99
+ if (
100
+ get_auto_link_context()
101
+ and resolved_chart.link is None
102
+ and chart.query_name
103
+ and resolved_chart.query is not None
104
+ ):
105
+ col_descs_for_link = executor.get_column_descriptions(chart.query_name)
106
+ if col_descs_for_link:
107
+ # str(desc[1]) is the PEP 249 type_code. DuckDB returns string type
108
+ # names ("INTEGER", "BIGINT", etc.), which plan_link_keys understands.
109
+ # Other drivers (Postgres, Snowflake) return integer OIDs — auto-link
110
+ # degrades to no-link for those, which is the safe failure mode for Phase 1.
111
+ column_rows = [
112
+ {"name": name, "actual_type": str(desc[1])}
113
+ for name, desc in col_descs_for_link.items()
114
+ ]
115
+ synthesized = resolve_auto_link(
116
+ resolved_chart,
117
+ resolved_chart.query,
118
+ column_rows,
119
+ auto_link=True,
120
+ )
121
+ if synthesized:
122
+ resolved_chart = resolved_chart.model_copy(update={"link": synthesized})
123
+
92
124
  detect_data = data
93
125
  if use_placeholder and not data:
94
126
  from dataface.core.render.placeholder import generate_placeholder_data
@@ -382,9 +382,11 @@ def render(
382
382
 
383
383
  # Board link rewriting: set context for the duration of this render pass
384
384
  from dataface.core.render.board_links import set_link_context
385
+ from dataface.core.render.chart.auto_link import set_auto_link_context
385
386
 
386
387
  link_context = options.get("link_context")
387
388
  set_link_context(link_context)
389
+ set_auto_link_context(face.auto_link)
388
390
 
389
391
  try:
390
392
  grid_enabled = options.get("grid", False)
@@ -405,6 +407,7 @@ def render(
405
407
  # (e.g. DF_RENDER_NO_LAYOUT, MissingRequiredVariablesError). Surface as
406
408
  # face_error so callers see the structured code unwrapped.
407
409
  set_link_context(None)
410
+ set_auto_link_context(False)
408
411
  return RenderResult(
409
412
  output=None,
410
413
  chart_errors=error_collector,
@@ -416,6 +419,7 @@ def render(
416
419
  from dataface.core.errors import DF_RENDER_INTERNAL
417
420
 
418
421
  set_link_context(None)
422
+ set_auto_link_context(False)
419
423
  wrapped = RenderError.from_code(DF_RENDER_INTERNAL, inner_message=str(e))
420
424
  return RenderResult(
421
425
  output=None,
@@ -426,6 +430,7 @@ def render(
426
430
  )
427
431
  finally:
428
432
  set_link_context(None)
433
+ set_auto_link_context(False)
429
434
 
430
435
  # Convert to requested format
431
436
  if format == "svg":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataface
3
- Version: 0.1.6.dev345
3
+ Version: 0.1.6.dev360
4
4
  Summary: Dataface - dbt-native dashboard and visualization layer
5
5
  Author: Fivetran, Inc.
6
6
  License-Expression: Apache-2.0
@@ -112,7 +112,7 @@ dataface/core/compile/normalize_charts.py,sha256=KW4dsW1_0SRS3q6cS6upI68-bgm60Gx
112
112
  dataface/core/compile/normalize_layout.py,sha256=Qv-wtc0c6O0EMnSW6CAuXTwH5j-wzzthqDl2rA7CUdY,34952
113
113
  dataface/core/compile/normalize_queries.py,sha256=VZ2yszJU_lTicT_X_p8AalRbXJQ7pUjgUDrwQ8BUE1Q,11951
114
114
  dataface/core/compile/normalize_variables.py,sha256=uz5XK7Gc-4omz5gJU1sgvUI32Ph55HhEWwqa17v_Obc,21458
115
- dataface/core/compile/normalizer.py,sha256=6A-38ny_E7MPrX-reMrlWXIj26gNhK5CDwBOvsUWgqk,25251
115
+ dataface/core/compile/normalizer.py,sha256=He1aSaut8CN5-JyLZS98dXif14rcjayP7ObgjoA8mRI,25285
116
116
  dataface/core/compile/palette.py,sha256=5yl3fzQPCdYdkgxqf2-D4Sh72M-mh5W_9JiGA5T3hGY,41567
117
117
  dataface/core/compile/parameterized.py,sha256=-wNzLLdZ2Nj6DGHW0TShNDBNuRTwxEpukbdQMesAYro,21996
118
118
  dataface/core/compile/parser.py,sha256=Itr_OfS3BNK29TCkoNnnoFevvaPUsu7mxAekk_bveQw,7850
@@ -140,8 +140,8 @@ dataface/core/compile/models/chart/authored.py,sha256=FxYrI8MUMp6fiLH677t7xbLt7z
140
140
  dataface/core/compile/models/chart/normalized.py,sha256=aJa-C2jcXg03bRUmod-JuW5TJMPWn3swjauiEtvVI7w,15373
141
141
  dataface/core/compile/models/chart/resolved.py,sha256=JQz38CKC15SSXueOi98tiFZp_GFsdZKGeMzn-4pqyWA,6336
142
142
  dataface/core/compile/models/face/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
143
- dataface/core/compile/models/face/authored.py,sha256=0aVyNdus1clTP2l8ofeHdKGh-Ojx9stYBVi63W7RCho,22563
144
- dataface/core/compile/models/face/normalized.py,sha256=a-ySYCFX21HEeFnwWuaHr4cirOnaeJ2Dd9fZ3pmX4_M,16984
143
+ dataface/core/compile/models/face/authored.py,sha256=rjfB2vQuz06oXtZkP4JNxe3SzutblSOych2JckmyATY,23213
144
+ dataface/core/compile/models/face/normalized.py,sha256=rMqvyYRO-ol9_VLM4TC1ciQY5a62kTpdIU39WlrpIOs,17329
145
145
  dataface/core/compile/models/face/resolved.py,sha256=btD3f2p_-3IXQOu0tc9CmCaSrAbxOsCG18MLhWdYvLg,3852
146
146
  dataface/core/compile/models/query/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
147
147
  dataface/core/compile/models/query/authored.py,sha256=tfe7KMc22VzxaxSDoQAKjk2Wn5GSn-wnf-x22hH7gG4,8522
@@ -302,7 +302,8 @@ dataface/core/pack/planner.py,sha256=GwN5bkPK5Rm-rx52SwaOb45KvDCeY_cvwmYWCv3D_ls
302
302
  dataface/core/pack/proposal_store.py,sha256=k2nY5TmdFaNeYYVVLA238oD4SJFwW8-LH2wwUUzGWsU,2943
303
303
  dataface/core/registered_views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
304
304
  dataface/core/registered_views/data_urls.py,sha256=5vxlpwpiT0VlzChI_Clzj1H8QOfCzDmoN7rofSzaKTg,1292
305
- dataface/core/registered_views/expander.py,sha256=zBTFCvZPQLfUjldAYCntOhGomDiuTb1oUHf7S_sdPso,15759
305
+ dataface/core/registered_views/expander.py,sha256=fQ6BG5cmRo09B-UOdtMrjvmaK7K1o1MgrwHHaivaSFE,14068
306
+ dataface/core/registered_views/link_keys.py,sha256=PwiA4CpbN5l0Qis_wHBuzBQBzWhGWARi5rCyAsUrg0Q,1839
306
307
  dataface/core/registered_views/loader.py,sha256=eA6P34JIEdzmF_k82wnll2IylDupLc0IMGM59Wg7VDo,3425
307
308
  dataface/core/registered_views/models.py,sha256=MhbdWIa649t5Ps4HUekz15qW-zFIVYI2Z5A3HCNaDm0,1724
308
309
  dataface/core/registered_views/query_runner.py,sha256=3popMvvaVD0zDQCnlYXVlCMKGuHFDa_9WD8fVI-9XRA,10657
@@ -314,13 +315,13 @@ dataface/core/registered_views/templates/data/root.yaml,sha256=Xo9-P_PkOqM0z-Cd-
314
315
  dataface/core/registered_views/templates/data/schema-index.yaml,sha256=W7v6nw7XHrNX7_bPpKXOY-FjgQyUJooOd-Ue9zLnVi0,418
315
316
  dataface/core/registered_views/templates/data/source-index.yaml,sha256=QIJJ4eJq5EjvDZOOwLFvIN-4x0TSvfg45PSpXZdFX3Q,343
316
317
  dataface/core/registered_views/templates/data/table-detail.yaml,sha256=3lWn9lxfvvjoFCUF-2mnQKnxluJilQF9zEELtEbduO8,691
317
- dataface/core/registered_views/templates/data/table-index.yaml,sha256=G8s196Z-p42-1dCzJU2WA4Z1GkA_ZIpCAUd1pRPqrcU,1262
318
+ dataface/core/registered_views/templates/data/table-index.yaml,sha256=qWeSNXI6ksFU9Vd3d5vyPFlyceRTFBqHFrwuQVyBFTs,847
318
319
  dataface/core/registered_views/templates/inspector/column.yaml,sha256=Pnb14siQxQsPqKhpk1hPY50HNLruUh9cyoyu5Nar2xw,517
319
320
  dataface/core/registered_views/templates/inspector/schema.yaml,sha256=Y6_OGWHxfEEsznXbnQkx37vjilSdzFE3y5yPVK8noOE,347
320
321
  dataface/core/registered_views/templates/inspector/source.yaml,sha256=13kVlM_HKbEdkZcox3vQ51qTYNvE_naDD5MbNkffkmA,294
321
322
  dataface/core/registered_views/templates/inspector/table.yaml,sha256=nbOqjuLKZMV6miQbOi6U9TjDCnK3CkX3GEwvWL67Dsk,445
322
323
  dataface/core/render/__init__.py,sha256=0aVzC2Maa1Hn68Qj7lpLLSzhgR5TNhlF-I8ZlKH3Y2U,2255
323
- dataface/core/render/board_links.py,sha256=CUzu5VV659AWsOZXVIXii2ax8c7kOhb6RxXbW5GS1eg,8078
324
+ dataface/core/render/board_links.py,sha256=kku2qAcicpss9FSl4zTVslulC-sHJUlgdv4hFP41Mq0,9413
324
325
  dataface/core/render/chart_interactivity.py,sha256=tcnTZ1c1RHFkxCZUvRzDm5cNYbEuSfGmwpc2B6y648k,1993
325
326
  dataface/core/render/dir_context.py,sha256=mZ-wD1rqbD5glACZglh7fncc8V04TucSv-08Z0lcRDQ,12587
326
327
  dataface/core/render/errors.py,sha256=WS1fnxlNGW2V9w6cKuPPLuuiX5fXar95xRdNziHGgmM,4716
@@ -337,7 +338,7 @@ dataface/core/render/layouts.py,sha256=kcwIi57N6rJ4cG7eqqCVG_RsMC0P4xr2dxFfqLjLv
337
338
  dataface/core/render/nav.py,sha256=l8VnDuwgJwftMY6Lw6dUK0yN44Cs-pxXcVIe5TpjK1o,3307
338
339
  dataface/core/render/placeholder.py,sha256=M1JncsWVGYJUzu3cEnC8-8TAnRtQWuBRpr6GtCBJupw,13517
339
340
  dataface/core/render/render_result.py,sha256=Rfc2-rlGpS98cHRxjJNy7KXoZOLNbxM6bzgggB8aReg,453
340
- dataface/core/render/renderer.py,sha256=cX6zi_kbOACkCSFpdkW3P19q7PYQAU0otTUVz0sckZ0,21253
341
+ dataface/core/render/renderer.py,sha256=ZpqjIr2dbQLdQBh_GtXDcscEAo7isapITRkmJ_g--E0,21481
341
342
  dataface/core/render/script_embedding.py,sha256=OePzZdk-7jR-WrF-Jnfq9SriGv8Cs2kqKfrZiZMiHuw,442
342
343
  dataface/core/render/svg_utils.py,sha256=q1ZS-r3jrSOXM2AO5HXrTtfiVwZEvEdgBcPw5pfRTUQ,6892
343
344
  dataface/core/render/template_loader.py,sha256=xa6jkqEfHxFbBg5sCH1IX4c2LJSD80yNFBzz09tn42w,2296
@@ -354,6 +355,7 @@ dataface/core/render/yaml_format.py,sha256=TfYQPEB7qGKwLLRZ3bzAvh9L0XnPilQsstf6G
354
355
  dataface/core/render/chart/__init__.py,sha256=nHyhv9WW7ySP2EooQ9MUDjnJSIEcAUTBHnAFGd4Z60I,961
355
356
  dataface/core/render/chart/arc_attached_table.py,sha256=A5E3Oi5QbjOXq38Sogf740BTymzY2Ks0p4GLEaQXC9s,10204
356
357
  dataface/core/render/chart/artifacts.py,sha256=0GnDrr35cJtX5REAgWMYVlEvcTdatm-flRlxy1T8KNM,384
358
+ dataface/core/render/chart/auto_link.py,sha256=Z2011cDCHTjfQ2EGTjEkfeatRQ35p_I-vZFbqCclJj8,6886
357
359
  dataface/core/render/chart/callout.py,sha256=XD1NNtSWZ-25lDsgHPgBPMjRW_pDPO2x4rLU-DPPNrQ,7830
358
360
  dataface/core/render/chart/decisions.py,sha256=4yNsUqhv0QiQ4O__eOU1CQybop8FdaStsY1C_1IFOoo,16218
359
361
  dataface/core/render/chart/geo.py,sha256=dIX4oMJb9yAboszznWYShsN-IL-7BZjVqb_bLcXvl4k,25014
@@ -362,7 +364,7 @@ dataface/core/render/chart/labels.py,sha256=VAjMiHbqqy8qPlbfHhJhcPPeZTF3N36NI15m
362
364
  dataface/core/render/chart/pipeline.py,sha256=QYwkybtLJ-74WIrcFCWtAiosZ1QwO-0n5McX3Wor-ts,27347
363
365
  dataface/core/render/chart/presentation.py,sha256=rDqP3g0XJAo1U2zJYU5PCH_c3vkDjo0V4eT4fbV32C4,1336
364
366
  dataface/core/render/chart/profile.py,sha256=rEAK9206LWgeCray8agMGYRD6jne1Wiglbxi__xppdo,162232
365
- dataface/core/render/chart/render_single.py,sha256=komGntJTajfioqWuyHAA6hyAs5tKST_NDcmq3Qw3MRQ,16366
367
+ dataface/core/render/chart/render_single.py,sha256=5jPiHZyCjaPPoUTyLUIqp9sW71y6LtKrSFl9WIEd4Qc,17723
366
368
  dataface/core/render/chart/renderers.py,sha256=bRc27MFf42ni9JxP8iPekSSPVNz7vjLarq2Ve_MItbg,4646
367
369
  dataface/core/render/chart/rendering.py,sha256=YkcglFVrox2ByG8qGiBwGa-ZQUnrG71UTIQc1xWfBDg,20320
368
370
  dataface/core/render/chart/serialization.py,sha256=fQFJkB8FMbmEx2Q8b8UdSe2yMl2XBWwPOPuobJyLnUA,2513
@@ -465,7 +467,7 @@ dataface/agent_api/_init_templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQe
465
467
  dataface/agent_api/_init_templates/dataface.yml,sha256=RiPfK_iwARqva3cao9oBf5b2lGTuut-adaabKFfv7xU,542
466
468
  dataface/agent_api/_init_templates/guide.yaml,sha256=WgGCIcIfJIOHvz33GsOm-kpSeJ6ikWsOH4_Alj4bC8A,4782
467
469
  dataface/agent_api/_init_templates/meta.yaml,sha256=9I8jwiVKGCu-g3vmMtwNo6ZKBJLtKYSVfugDCPFii6E,818
468
- dataface/ai/prompts/sql-guidance.md,sha256=Rzd42XdgYA3dYXuvzbi4lo6dAO4NQjJ7cpOW6HkVSzk,1168
470
+ dataface/ai/prompts/sql-guidance.md,sha256=CJKSA18Pw2RjBq181I1Ktc8X0FnQtX8pYAhuWT6O6Bs,1622
469
471
  dataface/ai/prompts/sql-system.md,sha256=L1F_Mh2BewpZU9x3UryUVHdWg1kZDC80Su_iD0x2xXk,299
470
472
  dataface/ai/prompts/system.md,sha256=ruqScPJ2UgYtnAviM6FFbeqaIQmogCbGCjl7gW48Wok,294
471
473
  dataface/ai/skills/before-after-comparison/SKILL.md,sha256=sVUPTTlU1WdDStBELhing_XQcQpzvKFco29DceFvmPU,3276
@@ -499,7 +501,7 @@ dataface/ai/skills/top-n-with-detail/SKILL.md,sha256=lW25eQCTZPzj46EHNvZTGZm4lAC
499
501
  dataface/ai/skills/top-n-with-detail/examples/top-n-with-detail.yml,sha256=UOhvxkZncXLsj6D1P0g0O1TZ5sEs0gOCWce2gc9xgPQ,1076
500
502
  dataface/ai/skills/two-by-two-grid-overview/SKILL.md,sha256=RRDXPrdbwCRpIhlRCzNZNp4pBRXmdgeu5rGjktlM4S0,2919
501
503
  dataface/ai/skills/two-by-two-grid-overview/examples/two-by-two-grid-overview.yml,sha256=rquYNR89pxWannuca_k2bQfTGB8T1PtffxytJ0nxK0c,1385
502
- dataface/agent_api/docs/yaml-reference.md,sha256=9HlaSRTCkY4rfc_jkTBLaxVCIftu0p-kQhUgZ2bpfqw,190012
504
+ dataface/agent_api/docs/yaml-reference.md,sha256=lglcFlYJ8JcKeWzyXkGgbDk6M0WI2duG9pRHc5bDFTM,190275
503
505
  d3_format/__init__.py,sha256=FkU-W58LUkTf-aA0Tv2FVRTZ-PL0cznXVmdW9viHJ14,407
504
506
  d3_format/errors.py,sha256=yJXRnnfpVtsrWjZYbGRFV_BR62ZaRNVkfDiuAPwiqX4,674
505
507
  d3_format/format.py,sha256=MgcqKSUsa-qYxzeT3RCGYcxY8WaF9I545ymTv5SIMQo,19367
@@ -515,8 +517,8 @@ mdsvg/renderer.py,sha256=3aHnwxlJxmI2X6kuI5GFBcOnXeP9T8HC8qNY0m7InsQ,66026
515
517
  mdsvg/style.py,sha256=AOpUrMseyjeIVMzgpEdYmNO66OdiOQIESWG0Fv9jUfg,17058
516
518
  mdsvg/types.py,sha256=MQlqsUP5y78D8kX0brD-82DkBvaUYdbmkq1U95TnyWA,5093
517
519
  mdsvg/utils.py,sha256=CIqFACFaJiM0A_dl-hJXYhRqH-T7ayazP62wgfWGMjw,1866
518
- dataface-0.1.6.dev345.dist-info/METADATA,sha256=fyf8q9cjDVoIcPfDFBQ87g05cmrObvGEjD_trNQjVT0,11455
519
- dataface-0.1.6.dev345.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
520
- dataface-0.1.6.dev345.dist-info/entry_points.txt,sha256=imfZRsAzmIbDRHW7jqhn0nT6EbYWAyOiuHisxmfVgXs,46
521
- dataface-0.1.6.dev345.dist-info/licenses/LICENSE,sha256=Iy9gBB2gC8WGQEwHxJd4-huwUvxB6OMOoT3no6emeaQ,11345
522
- dataface-0.1.6.dev345.dist-info/RECORD,,
520
+ dataface-0.1.6.dev360.dist-info/METADATA,sha256=oa3W02_3Wh6Y_FUyF8uJnm8HEkLRyNkAHfCqkghuau0,11455
521
+ dataface-0.1.6.dev360.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
522
+ dataface-0.1.6.dev360.dist-info/entry_points.txt,sha256=imfZRsAzmIbDRHW7jqhn0nT6EbYWAyOiuHisxmfVgXs,46
523
+ dataface-0.1.6.dev360.dist-info/licenses/LICENSE,sha256=Iy9gBB2gC8WGQEwHxJd4-huwUvxB6OMOoT3no6emeaQ,11345
524
+ dataface-0.1.6.dev360.dist-info/RECORD,,