Repository: PostHog/posthog
Pull request #31189: feat(hogql): Introduce table groups and more nested structure for tables
Author: login: Gilbert09
Created: 2025-04-14T14:22:47Z | Merged: 2025-04-15T08:07:05Z

## Problem
- Since adding in https://github.com/PostHog/posthog/pull/30947, we have a mix of how we denote table names:
  - A single element chain: `["postgres.prefix.table_name"]`
  - Multi element chain: `["postgres", "prefix", "table_name"]`
- The latter, the multi element, is the correct way to represent table names, but we're not using this in most places
- Because of this, we have mixed support all over the product, and so the likes of Trends breakdowns in product analytics now errors out with dot notated tables
  - https://posthog.slack.com/archives/C019RAX2XBN/p1744409673018359

## Changes
- Add greater support for the correct table chain all over the hogql database
  - Adding chain-first support, and backwards compaitability for areas of the product that still use single element chains to reference tables
- Updated warehouse joins to disallow periods in field keys (this caused a whole 'nother headache that we will need to sort out at some point)
- Introduces a `TableGroup` concept
  - This is a `FieldOrTable` model that holds a dict of other tables
  - This can be nested as deeply as we want, and so things like `stripe.prefix1.prefix2.prefix3.table_name` will resolve to the correct `S3Table` model
  - This progresses us along the direction that all table names _should_ be a string chain
    - Mainly, we can recursively search for tables here

## Does this work well for both Cloud and self-hosted?
Yes

## How did you test this code?
Tested:
- Querying a warehouse table
- Querying a join in a warehouse table
- Breakdown and filtering on trends using extended person properties
- Added a warehouse series in trends
- Filter a warehouse series in trends


Diff:
diff --git a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png
index 87245b940811..c12d7497f289 100644
Binary files a/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png and b/frontend/__snapshots__/scenes-app-pipeline--pipeline-node-new-hog-function--light.png differ
diff --git a/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx b/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx
index 218310d92a4f..5b62fc7314e4 100644
--- a/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx
+++ b/frontend/src/scenes/data-warehouse/viewLinkLogic.tsx
@@ -98,7 +98,7 @@ export const viewLinkLogic = kea<viewLinkLogicType>([
             '' as string,
             {
                 setFieldName: (_, { fieldName }) => fieldName,
-                selectJoiningTable: (_, { selectedTableName }) => selectedTableName,
+                selectJoiningTable: (_, { selectedTableName }) => selectedTableName.replaceAll('.', '_'),
                 toggleEditJoinModal: (_, { join }) => join.field_name ?? '',
                 clearModalFields: () => '',
             },
diff --git a/mypy-baseline.txt b/mypy-baseline.txt
index 21162ca4b99c..13ddc7f128af 100644
--- a/mypy-baseline.txt
+++ b/mypy-baseline.txt
@@ -135,7 +135,7 @@ posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression
 posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression has type "LazyTableType", variable has type "TableAliasType")  [assignment]
 posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression has type "TableType", variable has type "LazyTableType")  [assignment]
 posthog/hogql/resolver.py:0: error: Incompatible types in assignment (expression has type "Type | Any | None", variable has type "BaseTableType | SelectSetQueryType | SelectQueryType | SelectQueryAliasType | SelectViewType | None")  [assignment]
-posthog/hogql/resolver.py:0: error: Item "None" of "Database | None" has no attribute "get_table"  [union-attr]
+posthog/hogql/resolver.py:0: error: Item "None" of "Database | None" has no attribute "get_table_by_chain"  [union-attr]
 posthog/hogql/resolver.py:0: error: Item "None" of "JoinExpr | None" has no attribute "join_type"  [union-attr]
 posthog/hogql/resolver.py:0: error: Item "None" of "SelectQuery | SelectSetQuery | Placeholder | HogQLXTag | Field | None" has no attribute "type"  [union-attr]
 posthog/hogql/resolver.py:0: error: Item "None" of "SelectQuery | SelectSetQuery | Placeholder | HogQLXTag | Field | None" has no attribute "type"  [union-attr]
diff --git a/posthog/hogql/autocomplete.py b/posthog/hogql/autocomplete.py
index 12985250ed7b..a2491c4f23a1 100644
--- a/posthog/hogql/autocomplete.py
+++ b/posthog/hogql/autocomplete.py
@@ -198,8 +198,9 @@ def to_printed_hogql(self):
                         return resolve_fields_on_table(table, query)
 
         # Handle a base table
-        if context.database.has_table(table_name):
-            return context.database.get_table(table_name)
+        table_chain = [str(e) for e in join_expr.table.chain]
+        if context.database.has_table(table_chain):
+            return context.database.get_table_by_chain(table_chain)
     elif isinstance(join_expr.table, ast.SelectQuery):
         if join_expr.table.select_from is None:
             return None
diff --git a/posthog/hogql/database/database.py b/posthog/hogql/database/database.py
index 7fc1fc332341..c115cdf4ccd5 100644
--- a/posthog/hogql/database/database.py
+++ b/posthog/hogql/database/database.py
@@ -26,6 +26,7 @@
     StringDatabaseField,
     StringJSONDatabaseField,
     Table,
+    TableGroup,
     VirtualTable,
 )
 from posthog.hogql.database.schema.app_metrics2 import AppMetrics2Table
