2222from fastapi import APIRouter , WebSocket
2323from perspective import Client , Server , Table
2424from perspective .handlers .starlette import PerspectiveStarletteHandler
25- from pydantic import Field , PrivateAttr , field_validator
25+ from pydantic import BaseModel , Field , PrivateAttr , field_validator , model_validator
2626from starlette .websockets import WebSocketDisconnect
2727from typing_extensions import TypeAliasType
2828
3333__all__ = (
3434 "psp_schema_to_arrow_schema" ,
3535 "create_pyarrow_table" ,
36+ "TableConfig" ,
3637 "MountPerspectiveTables" ,
3738)
3839
@@ -141,23 +142,74 @@ def pull_data_thread(
141142]
142143
143144
145+ class TableConfig (BaseModel ):
146+ """Configuration for a perspective table. When channel is omitted, defaults to using the table name as the channel."""
147+
148+ channel : Optional [str ] = Field (None , description = "The source channel name. Defaults to the table name if omitted." )
149+ limit : Optional [int ] = Field (None , description = "Row limit for this table." )
150+ index : Optional [Union [str , List [str ]]] = Field (None , description = "Index field(s) for this table." )
151+ architecture : Literal ["server" , "client-server" ] = Field ("client-server" , description = "Perspective data architecture for this table." )
152+ excluded_columns : Optional [ExcludedColumns ] = Field (None , description = "Columns to exclude from the schema." )
153+
154+
155+ # Backwards compat alias
156+ AdditionalTableConfig = TableConfig
157+
158+
159+ def _is_channel_selection_input (v ) -> bool :
160+ """Detect whether a raw input value looks like a ChannelSelection (old-style tables field)."""
161+ if v is None or isinstance (v , list ):
162+ return True
163+ if isinstance (v , ChannelSelection ):
164+ return True
165+ if isinstance (v , dict ):
166+ keys = set (v .keys ())
167+ # Empty dict is ambiguous — treat as empty TableConfig dict (new-style)
168+ if len (keys ) == 0 :
169+ return False
170+ if keys <= {"include" , "exclude" }:
171+ # Verify values aren't TableConfig-like dicts (handles edge case of channel named "include"/"exclude")
172+ for val in v .values ():
173+ if isinstance (val , (dict , TableConfig )):
174+ return False
175+ return True
176+ return False
177+
178+
144179class MountPerspectiveTables (GatewayModule ):
145180 requires : Optional [ChannelSelection ] = []
146181
147- tables : ChannelSelection = Field (default_factory = ChannelSelection )
182+ tables : Dict [str , TableConfig ] = Field (
183+ default = {},
184+ description = (
185+ "Dictionary mapping table name to a TableConfig. Each entry configures a perspective table. "
186+ "If 'channel' is omitted in the config, the table name is used as the channel name. "
187+ "If 'channel' is set to a different name, an additional table is created mirroring that channel's data. "
188+ "Tables entries whose channel matches their name override per-table settings from legacy fields "
189+ "(limits, indexes, etc.). For backwards compatibility, this field also accepts a ChannelSelection "
190+ "(list or dict with include/exclude keys) which will be moved to 'channel_selection'."
191+ ),
192+ )
193+ channel_selection : ChannelSelection = Field (
194+ default_factory = ChannelSelection ,
195+ description = "Controls which channels are auto-discovered as perspective tables. "
196+ "Channels not explicitly listed in 'tables' will use defaults. "
197+ "This is the successor to the old 'tables' field when it was a ChannelSelection." ,
198+ )
148199 _unused_tables : Optional [List [str ]] = PrivateAttr (default_factory = list )
149200
150201 server_views : Optional [Dict [str , ViewConfig ]] = Field (
151202 default_factory = dict , description = "Optional dict mapping new table name to a dict with table and view information"
152203 )
153204
205+ # Legacy per-table config fields (still functional; tables entries take precedence)
154206 limits : Dict [str , int ] = Field (
155207 description = "Dict mapping table name to [perspective limit](https://perspective-dev.github.io/guide/explanation/table/options.html)" ,
156208 default = {},
157209 )
158210 default_limit : Optional [int ] = Field (None , description = "Default limit for all tables, i.e. 1000" )
159211 indexes : Dict [str , Optional [Union [str , List [str ]]]] = Field (
160- description = "Dict mapping table name to [perspective index](https://perspective-dev.github.io/guide/explanation/table/options.html). . If a multi-index is provided, will create a new computed index field." ,
212+ description = "Dict mapping table name to [perspective index](https://perspective-dev.github.io/guide/explanation/table/options.html). If a multi-index is provided, will create a new computed index field." ,
161213 default = {},
162214 )
163215 default_index : Optional [Union [str , List [str ]]] = Field (
@@ -171,6 +223,15 @@ class MountPerspectiveTables(GatewayModule):
171223 "client-server" ,
172224 description = "Default architecture for all tables, i.e. 'client-server'" ,
173225 )
226+ excluded_table_columns : Dict [str , ExcludedColumns ] = Field (
227+ default = {},
228+ description = (
229+ "Dictionary from table name to columns (which are attributes on a GatewayStruct) to exclude from perspective. "
230+ "The columns to exclude can be specified as either be a set of column names or as a dictionary. If specified "
231+ "as a dictionary, the dictionary is a mapping from attribute name to sub-attributes to exclude. This is defined "
232+ "recursively so it can be used to exclude fields that are structs of structs."
233+ ),
234+ )
174235
175236 layouts : Dict [str , str ] = Field (default = {})
176237 default_layout : Optional [str ] = Field (
@@ -189,15 +250,28 @@ class MountPerspectiveTables(GatewayModule):
189250 description = "Optional field on the channels which has a dictionary of layouts to use, "
190251 "such that it can also be used by other GatewayModules with custom table preparation logic." ,
191252 )
192- excluded_table_columns : Dict [str , ExcludedColumns ] = Field (
193- default = {},
194- description = (
195- "Dictionary from table name to columns (which are attributes on a GatewayStruct) to exclude from perspective. "
196- "The columns to exclude can be specified as either be a set of column names or as a dictionary. If specified "
197- "as a dictionary, the dictionary is a mapping from attribute name to sub-attributes to exclude. This is defined "
198- "recursively so it can be used to exclude fields that are structs of structs."
199- ),
200- )
253+
254+ @model_validator (mode = "before" )
255+ @classmethod
256+ def _normalize_tables_input (cls , data ):
257+ if not isinstance (data , dict ):
258+ return data
259+
260+ tables_input = data .get ("tables" )
261+ if _is_channel_selection_input (tables_input ):
262+ # Old style: `tables` was a ChannelSelection — move to channel_selection
263+ # Only do this if channel_selection is not already explicitly provided
264+ if tables_input is not None and "channel_selection" not in data :
265+ data ["channel_selection" ] = tables_input
266+ data .pop ("tables" , None )
267+
268+ # Merge deprecated additional_tables into tables
269+ additional = data .pop ("additional_tables" , None )
270+ if additional :
271+ tables = data .setdefault ("tables" , {})
272+ tables .update (additional )
273+
274+ return data
201275
202276 _route : str = "/perspective"
203277 _server : Server = PrivateAttr (default = None )
@@ -224,11 +298,37 @@ def _validate_server_views(cls, v: Dict[str, ViewConfig]) -> Dict[str, ViewConfi
224298 raise ValueError (f"View config for { new_table_name } must specify at least one view operation in addition to the base 'table'." )
225299 return v
226300
301+ def _get_effective_config (self , table_name : str ) -> TableConfig :
302+ """Get effective config for a table, merging tables dict entry with legacy fields and defaults."""
303+ config = self .tables .get (table_name )
304+ if config :
305+ return TableConfig (
306+ channel = config .channel ,
307+ limit = config .limit if config .limit is not None else self .limits .get (table_name , self .default_limit ),
308+ index = config .index if config .index is not None else self .indexes .get (table_name , self .default_index ),
309+ architecture = config .architecture or self .architectures .get (table_name , self .default_architecture ),
310+ excluded_columns = config .excluded_columns if config .excluded_columns is not None else self .excluded_table_columns .get (table_name ),
311+ )
312+ return TableConfig (
313+ limit = self .limits .get (table_name , self .default_limit ),
314+ index = self .indexes .get (table_name , self .default_index ),
315+ architecture = self .architectures .get (table_name , self .default_architecture ),
316+ excluded_columns = self .excluded_table_columns .get (table_name ),
317+ )
318+
227319 def _connect_all_tables (self , channels : GatewayChannels ) -> None :
228- selected_tables = set (self .tables .select_from (channels )) | set (_ ["table" ] for _ in self .server_views .values ())
229- for field in selected_tables :
320+ # Determine primary channels from channel_selection + server_views + tables entries without explicit channel
321+ selected_channels = set (self .channel_selection .select_from (channels )) | set (_ ["table" ] for _ in self .server_views .values ())
322+ for table_name , config in self .tables .items ():
323+ channel = config .channel or table_name
324+ if channel == table_name :
325+ selected_channels .add (table_name )
326+
327+ # Register primary tables (table_name == channel_name)
328+ for field in selected_channels :
329+ config = self ._get_effective_config (field )
230330 edge = channels .get_channel (field )
231- excluded_columns = self . excluded_table_columns . get ( field , None )
331+ excluded_columns = config . excluded_columns
232332 if isinstance (edge , dict ):
233333 if not edge :
234334 raise ValueError (f"No keys defined for dict basket channel { field } ." )
@@ -244,8 +344,8 @@ def _connect_all_tables(self, channels: GatewayChannels) -> None:
244344 self .add_table (
245345 field ,
246346 schema ,
247- limit = self . limits . get ( field , self . default_limit ) ,
248- index = self . indexes . get ( field , self . default_index ) ,
347+ limit = config . limit ,
348+ index = config . index ,
249349 )
250350
251351 if hasattr (subfield , "name" ):
@@ -265,8 +365,8 @@ def _connect_all_tables(self, channels: GatewayChannels) -> None:
265365 self .add_table (
266366 field ,
267367 schema ,
268- limit = self . limits . get ( field , self . default_limit ) ,
269- index = self . indexes . get ( field , self . default_index ) ,
368+ limit = config . limit ,
369+ index = config . index ,
270370 )
271371 self .push_to_perspective (
272372 edge ,
@@ -286,6 +386,64 @@ def _connect_all_tables(self, channels: GatewayChannels) -> None:
286386 view = base_table .view (** view_config )
287387 self ._table_insts [new_table_name ] = self ._client .table (view , name = new_table_name )
288388
389+ # Register additional tables (tables entries where channel differs from table_name)
390+ for table_name , table_config in self .tables .items ():
391+ channel = table_config .channel or table_name
392+ if channel == table_name :
393+ continue # Already handled as a primary table above
394+
395+ if channel not in self ._schema_insts :
396+ raise ValueError (
397+ f"Table '{ table_name } ' references channel '{ channel } ' which is not a registered table. "
398+ f"Ensure '{ channel } ' is included in the channel selection or as a primary table."
399+ )
400+ config = self ._get_effective_config (table_name )
401+ excluded_columns = config .excluded_columns
402+ if excluded_columns is not None :
403+ # Recompute schema with different exclusions
404+ edge = channels .get_channel (channel )
405+ if isinstance (edge , dict ):
406+ a_subfield = list (edge .keys ())[0 ]
407+ ts_type = channels .get_channel (channel , a_subfield ).tstype .typ
408+ else :
409+ ts_type = edge .tstype .typ
410+ if get_origin (ts_type ) is list :
411+ ts_type = get_args (ts_type )[0 ]
412+ schema = ts_type .psp_schema (excluded_columns )
413+ else :
414+ schema = self ._schema_insts [channel ].copy ()
415+
416+ self .add_table (
417+ table_name ,
418+ schema ,
419+ limit = config .limit ,
420+ index = config .index ,
421+ )
422+ self ._schema_insts [table_name ] = schema
423+ self ._arrow_schema_insts [table_name ] = psp_schema_to_arrow_schema (schema )
424+ self ._arrow_schema_date_conversions [table_name ] = set ()
425+ for k , v in schema .items ():
426+ if v is date :
427+ self ._arrow_schema_date_conversions [table_name ].add (k )
428+
429+ # Wire up data flow: push same channel data to the additional table
430+ edge = channels .get_channel (channel )
431+ if isinstance (edge , dict ):
432+ to_flatten = []
433+ for subfield in edge .keys ():
434+ to_flatten .append (channels .get_channel (channel , subfield ))
435+ if hasattr (subfield , "name" ):
436+ self .push_to_perspective (csp .flatten (to_flatten ), table_name , subfield .name )
437+ else :
438+ self .push_to_perspective (csp .flatten (to_flatten ), table_name , subfield )
439+ else :
440+ ts_type = channels .get_channel (channel ).tstype .typ
441+ if get_origin (ts_type ) is list :
442+ edge = csp .unroll (channels .get_channel (channel ))
443+ else :
444+ edge = channels .get_channel (channel )
445+ self .push_to_perspective (edge , table_name )
446+
289447 def get_schema_from_field (self , channels : GatewayChannels , field : str ):
290448 edge = channels .get_channel (field )
291449
@@ -451,11 +609,15 @@ async def get_perspective_meta():
451609 indexes, and architecture.
452610 """
453611 return {
454- "limits" : self .limits ,
612+ "limits" : { ** self .limits , ** { k : v . limit for k , v in self . tables . items () if v . limit is not None }} ,
455613 "default_limit" : self .default_limit ,
456- "indexes" : {** self .indexes , ** {k : v [0 ] for k , v in self ._computed_indexes .items ()}},
614+ "indexes" : {
615+ ** self .indexes ,
616+ ** {k : v .index for k , v in self .tables .items () if v .index is not None },
617+ ** {k : v [0 ] for k , v in self ._computed_indexes .items ()},
618+ },
457619 "default_index" : self .default_index ,
458- "architectures" : self .architectures ,
620+ "architectures" : { ** self .architectures , ** { k : v . architecture for k , v in self . tables . items ()}} ,
459621 "default_architecture" : self .default_architecture ,
460622 "layouts" : self .layouts ,
461623 "default_layout" : self .default_layout ,
0 commit comments