Repository: edgedb/edgedb

Issue:
TypeError: Cannot read properties of null (reading 'actual_total_time') on query analyze
<!-- Please search existing issues to avoid creating duplicates. -->

<!--
For the EdgeDB Version: run `edgedb query 'select sys::get_version_as_str()'` from your project directory (or run `select sys::get_version_as_str();` in the EdgeDB interactive shell).
For the EdgeDB CLI Version: Run `edgedb --version` from anywhere
-->

- **EdgeDB Version:** 3.0
- **EdgeDB CLI Version:** 2.2.3+4793161
- **OS Version:** macOS 13.4 (22F66)

### Steps to Reproduce:

when i try to analyze the following query i get the error; `TypeError: Cannot read properties of null (reading 'actual_total_time')`

### Query:

```
analyze WITH
    users := (
      SELECT UserPreference
      FILTER count(File FILTER .userId = UserPreference.userId AND .isPremium = <bool>false AND .status = <str>"PUBLISHED") >= 3 AND .isHireable = <bool>true
    ),
    users_with_recent_files := (
      SELECT users {
        totalDownloadCount := sum(
          (
            SELECT File
            FILTER .userId = users.userId
            AND .publishedAt >= <datetime>"2023-06-24T09:37:21.714Z" 
            AND .isPremium = <bool>false 
            AND .status = <str>"PUBLISHED"
          ).downloadCount
        ),
        files := (
          SELECT File {
            id,
            name,
            bgColor,
            isSticker,
            publishedAt,
            speed,
            status,
            workflowId,
            userTags,
            userId,
            lottieSource,
            jsonSource,
            imageSource
          }
          FILTER .userId = users.userId
          AND .publishedAt >= <datetime>"2023-06-24T09:37:21.714Z" 
          AND .isPremium = <bool>false 
          AND .status = <str>"PUBLISHED"
          ORDER BY .downloadCount DESC
          LIMIT 3
        )
      }
    )
  SELECT users_with_recent_files {
    userId,
    preferredLang,
    countryCode,
    isHireable,
    totalDownloadCount,
    fileCount,
    files: {
      id,
      name,
      bgColor,
      isSticker,
      publishedAt,
      speed,
      status,
      workflowId,
      userTags,
      userId,
      lottieSource,
      jsonSource,
      imageSource
    }
  }
  ORDER BY .totalDownloadCount desc
  OFFSET 0
  LIMIT 6
```
<!-- If the issue is about a query error, please also provide your schema -->

