@@ -35,14 +35,53 @@ class LookMLAdapter(BaseAdapter):
3535 """
3636
3737 def parse (self , source : str | Path ) -> SemanticGraph :
38- """Parse LookML files into semantic graph.
38+ """Parse LookML files into a semantic graph.
39+
40+ Auto-registration to an active SemanticLayer is suppressed while the graph is
41+ built, so a tableless view that only gets its default table after refinements/
42+ extends are resolved is not validated mid-parse (which would raise inside a
43+ ``with SemanticLayer():`` block). The finalized models are registered once.
3944
4045 Args:
4146 source: Path to .lkml file or directory
4247
4348 Returns:
4449 Semantic graph with imported models
4550 """
51+ from sidemantic .core .registry import get_current_layer , set_current_layer
52+
53+ prev_layer = get_current_layer ()
54+ set_current_layer (None )
55+ try :
56+ graph = self ._build_graph (source )
57+ finally :
58+ set_current_layer (prev_layer )
59+ # Defer auto-registration until models are complete (tables defaulted). Skip ONLY
60+ # intentional non-queryable templates -- abstract extension:required bases and
61+ # unsupported derived tables, marked in meta -- which add_model's validation would
62+ # reject; mirrors loaders._is_registerable_model. A merely-broken tableless view
63+ # (e.g. extends:[missing], left unresolved) is NOT skipped, so add_model still
64+ # surfaces the real "no table/sql" error instead of silently dropping it. Strip
65+ # survivors' relationships pointing at a skipped template (e.g. an explore join to
66+ # it) so the active layer is not left with a dangling relationship validation flags.
67+ if prev_layer is not None :
68+ skipped = {
69+ name
70+ for name , m in graph .models .items ()
71+ if not (m .table or m .sql or getattr (m , "dax" , None ) or getattr (m , "source_uri" , None ))
72+ and (m .meta or {}).get ("lookml_template" )
73+ }
74+ for model in graph .models .values ():
75+ if model .name in skipped or model .name in prev_layer .graph .models :
76+ continue
77+ rels = getattr (model , "relationships" , None )
78+ if rels :
79+ model .relationships = [r for r in rels if r .name not in skipped ]
80+ prev_layer .add_model (model )
81+ return graph
82+
83+ def _build_graph (self , source : str | Path ) -> SemanticGraph :
84+ """Build the semantic graph from LookML files (see :meth:`parse`)."""
4685 graph = SemanticGraph ()
4786 source_path = Path (source )
4887
@@ -58,17 +97,50 @@ def parse(self, source: str | Path) -> SemanticGraph:
5897 for lkml_file in lkml_files :
5998 self ._parse_views_from_file (lkml_file , graph , refinements )
6099
100+ # Snapshot abstract / unsupported-derived_table flags BEFORE refinement merge:
101+ # merge_model REPLACES a base view's meta when the refinement carries metadata,
102+ # which would otherwise drop these markers.
103+ abstract_pre = {n for n , m in graph .models .items () if (m .meta or {}).get ("extension_required" )}
104+ unsupported_pre = {n for n , m in graph .models .items () if (m .meta or {}).get ("unsupported_derived_table" )}
105+
61106 # Apply refinements: merge each refinement into its base view
62107 from sidemantic .core .inheritance import merge_model , resolve_model_inheritance
63108
109+ refinement_abstract : set [str ] = set ()
110+ refinement_unsupported_dt : set [str ] = set ()
64111 for refinement in refinements :
65112 base_name = refinement .name .lstrip ("+" )
113+ # Record flags from EACH refinement's own meta: a later refinement's merge
114+ # can replace the base meta and drop a flag an earlier refinement added.
115+ rmeta = refinement .meta or {}
116+ if rmeta .get ("extension_required" ):
117+ refinement_abstract .add (base_name )
118+ if rmeta .get ("unsupported_derived_table" ):
119+ refinement_unsupported_dt .add (base_name )
66120 if base_name in graph .models :
67121 # Create a copy with the base name for merging
68122 refinement_for_merge = refinement .model_copy (update = {"name" : base_name })
69123 merged = merge_model (refinement_for_merge , graph .models [base_name ])
70124 graph .models [base_name ] = merged
71125
126+ # Union the pre-merge snapshot, every refinement's own flags, and the post-merge
127+ # state (so a flag added by ANY refinement is caught even if a later refinement
128+ # replaced the meta). Resolve this BEFORE extends so a concrete child that only
129+ # INHERITS abstractness is not treated as abstract. Record extends parents too,
130+ # so descendants of an unsupported derived table are detectable after resolution
131+ # clears `extends`.
132+ abstract_views = (
133+ abstract_pre
134+ | refinement_abstract
135+ | {n for n , m in graph .models .items () if (m .meta or {}).get ("extension_required" )}
136+ )
137+ unsupported_dt_views = (
138+ unsupported_pre
139+ | refinement_unsupported_dt
140+ | {n for n , m in graph .models .items () if (m .meta or {}).get ("unsupported_derived_table" )}
141+ )
142+ extends_parent = {n : m .extends for n , m in graph .models .items () if m .extends }
143+
72144 # Resolve extends chains. Pre-filter to models whose full chain
73145 # is present so one broken/missing parent doesn't block valid ones.
74146 def _chain_resolvable (name : str , visited : set [str ] | None = None ) -> bool :
@@ -92,10 +164,68 @@ def _chain_resolvable(name: str, visited: set[str] | None = None) -> bool:
92164 resolved .update (unresolvable )
93165 graph .models = resolved
94166
167+ def _extends_chain_has (name : str , flagset : set [str ]) -> bool :
168+ """True if name or any of its extends-ancestors is in flagset."""
169+ seen : set [str ] = set ()
170+ cur : str | None = name
171+ while cur is not None and cur not in seen :
172+ if cur in flagset :
173+ return True
174+ seen .add (cur )
175+ cur = extends_parent .get (cur )
176+ return False
177+
178+ # Apply the implicit "table = view name" default AFTER refinements and extends
179+ # are resolved, so a view whose fields/name come from a refinement or whose
180+ # parent was tableless still gets its OWN name as the table (Looker's behavior).
181+ # Skip abstract views, still-unresolved-extends, and views that are (or extend) an
182+ # unsupported derived table. Do NOT require parsed fields: Looker defaults the table
183+ # for an ordinary fieldless view (`view: orders {}`, or one with only adapter-ignored
184+ # fields) too, so leaving it tableless would wrongly fail CLI validation/registration.
185+ # Abstractness is NOT inherited through extends (a concrete child of an abstract base
186+ # gets its own table), but an unsupported derived table IS inherited by descendants.
187+ def _apply_default_tables ():
188+ for model_name , model in graph .models .items ():
189+ if (
190+ model .table is None
191+ and model .sql is None
192+ and not model .extends
193+ and model_name not in abstract_views
194+ and not _extends_chain_has (model_name , unsupported_dt_views )
195+ and not (model .meta or {}).get ("unsupported_derived_table" )
196+ ):
197+ model .table = model_name
198+
199+ _apply_default_tables ()
200+
95201 # Second pass: parse explores and add relationships
96202 for lkml_file in lkml_files :
97203 self ._parse_explores_from_file (lkml_file , graph )
98204
205+ # Re-apply the default: explores can add segments (sql_always_where /
206+ # always_filter) to an otherwise-fieldless view, which only now makes it
207+ # eligible for the implicit table default.
208+ _apply_default_tables ()
209+
210+ # Re-assert non-queryable markers on models intentionally left tableless. A
211+ # refinement can OVERWRITE a view's meta (dropping an `extension_required` flag an
212+ # earlier refinement added) even though abstractness is tracked in side-sets, so
213+ # without this the loader (which keys off final meta) would try to register and
214+ # reject them. Set the flag definitively now, after all merges. Also stamp a
215+ # PARSER-OWNED `lookml_template` marker so registration skips (here and in the
216+ # loader) key off a sidemantic-internal flag, not the public `extension_required`/
217+ # `unsupported_derived_table` keys -- a native/other-format model that happens to
218+ # carry those user-facing keys must still surface its missing-source error.
219+ for model_name , model in graph .models .items ():
220+ if model .table is not None or model .sql is not None :
221+ continue
222+ if model_name in abstract_views :
223+ model .meta = {** (model .meta or {}), "extension_required" : True , "lookml_template" : True }
224+ elif _extends_chain_has (model_name , unsupported_dt_views ) or (model .meta or {}).get (
225+ "unsupported_derived_table"
226+ ):
227+ model .meta = {** (model .meta or {}), "unsupported_derived_table" : True , "lookml_template" : True }
228+
99229 # Rebuild adjacency graph now that relationships have been added
100230 graph .build_adjacency ()
101231
@@ -1103,6 +1233,14 @@ def _folded_measure_filter(m_def):
11031233 elif isinstance (extends_list , str ):
11041234 extends = extends_list
11051235
1236+ # A LookML view with no sql_table_name/derived_table implicitly uses a table
1237+ # named after the view (Looker's default). A view with a derived_table the
1238+ # adapter cannot turn into SQL must NOT get such a default; mark it so the
1239+ # post-merge pass skips it (the raw derived_table dict is not retained).
1240+ unsupported_derived_table = bool (view_def .get ("derived_table" )) and sql is None
1241+ if unsupported_derived_table :
1242+ model_meta ["unsupported_derived_table" ] = True
1243+
11061244 # Build kwargs conditionally so that unset scalars don't appear in
11071245 # model_fields_set. This matters for refinements: merge_model treats
11081246 # every field in model_fields_set as an explicit child override, so
0 commit comments