@@ -177,14 +178,58 @@ def get_timezone(self) -> str:
     def get_week_start_day(self) -> WeekStartDay:
         return self._week_start_day or WeekStartDay.SUNDAY
 
-    def has_table(self, table_name: str) -> bool:
+    def has_table(self, table_name: str | list[str]) -> bool:
+        if isinstance(table_name, list) or "." in table_name:
+            if isinstance(table_name, list):
+                # Handling trends data warehouse nodes
+                if len(table_name) == 1 and "." in table_name[0]:
+                    table_chain = table_name[0].split(".")
+                else:
+                    table_chain = table_name
+            else:
+                table_chain = table_name.split(".")
+            if not hasattr(self, table_chain[0]):
+                return False
+
+            try:
+                return self.get_table_by_chain(table_chain) is not None
+            except QueryError:
+                return False
+
         return hasattr(self, table_name)
 
     def get_table(self, table_name: str) -> Table:
+        if "." in table_name:
+            return self.get_table_by_chain(table_name.split("."))
+
         if self.has_table(table_name):
             return getattr(self, table_name)
+
         raise QueryError(f'Unknown table "{table_name}".')
 
+    def get_table_by_chain(self, table_chain: list[str]) -> Table:
+        # Handling trends data warehouse nodes
+        if len(table_chain) == 1 and "." in table_chain[0]:
+            table_chain = table_chain[0].split(".")
+
+        if not self.has_table(table_chain[0]):
+            raise QueryError(f'Unknown table "{".".join(table_chain)}".')
+
+        database_table = getattr(self, table_chain[0])
+
+        if isinstance(database_table, TableGroup) and len(table_chain) > 1:
+            for ele in table_chain[1:]:
+                if isinstance(database_table, TableGroup):
+                    if not database_table.has_table(ele):
+                        raise QueryError(f'Unknown table "{".".join(table_chain)}".')
+
+                    database_table = database_table.get_table(ele)
+
+        if not database_table or not isinstance(database_table, Table):
+            raise QueryError(f'Unknown table "{".".join(table_chain)}".')
+
+        return database_table
+
     def get_all_tables(self) -> list[str]:
         warehouse_table_names = list(filter(lambda x: "." in x, self._warehouse_table_names))
 
@@ -207,7 +252,11 @@ def get_views(self) -> list[str]:
     def add_warehouse_tables(self, **field_definitions: Any):
         for f_name, f_def in field_definitions.items():
             setattr(self, f_name, f_def)
-            self._warehouse_table_names.append(f_name)
+
+            if isinstance(f_def, Table):
+                self._warehouse_table_names.append(f_name)
+            elif isinstance(f_def, TableGroup):
+                self._warehouse_table_names.extend([f"{f_name}.{x}" for x in f_def.resolve_all_table_names()])
 
     def add_warehouse_self_managed_tables(self, **field_definitions: Any):
         for f_name, f_def in field_definitions.items():