### Schema:
- schema I can provide on request
below is a minimal schema;
```
module default {
  # Any time that the SDL declaration refers to an inherited property
  # that is being overloaded (by adding more constraints, for example),
  # the overloaded keyword must be used. This is to prevent
  # unintentional overloading due to name clashes

  scalar type autoIncrementFileLegacyId extending sequence;
  scalar type autoIncrementCollectionLegacyId extending sequence;
  scalar type autoIncrementCommentMappingId extending sequence;

  type File {
    # the File.legacyId property can be set at the time of creation
    # but not modified thereafter.
    required property legacyId -> int32{
      default := (select sequence_next(introspect autoIncrementFileLegacyId)+1000000)
    };

    # the constraint below guarantees that File.name doesn’t contain
    # any leading or trailing whitespace by checking that the raw string
    # is equal to the trimmed version. It uses the built-in str_trim() function.
    required property name -> str {
      constraint expression on (
        __subject__ = str_trim(__subject__)
      );
    }

    required property slug -> str;

    required property hash -> str {
      readonly := true;
      constraint exclusive;
    }

    property description -> str;

    # the File.workflowId property can be set at the time of creation
    # but not modified thereafter.
    property workflowId -> uuid {
      readonly := true;
    }

    required property userTags -> json;

    # the File.createdAt property can be set at the time of creation
    # but not modified thereafter.
    required property createdAt -> datetime{
      default := std::datetime_current();
      readonly := true;
    }

    property updatedAt -> datetime;

    property publishedAt -> datetime;

    # the File.userId property can be set at the time of creation
    # but not modified thereafter.
    required property userId -> uuid {
      readonly := true;
    }

    # default status to be 'PENDING'
    required property status -> str {
      constraint one_of('PENDING', 'PUBLISHED', 'REJECTED', 'DELETED');
      default := 'PENDING';
    }

    property bgColor -> str;

    # default isSticker to be false
    required property isSticker -> bool {
      default := false;
    }

    required property isPremium -> bool {
      default := false;
    }

    property downloadCount -> int32{
      default := 0;
    };

    multi link fileVariations := .<file[is FileVariation];

    property lottieSource := (
      select .fileVariations
      filter
        .type = "LOTTIE" and .isOptimized = false
      order by .createdAt desc
      limit 1
    ).path;

    property jsonSource := (
      select .fileVariations
      filter
        .type = "JSON" and .isOptimized = false
      order by .createdAt desc
      limit 1
    ).path;

    property imageSource := (
      select .fileVariations
      filter
        .type = "PNG"
      order by .createdAt desc
      limit 1
    ).path;

    index on ((.hash, .status, .isPremium));
    index on ((.slug, .status));
    index on ((.id, .isSticker, .isPremium, .status));
    index on ((.isSticker, .isPremium, .status));
    index on ((.userId, .isPremium, .status));
    index on ((.userId, .status));
    index on ((.hash, .status));
    index on ((.userId, .isPremium, .status, .publishedAt));
    index on (.hash);
    index on (.isSticker);
    index on (.isPremium);
    index on (.status);
    index on (.userId);
    index on (.publishedAt);
    index on (.downloadCount);
  }

  type FileVariation {
    required property path -> str;

    # the FileVariation.createdAt property can be set at the time of creation
    # but not modified thereafter.
    required property createdAt -> datetime {
      default := std::datetime_current();
      readonly := true;
    }

    property updatedAt -> datetime;

    # default type to be 'LOTTIE'
    required property type -> str {
      constraint one_of('LOTTIE', 'JSON', 'MP4', 'GIF', 'PNG', 'ZIP', 'WEBP', 'WEBM', 'MOV', 'AEP');
      default := 'LOTTIE';
    }

    # default size to be 0
    property size -> int32 {
      default := 0;
    }

    # one to one relationship
    link file -> File;

    # one to one relationship
    link fileDimension -> FileDimension;

    property isOptimized -> bool {
      default := false;
    }

    property isTransparent -> bool {
      default := false;
    }

    index on ((.type, .isOptimized));
    index on ((.type, .isTransparent));
    index on (.type);
    index on (.isOptimized);
    index on (.isTransparent);
  }

  type UserPreference {
    # user id, refering to auth
    required property userId -> uuid{
      constraint exclusive;
    };

    # user hire me status
    property isHireable -> bool {
      default := false;
    }
    index on (.isHireable);
  }

  type FileDimension {
    required property name -> str;
    required property width -> int32 {
      default := 0;
    }
    required property height -> int32 {
      default := 0;
    }
  }
};
```


Discussion:
Did this happen in GUI or in the CLI?
i tested this on edgeDB UI
here is the output on CLI;
```
────────────────────────────────────────────────────── Computable 0 ──────────────────────────────────────────────────────
➎  ((select .fileVariations filter ((.type = 'LOTTIE') AND (.isOptimized = false)) order by .createdAt DESC limit 1 )).path

─────────── Computable 1 ───────────
➎  .<file[is default::FileVariation]

───────────────────────────────────────────────────── Computable 2 ─────────────────────────────────────────────────────
➏  ((select .fileVariations filter ((.type = 'JSON') AND (.isOptimized = false)) order by .createdAt DESC limit 1 )).path

────────────────────────────────────── Computable 3 ──────────────────────────────────────
➐  ((select .fileVariations filter (.type = 'PNG') order by .createdAt DESC limit 1 )).path
```

but it does not show much of the query timings
I have encountered a similar issue - a completely different query with a completely different schema - but same fail result on ANALYZE (the query itself works fine).

I will not attach all the query/schema here (I can on request) - but I did manage to get the analyze to work by removing an inner nested FILTER statement. So with the filter the analyze was failing, when the filter clause was removed it then worked. Maybe this helps pin point the issue.

I have a similar pattern to the query above

```
WITH

   name := SELECT { ... SELECT .. FILTER  }

   ...

SELECT name { ..fields.. } 
  ORDER
  LIMIT etc
```





Resolving patch:
diff --git a/edb/server/compiler/explain/coarse_grained.py b/edb/server/compiler/explain/coarse_grained.py
--- a/edb/server/compiler/explain/coarse_grained.py
+++ b/edb/server/compiler/explain/coarse_grained.py
@@ -136,8 +136,11 @@ def _build_shape(
     for name, pointer in shape.pointers.items():
         subpath = f"{path}.{name}"
 
-        if pointer.main_alias:
-            info = index.by_alias[pointer.main_alias]
+        if (
+            pointer.main_alias is not None and
+            (c_info := index.by_alias.get(pointer.main_alias)) is not None
+        ):
+            info = c_info
         else:
             for alias in pointer.aliases:
                 if c_info := index.by_alias.get(alias):


Test patch:
diff --git a/tests/schemas/explain_bug5758.esdl b/tests/schemas/explain_bug5758.esdl
new file mode 100644
--- /dev/null
+++ b/tests/schemas/explain_bug5758.esdl
@@ -0,0 +1,53 @@
+#
+# This source file is part of the EdgeDB open source project.
+#
+# Copyright 2023-present MagicStack Inc. and the EdgeDB authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+
+# Schema for bug #5758
+type User {
+    required name: str;
+};
+
+type Album extending TimeTrackedEntity {
+    required name: str;
+
+    multi link tracks: Track {
+        position: int16;
+    };
+};
+
+type Track extending TimeTrackedEntity {
+    required name: str;
+    multi link artists: Artist;
+    multi link liked_by: User;
+};
+
+type Artist extending TimeTrackedEntity {
+    required name: str;
+    required stream_count: int64;
+    required follower_count: int64;
+};
+
+abstract type TimeTrackedEntity {
+    created: datetime {
+        rewrite insert using (datetime_of_statement())
+    };
+
+    modified: datetime {
+        rewrite update using (datetime_of_statement())
+    };
+};
\ No newline at end of file
diff --git a/tests/schemas/explain_bug5791.esdl b/tests/schemas/explain_bug5791.esdl
new file mode 100644
--- /dev/null
+++ b/tests/schemas/explain_bug5791.esdl
@@ -0,0 +1,182 @@
+#
+# This source file is part of the EdgeDB open source project.
+#
+# Copyright 2023-present MagicStack Inc. and the EdgeDB authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+
+# Schema for bug #5791
+
+scalar type autoIncrementFileLegacyId extending sequence;
+scalar type autoIncrementCollectionLegacyId extending sequence;
+scalar type autoIncrementCommentMappingId extending sequence;
+
+type File {
+    required property legacyId -> int32{
+        default := (
+            select sequence_next(introspect autoIncrementFileLegacyId)+1000000)
+    };
+
+    required property name -> str {
+        constraint expression on (
+            __subject__ = str_trim(__subject__)
+        );
+    }
+
+    required property slug -> str;
+
+    required property hash -> str {
+        readonly := true;
+        constraint exclusive;
+    }
+
+    property description -> str;
+
+    property workflowId -> uuid {
+        readonly := true;
+    }
+
+    required property userTags -> json;
+
+    required property createdAt -> datetime{
+        default := std::datetime_current();
+        readonly := true;
+    }
+    property updatedAt -> datetime;
+    property publishedAt -> datetime;
+
+    required property userId -> uuid {
+        readonly := true;
+    }
+
+    required property status -> str {
+        constraint one_of('PENDING', 'PUBLISHED', 'REJECTED', 'DELETED');
+        default := 'PENDING';
+    }
+
+    property bgColor -> str;
+
+    required property isSticker -> bool {
+        default := false;
+    }
+    required property isPremium -> bool {
+        default := false;
+    }
+
+    property downloadCount -> int32{
+        default := 0;
+    };
+
+    multi link fileVariations := .<file[is FileVariation];
+
+    property lottieSource := (
+        select .fileVariations
+        filter .type = "LOTTIE" and .isOptimized = false
+        order by .createdAt desc
+        limit 1
+    ).path;
+
+    property jsonSource := (
+        select .fileVariations
+        filter .type = "JSON" and .isOptimized = false
+        order by .createdAt desc
+        limit 1
+    ).path;
+
+    property imageSource := (
+        select .fileVariations
+        filter .type = "PNG"
+        order by .createdAt desc
+        limit 1
+    ).path;
+
+    index on ((.hash, .status, .isPremium));
+    index on ((.slug, .status));
+    index on ((.id, .isSticker, .isPremium, .status));
+    index on ((.isSticker, .isPremium, .status));
+    index on ((.userId, .isPremium, .status));
+    index on ((.userId, .status));
+    index on ((.hash, .status));
+    index on ((.userId, .isPremium, .status, .publishedAt));
+    index on (.hash);
+    index on (.isSticker);
+    index on (.isPremium);
+    index on (.status);
+    index on (.userId);
+    index on (.publishedAt);
+    index on (.downloadCount);
+}
+
+type FileVariation {
+    required property path -> str;
+
+    required property createdAt -> datetime {
+        default := std::datetime_current();
+        readonly := true;
+    }
+
+    property updatedAt -> datetime;
+
+    required property type -> str {
+        constraint one_of(
+            'LOTTIE', 'JSON', 'MP4', 'GIF', 'PNG', 'ZIP', 'WEBP', 'WEBM',
+            'MOV', 'AEP'
+        );
+        default := 'LOTTIE';
+    }
+
+    property size -> int32 {
+        default := 0;
+    }
+
+    link file -> File;
+
+    link fileDimension -> FileDimension;
+
+    property isOptimized -> bool {
+        default := false;
+    }
+
+    property isTransparent -> bool {
+        default := false;
+    }
+
+    index on ((.type, .isOptimized));
+    index on ((.type, .isTransparent));
+    index on (.type);
+    index on (.isOptimized);
+    index on (.isTransparent);
+}
+
+type UserPreference {
+    required property userId -> uuid{
+        constraint exclusive;
+    };
+
+    property isHireable -> bool {
+        default := false;
+    }
+    index on (.isHireable);
+}
+
+type FileDimension {
+    required property name -> str;
+    required property width -> int32 {
+        default := 0;
+    }
+    required property height -> int32 {
+        default := 0;
+    }
+}
diff --git a/tests/test_edgeql_explain.py b/tests/test_edgeql_explain.py
--- a/tests/test_edgeql_explain.py
+++ b/tests/test_edgeql_explain.py
@@ -38,6 +38,12 @@ class TestEdgeQLExplain(tb.QueryTestCase):
     SCHEMA = os.path.join(os.path.dirname(__file__), 'schemas',
                           'explain.esdl')
 
+    SCHEMA_BUG5758 = os.path.join(os.path.dirname(__file__), 'schemas',
+                                  'explain_bug5758.esdl')
+
+    SCHEMA_BUG5791 = os.path.join(os.path.dirname(__file__), 'schemas',
+                                  'explain_bug5791.esdl')
+
     SETUP = [
         os.path.join(os.path.dirname(__file__), 'schemas',
                      'explain_setup.edgeql'),
@@ -1797,6 +1803,127 @@ async def test_edgeql_explain_json_contains_01(self):
             },
         )
 
+    async def test_edgeql_explain_bug_5758(self):
+        # Issue #5758
+        res = await self.explain('''
+            with
+                module bug5758,
+                user := (select User filter .id =
+                    <uuid>'17b5649c-58b2-11ee-a739-4706f31ed5ab'),
+                track := (select Track filter .id =
+                    <uuid>'81958316-58d4-11ee-a739-9b645ff26c66'),
+                shouldLike := (user not in track.liked_by)
+            select (
+                update track
+                set {
+                    liked_by := assert_distinct(
+                        (.liked_by union user) if shouldLike
+                        else (select .liked_by filter .id != user.id)
+                    )
+                }
+            );
+        ''', execute=False)
+        # We use execute := False above because we actually don't have data,
+        # but we can target the issue reliably with the "default" plan.
+        #
+        # The bug is that when coarse plan is generated "main_alias" may not
+        # be found in the plan, causing the coarse plan to be None.
+        #
+        # Part of the problem with this kind of bug is that we can definitely
+        # tell that having no coarse plan is cause by an exception, but
+        # besides that it's much harder to validate that the actual "fixed"
+        # coarse plan is "good".
+        self.assertIsNotNone(res['coarse_grained'])
+
+    async def test_edgeql_explain_bug_5791(self):
+        # Issue #5758
+        res = await self.explain('''
+            with
+                module bug5791,
+                users := (
+                    SELECT UserPreference
+                    FILTER count(
+                        File
+                        FILTER
+                            .userId = UserPreference.userId
+                            AND .isPremium = false
+                            AND .status = "PUBLISHED"
+                    ) >= 3
+                    AND .isHireable = true
+                ),
+                users_with_recent_files := (
+                    SELECT users {
+                        totalDownloadCount := sum((
+                            SELECT File
+                            FILTER
+                                .userId = users.userId
+                                AND .publishedAt >=
+                                    <datetime>"2023-06-24T09:37:21.714Z"
+                                AND .isPremium = false
+                                AND .status = "PUBLISHED"
+                            ).downloadCount
+                        ),
+                        files := (
+                            SELECT File {
+                                id,
+                                name,
+                                bgColor,
+                                isSticker,
+                                publishedAt,
+                                status,
+                                workflowId,
+                                userTags,
+                                userId,
+                                lottieSource,
+                                jsonSource,
+                                imageSource
+                            }
+                            FILTER
+                                .userId = users.userId
+                                AND .publishedAt >=
+                                    <datetime>"2023-06-24T09:37:21.714Z"
+                                AND .isPremium = false
+                                AND .status = "PUBLISHED"
+                            ORDER BY .downloadCount DESC
+                            LIMIT 3
+                        )
+                    }
+                )
+                SELECT users_with_recent_files {
+                    userId,
+                    isHireable,
+                    totalDownloadCount,
+                    files: {
+                        id,
+                        name,
+                        bgColor,
+                        isSticker,
+                        publishedAt,
+                        status,
+                        workflowId,
+                        userTags,
+                        userId,
+                        lottieSource,
+                        jsonSource,
+                        imageSource
+                    }
+                }
+                ORDER BY .totalDownloadCount DESC
+                OFFSET 0
+                LIMIT 6
+        ''', execute=False)
+        # We use execute := False above because we actually don't have data,
+        # but we can target the issue reliably with the "default" plan.
+        #
+        # The bug is that when coarse plan is generated "main_alias" may not
+        # be found in the plan, causing the coarse plan to be None.
+        #
+        # Part of the problem with this kind of bug is that we can definitely
+        # tell that having no coarse plan is cause by an exception, but
+        # besides that it's much harder to validate that the actual "fixed"
+        # coarse plan is "good".
+        self.assertIsNotNone(res['coarse_grained'])
+
 
 class NameTranslation(unittest.TestCase):