@@ -367,10 +416,10 @@ def create_hogql_database(
             if database.events.fields.get(mapping.group_type) is None:
                 database.events.fields[mapping.group_type] = FieldTraverser(chain=[f"group_{mapping.group_type_index}"])
 
-    warehouse_tables: dict[str, Table] = {}
+    warehouse_tables: dict[str, Table | TableGroup] = {}
     warehouse_tables_dot_notation_mapping: dict[str, str] = {}
-    self_managed_warehouse_tables: dict[str, Table] = {}
-    views: dict[str, Table] = {}
+    self_managed_warehouse_tables: dict[str, Table | TableGroup] = {}
+    views: dict[str, Table | TableGroup] = {}
 
     with timings.measure("data_warehouse_saved_query"):
         with timings.measure("select"):
@@ -419,15 +468,40 @@ def to_printed_clickhouse(self, context):
                 if table.external_data_source:
                     source_type = table.external_data_source.source_type
                     prefix = table.external_data_source.prefix
+                    table_chain: list[str] = [source_type.lower()]
+
                     if prefix is not None and isinstance(prefix, str) and prefix != "":
                         table_name_stripped = table.name.replace(f"{prefix}{source_type}_".lower(), "")
-                        dot_name = f"{source_type}.{prefix.strip('_')}.{table_name_stripped}".lower()
+                        table_chain.extend([prefix.strip("_").lower(), table_name_stripped])
                     else:
                         table_name_stripped = table.name.replace(f"{source_type}_".lower(), "")
-                        dot_name = f"{source_type}.{table_name_stripped}".lower()
+                        table_chain.append(table_name_stripped)
+
+                    last_group: TableGroup | None = None
+                    for index, ele in enumerate(table_chain):
+                        is_last_element = index == len(table_chain) - 1
+                        if last_group:
+                            if is_last_element:
+                                last_group.tables[ele] = s3_table
+                            elif last_group.has_table(ele):
+                                last_group_table = last_group.get_table(ele)
+                                assert isinstance(last_group_table, TableGroup)
+                                last_group = last_group_table
+                            else:
+                                new_group = TableGroup()
+                                last_group.tables[ele] = new_group
+                                last_group = new_group
+                        elif warehouse_tables.get(ele) is not None:
+                            parent_wh_table = warehouse_tables[ele]
+                            if isinstance(parent_wh_table, TableGroup):
+                                last_group = parent_wh_table
+                        else:
+                            new_group = TableGroup()
+                            warehouse_tables[ele] = new_group
+                            last_group = new_group
 
-                    warehouse_tables[dot_name] = s3_table
-                    warehouse_tables_dot_notation_mapping[dot_name] = table.name
+                    joined_table_chain = ".".join(table_chain)
+                    warehouse_tables_dot_notation_mapping[joined_table_chain] = table.name
 
     # For every Stripe source, let's generate its own revenue view
     # Prefetch related schemas and tables to avoid N+1
@@ -445,42 +519,63 @@ def to_printed_clickhouse(self, context):
                 if view is not None:
                     views[view.name] = view
 
-    def define_mappings(store: dict[str, Table], get_table: Callable):
-        if warehouse_modifier.table_name not in store:
+    def define_mappings(store: dict[str, Table | TableGroup], get_table: Callable):
+        table: Table | None = None
+
+        if warehouse_modifier.table_name in store:
+            _table = store[warehouse_modifier.table_name]
+            assert isinstance(_table, Table)
+            table = _table
+
+        if "." in warehouse_modifier.table_name:
+            table_chain = warehouse_modifier.table_name.split(".")
+            _table_or_group: Table | TableGroup | None = store.get(table_chain[0])
+            if _table_or_group is None:
+                return store
+
+            for ele in table_chain[1:]:
+                if isinstance(_table_or_group, Table):
+                    table = _table_or_group
+                elif isinstance(_table_or_group, TableGroup):
+                    _table_or_group = _table_or_group.tables.get(ele)
+                    if isinstance(_table_or_group, Table):
+                        table = _table_or_group
+
+        if table is None:
             return store
 
-        if "id" not in store[warehouse_modifier.table_name].fields.keys():
-            store[warehouse_modifier.table_name].fields["id"] = ExpressionField(
+        if "id" not in table.fields.keys():
+            table.fields["id"] = ExpressionField(
                 name="id",
                 expr=parse_expr(warehouse_modifier.id_field),
             )
 
-        if "timestamp" not in store[warehouse_modifier.table_name].fields.keys() or not isinstance(
-            store[warehouse_modifier.table_name].fields.get("timestamp"), DateTimeDatabaseField
+        if "timestamp" not in table.fields.keys() or not isinstance(
+            table.fields.get("timestamp"), DateTimeDatabaseField
         ):
             table_model = get_table(team=team, warehouse_modifier=warehouse_modifier)
             timestamp_field_type = table_model.get_clickhouse_column_type(warehouse_modifier.timestamp_field)
 
             # If field type is none or datetime, we can use the field directly
             if timestamp_field_type is None or timestamp_field_type.startswith("DateTime"):
-                store[warehouse_modifier.table_name].fields["timestamp"] = ExpressionField(
+                table.fields["timestamp"] = ExpressionField(
                     name="timestamp",
                     expr=ast.Field(chain=[warehouse_modifier.timestamp_field]),
                 )
             else:
-                store[warehouse_modifier.table_name].fields["timestamp"] = ExpressionField(
+                table.fields["timestamp"] = ExpressionField(
                     name="timestamp",
                     expr=ast.Call(name="toDateTime", args=[ast.Field(chain=[warehouse_modifier.timestamp_field])]),
                 )
 
         # TODO: Need to decide how the distinct_id and person_id fields are going to be handled
-        if "distinct_id" not in store[warehouse_modifier.table_name].fields.keys():
-            store[warehouse_modifier.table_name].fields["distinct_id"] = ExpressionField(
+        if "distinct_id" not in table.fields.keys():
+            table.fields["distinct_id"] = ExpressionField(
                 name="distinct_id",
                 expr=parse_expr(warehouse_modifier.distinct_id_field),
             )
 
-        if "person_id" not in store[warehouse_modifier.table_name].fields.keys():
+        if "person_id" not in table.fields.keys():
             events_join = (
                 DataWarehouseJoin.objects.filter(
                     team_id=team.pk,
@@ -491,11 +586,9 @@ def define_mappings(store: dict[str, Table], get_table: Callable):
                 .first()
             )
             if events_join:
-                store[warehouse_modifier.table_name].fields["person_id"] = FieldTraverser(
-                    chain=[events_join.field_name, "person_id"]
-                )
+                table.fields["person_id"] = FieldTraverser(chain=[events_join.field_name, "person_id"])
             else:
-                store[warehouse_modifier.table_name].fields["person_id"] = ExpressionField(
+                table.fields["person_id"] = ExpressionField(
                     name="person_id",
                     expr=parse_expr(warehouse_modifier.distinct_id_field),
                 )
@@ -673,7 +766,7 @@ def serialize_database(
         elif isinstance(table, Table):
             field_input = table.fields
 
-        fields = serialize_fields(field_input, context, table_key, table_type="posthog")
+        fields = serialize_fields(field_input, context, table_key.split("."), table_type="posthog")
         fields_dict = {field.name: field for field in fields}
         tables[table_key] = DatabaseSchemaPostHogTable(fields=fields_dict, id=table_key, name=table_key)
 
@@ -750,11 +843,13 @@ def serialize_database(
             table_key = warehouse_table.name
 
         field_input = {}
-        table = getattr(context.database, table_key, None)
+        table = context.database.get_table(table_key)
         if isinstance(table, Table):
             field_input = table.fields
 
-        fields = serialize_fields(field_input, context, table_key, warehouse_table.columns, table_type="external")
+        fields = serialize_fields(
+            field_input, context, table_key.split("."), warehouse_table.columns, table_type="external"
+        )
         fields_dict = {field.name: field for field in fields}
 
         tables[table_key] = DatabaseSchemaDataWarehouseTable(
@@ -779,7 +874,7 @@ def serialize_database(
         if view is None:
             continue
 
-        fields = serialize_fields(view.fields, context, view_name, table_type="external")
+        fields = serialize_fields(view.fields, context, view_name.split("."), table_type="external")
         fields_dict = {field.name: field for field in fields}
 
         if isinstance(view, RevenueAnalyticsRevenueView):
@@ -834,7 +929,7 @@ def constant_type_to_serialized_field_type(constant_type: ast.ConstantType) -> D
 def serialize_fields(
     field_input,
     context: HogQLContext,
-    table_name: str,
+    table_chain: list[str],
     db_columns: Optional[DataWarehouseTableColumns] = None,
     table_type: Literal["posthog"] | Literal["external"] = "posthog",
 ) -> list[DatabaseSchemaField]:
@@ -941,7 +1036,7 @@ def serialize_fields(
                     )
                 )
             elif isinstance(field, ExpressionField):
-                field_expr = resolve_types_from_table(field.expr, table_name, context, "hogql")
+                field_expr = resolve_types_from_table(field.expr, table_chain, context, "hogql")
                 assert field_expr.type is not None
                 constant_type = field_expr.type.resolve_constant_type(context)
 
diff --git a/posthog/hogql/database/models.py b/posthog/hogql/database/models.py
index b0e87594737a..f1bd68f1c542 100644
--- a/posthog/hogql/database/models.py
+++ b/posthog/hogql/database/models.py
@@ -161,6 +161,33 @@ def get_asterisk(self):
         return asterisk
 
 
+class TableGroup(FieldOrTable):
+    tables: dict[str, "Table | TableGroup"] = field(default_factory=dict)
+
+    def has_table(self, name: str) -> bool:
+        return name in self.tables
+
+    def get_table(self, name: str) -> "Table | TableGroup":
+        return self.tables[name]
+
+    def to_printed_clickhouse(self, context: "HogQLContext") -> str:
+        raise NotImplementedError("TableGroup.to_printed_clickhouse not overridden")
+
+    def to_printed_hogql(self) -> str:
+        raise NotImplementedError("TableGroup.to_printed_hogql not overridden")
+
+    def resolve_all_table_names(self) -> list[str]:
+        names: list[str] = []
+        for name, table in self.tables.items():
+            if isinstance(table, Table):
+                names.append(name)
+            elif isinstance(table, TableGroup):
+                child_names = table.resolve_all_table_names()
+                names.extend([f"{name}.{x}" for x in child_names])
+
+        return names
+
+
 class LazyJoin(FieldOrTable):
     model_config = ConfigDict(extra="forbid")
 
diff --git a/posthog/hogql/database/test/test_database.py b/posthog/hogql/database/test/test_database.py
index 884a97107565..98265842f634 100644
--- a/posthog/hogql/database/test/test_database.py
+++ b/posthog/hogql/database/test/test_database.py
@@ -8,7 +8,13 @@
 
 from posthog.hogql.constants import MAX_SELECT_RETURNED_ROWS
 from posthog.hogql.database.database import create_hogql_database, serialize_database
-from posthog.hogql.database.models import FieldTraverser, LazyJoin, StringDatabaseField, ExpressionField, Table
+from posthog.hogql.database.models import (
+    FieldTraverser,
+    LazyJoin,
+    StringDatabaseField,
+    ExpressionField,
+    Table,
+)
 from posthog.hogql.errors import ExposedHogQLError
 from posthog.hogql.modifiers import create_default_modifiers_for_team
 from posthog.hogql.parser import parse_expr, parse_select
@@ -771,6 +777,9 @@ def test_data_warehouse_events_modifiers_with_dot_notation(self):
 
         db = create_hogql_database(team=self.team, modifiers=modifiers)
 
+        stripe_table = db.get_table("stripe.table")
+        assert isinstance(stripe_table, Table)
+
         # Ensure the correct table was retrieved by checking the original table name in dot notation mapping
         context = HogQLContext(
             team_id=self.team.pk,
diff --git a/posthog/hogql/resolver.py b/posthog/hogql/resolver.py
index ad236e3b0492..70c4d24652fe 100644
--- a/posthog/hogql/resolver.py
+++ b/posthog/hogql/resolver.py
@@ -70,16 +70,17 @@ def resolve_constant_data_type(constant: Any) -> ConstantType:
 
 
 def resolve_types_from_table(
-    expr: ast.Expr, table_name: str, context: HogQLContext, dialect: Literal["hogql", "clickhouse"]
+    expr: ast.Expr, table_chain: list[str], context: HogQLContext, dialect: Literal["hogql", "clickhouse"]
 ) -> ast.Expr:
     if context.database is None:
         raise QueryError("Database needs to be defined")
 
-    if not context.database.has_table(table_name):
-        raise QueryError(f'Table "{table_name}" does not exist')
+    if not context.database.has_table(table_chain):
+        raise QueryError(f'Table "{".".join(table_chain)}" does not exist')
 
     select_node = ast.SelectQuery(
-        select=[ast.Field(chain=["*"])], select_from=ast.JoinExpr(table=ast.Field(chain=[table_name]))
+        select=[ast.Field(chain=["*"])],
+        select_from=ast.JoinExpr(table=ast.Field(chain=cast(list[str | int], table_chain))),
     )
     select_node_with_types = cast(ast.SelectQuery, resolve_types(select_node, context, dialect))
     assert select_node_with_types.type is not None
@@ -316,13 +317,12 @@ def visit_join_expr(self, node: ast.JoinExpr):
 
         if isinstance(node.table, ast.Field):
             table_name_chain = [str(n) for n in node.table.chain]
-            table_name_dot_notation = ".".join(table_name_chain)
             table_name_alias = "__".join(table_name_chain)
             table_alias: str = node.alias or table_name_alias
             if table_alias in scope.tables:
                 raise QueryError(f'Already have joined a table called "{table_alias}". Can\'t redefine.')
 
-            database_table = self.database.get_table(table_name_dot_notation)
+            database_table = self.database.get_table_by_chain(table_name_chain)
 
             if isinstance(database_table, SavedQuery):
                 self.current_view_depth += 1
diff --git a/posthog/hogql/test/test_resolver.py b/posthog/hogql/test/test_resolver.py
index b76d7692c634..54a471f7eb3a 100644
--- a/posthog/hogql/test/test_resolver.py
+++ b/posthog/hogql/test/test_resolver.py
@@ -17,6 +17,7 @@
     FieldTraverser,
     StringDatabaseField,
     StringJSONDatabaseField,
+    TableGroup,
 )
 from posthog.hogql.database.schema.events import EventsTable
 from posthog.hogql.errors import QueryError
@@ -719,11 +720,20 @@ def test_property_access_with_tuples_zero_index_error(self):
             resolve_types(node, context, dialect="clickhouse")
 
     def test_nested_table_name(self):
-        self.database.__setattr__("nested.events", EventsTable())
+        table_group = TableGroup(tables={"events": EventsTable()})
+        self.database.__setattr__("nested", table_group)
         query = "SELECT * FROM nested.events"
         resolve_types(self._select(query), self.context, dialect="hogql")
 
     def test_deeply_nested_table_name(self):
-        self.database.__setattr__("nested.events.some.other.table", EventsTable())
+        table_group = TableGroup(
+            tables={
+                "events": TableGroup(
+                    tables={"some": TableGroup(tables={"other": TableGroup(tables={"table": EventsTable()})})}
+                )
+            }
+        )
+
+        self.database.__setattr__("nested", table_group)
         query = "SELECT * FROM nested.events.some.other.table"
         resolve_types(self._select(query), self.context, dialect="hogql")
diff --git a/posthog/warehouse/api/saved_query.py b/posthog/warehouse/api/saved_query.py
index 3d4893894a5d..3ad364bbbb9f 100644
--- a/posthog/warehouse/api/saved_query.py
+++ b/posthog/warehouse/api/saved_query.py
@@ -70,7 +70,7 @@ def get_columns(self, view: DataWarehouseSavedQuery) -> list[SerializedField]:
 
         context = HogQLContext(team_id=team_id, database=database)
 
-        fields = serialize_fields(view.hogql_definition().fields, context, view.name, table_type="external")
+        fields = serialize_fields(view.hogql_definition().fields, context, view.name_chain, table_type="external")
         return [
             SerializedField(
                 key=field.name,
diff --git a/posthog/warehouse/api/table.py b/posthog/warehouse/api/table.py
index 59a879bc48c6..e396fe92f4c3 100644
--- a/posthog/warehouse/api/table.py
+++ b/posthog/warehouse/api/table.py
@@ -68,7 +68,7 @@ def get_columns(self, table: DataWarehouseTable) -> list[SerializedField]:
         serializes_fields = serialize_fields(
             fields,
             HogQLContext(database=database, team_id=self.context["team_id"]),
-            table.name,
+            table.name_chain,
             table.columns,
             table_type="external",
         )
@@ -140,7 +140,7 @@ def get_columns(self, table: DataWarehouseTable) -> list[SerializedField]:
         fields = serialize_fields(
             table.hogql_definition().fields,
             HogQLContext(database=database, team_id=team_id),
-            table.name,
+            table.name_chain,
             table_type="external",
         )
         return [
diff --git a/posthog/warehouse/api/test/test_view_link.py b/posthog/warehouse/api/test/test_view_link.py
index a04cbdcda59a..763624a860b8 100644
--- a/posthog/warehouse/api/test/test_view_link.py
+++ b/posthog/warehouse/api/test/test_view_link.py
@@ -76,6 +76,20 @@ def test_create_key_error(self):
         )
         self.assertEqual(response.status_code, 400, response.content)
 
+    def test_field_name_periods(self):
+        response = self.client.post(
+            f"/api/environments/{self.team.id}/warehouse_view_links/",
+            {
+                "source_table_name": "events",
+                "joining_table_name": "persons",
+                "source_table_key": "uuid",
+                "joining_table_key": "id",
+                "field_name": "some_field.other.field",
+                "configuration": None,
+            },
+        )
+        self.assertEqual(response.status_code, 400, response.content)
+
     def test_create_saved_query_key_error(self):
         response = self.client.post(
             f"/api/environments/{self.team.id}/warehouse_view_links/",
diff --git a/posthog/warehouse/api/view_link.py b/posthog/warehouse/api/view_link.py
index ee8db47fc676..7b8b2ba42048 100644
--- a/posthog/warehouse/api/view_link.py
+++ b/posthog/warehouse/api/view_link.py
@@ -51,6 +51,9 @@ def _validate_key_uniqueness(self, field_name: str, table_name: str, team_id: in
         if field_name is None:
             raise serializers.ValidationError("Field name must not be empty.")
 
+        if "." in field_name:
+            raise serializers.ValidationError("Field name must not contain a period: '.'")
+
         database = create_hogql_database(team_id)
         table = database.get_table(table_name)
         field = table.fields.get(field_name)
diff --git a/posthog/warehouse/models/datawarehouse_saved_query.py b/posthog/warehouse/models/datawarehouse_saved_query.py
index ef250eb3c486..923e0266e207 100644
--- a/posthog/warehouse/models/datawarehouse_saved_query.py
+++ b/posthog/warehouse/models/datawarehouse_saved_query.py
@@ -85,6 +85,10 @@ class Meta:
             )
         ]
 
+    @property
+    def name_chain(self) -> list[str]:
+        return self.name.split(".")
+
     def soft_delete(self):
         self.deleted = True
         self.deleted_at = datetime.now()
diff --git a/posthog/warehouse/models/join.py b/posthog/warehouse/models/join.py
index c41f9d00d8eb..d4eb70abcdca 100644
--- a/posthog/warehouse/models/join.py
+++ b/posthog/warehouse/models/join.py
@@ -1,4 +1,4 @@
-from typing import Optional
+from typing import Optional, cast
 from warnings import warn
 from datetime import datetime
 from django.db import models
@@ -43,6 +43,10 @@ class DataWarehouseJoin(CreatedMetaFields, UUIDModel, DeletedMetaFields):
     field_name = models.CharField(max_length=400)
     configuration = models.JSONField(default=dict, null=True)
 
+    @property
+    def joining_table_name_chain(self) -> list[str | int]:
+        return cast(list[str | int], self.joining_table_name.split("."))
+
     def soft_delete(self):
         self.deleted = True
         self.deleted_at = datetime.now()
@@ -73,7 +77,7 @@ def _join_function(
                         ast.Alias(alias=alias, expr=ast.Field(chain=chain))
                         for alias, chain in join_to_add.fields_accessed.items()
                     ],
-                    select_from=ast.JoinExpr(table=ast.Field(chain=[self.joining_table_name])),
+                    select_from=ast.JoinExpr(table=ast.Field(chain=self.joining_table_name_chain)),
                 ),
                 join_type="LEFT JOIN",
                 alias=join_to_add.to_table,
diff --git a/posthog/warehouse/models/table.py b/posthog/warehouse/models/table.py
index 3602b849adc4..b93228d44bd5 100644
--- a/posthog/warehouse/models/table.py
+++ b/posthog/warehouse/models/table.py
@@ -112,6 +112,10 @@ class TableFormat(models.TextChoices):
 
     __repr__ = sane_repr("name")
 
+    @property
+    def name_chain(self) -> list[str]:
+        return self.name.split(".")
+
     def soft_delete(self):
         from posthog.warehouse.models.join import DataWarehouseJoin
 


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

# Table Group Implementation for Improved HogQL Table Nesting

This PR introduces a new `TableGroup` class to standardize table name representation in HogQL, supporting both single-element chains (dot-notated strings) and multi-element chains (arrays) with preference for the latter.

- Added `TableGroup` class in `models.py` to manage nested table structures with methods for table existence checking and retrieval
- Updated `get_table_by_chain` method in `database.py` to properly traverse nested table structures
- Modified `joining_table_name_chain` property in `DataWarehouseJoin` to convert dot-separated strings to proper chains
- Added validation in `ViewLinkSerializer` to prevent field names with periods to avoid conflicts with table naming
- Updated serialization functions in `table.py` and `saved_query.py` to split table names into arrays using `split(".")`

<sub>11 file(s) reviewed, 4 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=posthog_31189)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:25:58Z
user: login: EDsCODE
body: Makes sense to me. I'll defer to Marius for the overall direction. Seems like this would take over the dot notation for table fields vs anything else (like name spacing in some other way)
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-15T02:38:23Z

Review comments:
diff_hunk: @@ -161,6 +161,33 @@ def get_asterisk(self):
         return asterisk
 
 
+class TableGroup(FieldOrTable):
+    tables: dict[str, "Table | TableGroup"] = field(default_factory=dict)
+
+    def has_table(self, name: str) -> bool:
+        return name in self.tables
+
+    def get_table(self, name: str) -> "Table | TableGroup":
+        return self.tables[name]
+
+    def to_printed_clickhouse(self, context: "HogQLContext") -> str:
+        raise NotImplementedError("Table.to_printed_clickhouse not overridden")
path: posthog/hogql/database/models.py
user: login: greptile-apps[bot]
body: syntax: Error message references `Table.to_printed_clickhouse` but this is in the `TableGroup` class.

```suggestion
    def to_printed_clickhouse(self, context: "HogQLContext") -> str:
        raise NotImplementedError("TableGroup.to_printed_clickhouse not overridden")
```
created_at: 2025-04-14T14:23:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -161,6 +161,33 @@ def get_asterisk(self):
         return asterisk
 
 
+class TableGroup(FieldOrTable):
+    tables: dict[str, "Table | TableGroup"] = field(default_factory=dict)
+
+    def has_table(self, name: str) -> bool:
+        return name in self.tables
+
+    def get_table(self, name: str) -> "Table | TableGroup":
+        return self.tables[name]
+
+    def to_printed_clickhouse(self, context: "HogQLContext") -> str:
+        raise NotImplementedError("Table.to_printed_clickhouse not overridden")
+
+    def to_printed_hogql(self) -> str:
+        raise NotImplementedError("Table.to_printed_hogql not overridden")
path: posthog/hogql/database/models.py
user: login: greptile-apps[bot]
body: syntax: Error message references `Table.to_printed_hogql` but this is in the `TableGroup` class.

```suggestion
    def to_printed_hogql(self) -> str:
        raise NotImplementedError("TableGroup.to_printed_hogql not overridden")
```
created_at: 2025-04-14T14:23:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -70,7 +70,7 @@ def get_columns(self, view: DataWarehouseSavedQuery) -> list[SerializedField]:
 
         context = HogQLContext(team_id=team_id, database=database)
 
-        fields = serialize_fields(view.hogql_definition().fields, context, view.name, table_type="external")
+        fields = serialize_fields(view.hogql_definition().fields, context, view.name.split(""), table_type="external")
path: posthog/warehouse/api/saved_query.py
user: login: greptile-apps[bot]
body: logic: The `split("")` call is splitting the view name on an empty string, which would result in an array of individual characters rather than splitting on dots. This should likely be `split(".")` to properly convert dot-notation into an array of path components.

```suggestion
        fields = serialize_fields(view.hogql_definition().fields, context, view.name.split("."), table_type="external")
```
created_at: 2025-04-14T14:24:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -419,15 +468,40 @@ def to_printed_clickhouse(self, context):
                 if table.external_data_source:
                     source_type = table.external_data_source.source_type
                     prefix = table.external_data_source.prefix
+                    table_chain: list[str] = [source_type.lower()]
+
                     if prefix is not None and isinstance(prefix, str) and prefix != "":
                         table_name_stripped = table.name.replace(f"{prefix}{source_type}_".lower(), "")
-                        dot_name = f"{source_type}.{prefix.strip('_')}.{table_name_stripped}".lower()
+                        table_chain.extend([prefix.strip("_").lower(), table_name_stripped])
                     else:
                         table_name_stripped = table.name.replace(f"{source_type}_".lower(), "")
-                        dot_name = f"{source_type}.{table_name_stripped}".lower()
+                        table_chain.append(table_name_stripped)
+
+                    last_group: TableGroup | None = None
+                    for index, ele in enumerate(table_chain):
+                        is_last_element = index == len(table_chain) - 1
+                        if last_group:
+                            if is_last_element:
+                                last_group.tables[ele] = s3_table
+                            elif last_group.has_table(ele):
+                                last_group_table = last_group.get_table(ele)
+                                assert isinstance(last_group_table, TableGroup)
+                                last_group = last_group_table
+                            else:
+                                new_group = TableGroup()
+                                last_group.tables[ele] = new_group
+                                last_group = new_group
+                        elif warehouse_tables.get(ele) is not None:
+                            parent_wh_table = warehouse_tables[ele]
+                            if isinstance(parent_wh_table, TableGroup):
+                                last_group = parent_wh_table
+                        else:
+                            new_group = TableGroup()
+                            warehouse_tables[ele] = new_group
+                            last_group = new_group
 
-                    warehouse_tables[dot_name] = s3_table
-                    warehouse_tables_dot_notation_mapping[dot_name] = table.name
+                    joined_table_chain = ".".join(table_chain)
+                    warehouse_tables_dot_notation_mapping[joined_table_chain] = table.name
path: posthog/hogql/database/database.py
user: login: greptile-apps[bot]
body: style: This nested loop builds the TableGroup hierarchy. It's complex and might be worth extracting into a separate helper method for better readability and testability.
created_at: 2025-04-14T14:24:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: posthog-bot
created_at: 2025-04-14T14:34:05Z
body: ## 📸 UI snapshots have been updated

**2** snapshot changes in total. **0** added, **2** modified, **0** deleted:

- **`chromium`**: **0** added, **2** modified, **0** deleted ([diff for shard 1](https://github.com/PostHog/posthog/pull/31189/commits/38af4f73dc45a2cbfb954e5cc7fcc2f78bb06a1b), [diff for shard 5](https://github.com/PostHog/posthog/pull/31189/commits/72345c3fd6f62c0ebd79d600ae776008f133f6ed))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/31189/commits/bafca8c21232f16ddd54f44dce1868bce8931025).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/31189/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: posthog-bot
created_at: 2025-04-14T14:43:00Z
body: ## 📸 UI snapshots have been updated

**1** snapshot changes in total. **0** added, **1** modified, **0** deleted:

- **`chromium`**: **0** added, **1** modified, **0** deleted ([diff for shard 5](https://github.com/PostHog/posthog/pull/31189/commits/cea533d50dfb67097f2d5550a653b90b094fdab8))
- `webkit`: **0** added, **0** modified, **0** deleted

Triggered by [this commit](https://github.com/PostHog/posthog/pull/31189/commits/446e9d5f52389c31ef84226bc80d469a08c3741e).

👉 **[Review this PR's diff of snapshots.](https://github.com/PostHog/posthog/pull/31189/files#:~:text=frontend/__snapshots__/)**
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-14T14:58:01Z
body: **Size Change:** +30 B (0%) 

**Total Size:** 13.6 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size | Change |
| :--- | :---: | :---: |
| `frontend/dist/toolbar.js` | 13.6 MB | +30 B (0%) |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR