Repository: voxel51/fiftyone
Pull request: View stage enhancements
Closes #465 and #466.

And adds array slicing!

Problem statement:
View bar does not handle default values appropriately
If I try to create an instance of a view stage in the App with a parameter with a default value, like the `reverse` parameter of the `SortBy` stage, which is supposed to default to `False`, I get an error.

I can make it function by changing the backend param validation as follows:

```py
    @classmethod
    def _params(cls):
        return [
            {"name": "field_or_expr", "type": "dict|str"},
            {"name": "reverse", "type": "bool|NoneType", "default": "False"},
        ]
```

where I have allowed `reverse` to be `NoneType`. This works because the view bar seems to ignore the `default: False` part and just send `reverse=None` to the backend

Hints from issue discussion:

Commit message: include private fields

Patch to review:
diff --git a/electron/app/components/CheckboxGrid.tsx b/electron/app/components/CheckboxGrid.tsx
index 2a55e5627f2..5c50fe7cf2f 100644
--- a/electron/app/components/CheckboxGrid.tsx
+++ b/electron/app/components/CheckboxGrid.tsx
@@ -123,7 +123,7 @@ const Entry = ({ entry, onCheck }) => {
               {entry.name}
             </span>
             <span className="data">{entry.data}</span>
-            {entry.selected && entry.type && (
+            {entry.selected && entry.type && entry.count > 0 && (
               <ArrowDropDown
                 onClick={(e) => {
                   e.preventDefault();
diff --git a/electron/app/components/DisplayOptionsSidebar.tsx b/electron/app/components/DisplayOptionsSidebar.tsx
index 832ab1f4b71..ab5919ac320 100644
--- a/electron/app/components/DisplayOptionsSidebar.tsx
+++ b/electron/app/components/DisplayOptionsSidebar.tsx
@@ -104,6 +104,7 @@ const Cell = ({ label, icon, entries, onSelect, colorMapping, title }) => {
             selected: e.selected,
             type: e.type,
             data: [(e.count || 0).toLocaleString()],
+            count: e.count,
             color: colorMapping[e.name],
             disabled: Boolean(e.disabled),
           }))}
diff --git a/electron/app/components/ViewBar/ViewStage/viewStageParameterMachine.ts b/electron/app/components/ViewBar/ViewStage/viewStageParameterMachine.ts
index 91733d3e261..0bdfc1e09ab 100644
--- a/electron/app/components/ViewBar/ViewStage/viewStageParameterMachine.ts
+++ b/electron/app/components/ViewBar/ViewStage/viewStageParameterMachine.ts
@@ -208,11 +208,15 @@ export default Machine(
               actions: [
                 assign({
                   submitted: true,
-                  value: ({ type, value }) =>
-                    type.split("|").reduce((acc, t) => {
-                      const parser = PARSER[t];
-                      return parser.validate(value) ? parser.parse(value) : acc;
-                    }, undefined),
+                  value: ({ type, value, defaultValue }) =>
+                    value === "" && defaultValue
+                      ? defaultValue
+                      : type.split("|").reduce((acc, t) => {
+                          const parser = PARSER[t];
+                          return parser.validate(value)
+                            ? parser.parse(value)
+                            : acc;
+                        }, undefined),
                   errorId: undefined,
                 }),
                 sendParent((ctx) => ({
@@ -220,8 +224,11 @@ export default Machine(
                   parameter: ctx,
                 })),
               ],
-              cond: ({ type, value }) => {
-                return type.split("|").some((t) => PARSER[t].validate(value));
+              cond: ({ type, value, defaultValue }) => {
+                return (
+                  (value === "" && defaultValue) ||
+                  type.split("|").some((t) => PARSER[t].validate(value))
+                );
               },
             },
             {
diff --git a/electron/app/components/ViewBar/viewBarMachine.ts b/electron/app/components/ViewBar/viewBarMachine.ts
index 3f2421f8f40..47f0a41b492 100644
--- a/electron/app/components/ViewBar/viewBarMachine.ts
+++ b/electron/app/components/ViewBar/viewBarMachine.ts
@@ -88,21 +88,23 @@ function setStages(ctx, stageInfo) {
         false,
         ctx.stages.length,
         i === Math.min(view.length - 1, ctx.activeStage),
-        stage.kwargs.map((p, j) => {
-          const stageInfoResult = stageInfo.filter(
-            (s) => s.name === stageName
-          )[0];
-          return createParameter(
-            stageName,
-            p[0],
-            stageInfoResult.params[j].type,
-            stageInfoResult.params[j].default,
-            operate(stageInfoResult.params[j].type, "castFrom", p[1]),
-            true,
-            false,
-            j === stageInfoResult.params.length - 1
-          );
-        }),
+        stage.kwargs
+          .filter((k) => !k[0].startsWith("_"))
+          .map((p, j) => {
+            const stageInfoResult = stageInfo.filter(
+              (s) => s.name === stageName
+            )[0];
+            return createParameter(
+              stageName,
+              p[0],
+              stageInfoResult.params[j].type,
+              stageInfoResult.params[j].default,
+              operate(stageInfoResult.params[j].type, "castFrom", p[1]),
+              true,
+              false,
+              j === stageInfoResult.params.length - 1
+            );
+          }),
         true,
         true
       );
diff --git a/fiftyone/core/cli.py b/fiftyone/core/cli.py
index c2361567974..0597f836a57 100644
--- a/fiftyone/core/cli.py
+++ b/fiftyone/core/cli.py
@@ -747,6 +747,12 @@ class AppViewCommand(Command):
         # View a zoo dataset in the app
         fiftyone app view --zoo-dataset <name> --splits <split1> ...
 
+        # View a directory of images in the app
+        fiftyone app view --images-dir <images-dir>
+
+        # View a glob pattern of images in the app
+        fiftyone app view --images-patt <images-patt>
+
         # View a dataset stored in JSON format on disk in the app
         fiftyone app view --json-path <json-path>
 
@@ -784,6 +790,16 @@ def setup(parser):
             nargs="+",
             help="the dataset splits to load",
         )
+        parser.add_argument(
+            "--images-dir",
+            metavar="IMAGES_DIR",
+            help="the path to a directory of images",
+        )
+        parser.add_argument(
+            "--images-patt",
+            metavar="IMAGES_PATT",
+            help="a glob pattern of images",
+        )
         parser.add_argument(
             "-j",
             "--json-path",
@@ -823,6 +839,16 @@ def execute(parser, args):
             dataset = fod.Dataset.from_dir(
                 dataset_dir, dataset_type, name=name
             )
+        elif args.images_dir:
+            # View a directory of images
+            name = args.name
+            images_dir = args.images_dir
+            dataset = fod.Dataset.from_images_dir(images_dir, name=name)
+        elif args.images_patt:
+            # View a glob pattern of images
+            name = args.name
+            images_patt = args.images_patt
+            dataset = fod.Dataset.from_images_patt(images_patt, name=name)
         elif args.json_path:
             # View a dataset from a JSON file
             name = args.name
diff --git a/fiftyone/core/collections.py b/fiftyone/core/collections.py
index e2379d90624..dd26a4fd057 100644
--- a/fiftyone/core/collections.py
+++ b/fiftyone/core/collections.py
@@ -302,9 +302,9 @@ def exclude_fields(self, field_names):
         return self._add_view_stage(fos.ExcludeFields(field_names))
 
     @view_stage
-    def exists(self, field):
-        """Returns a view containing the samples that have a non-``None`` value
-        for the given field.
+    def exists(self, field, bool=True):
+        """Returns a view containing the samples that have (or do not have) a
+        non-``None`` value for the given field.
 
         Examples::
 
@@ -319,13 +319,22 @@ def exists(self, field):
 
             view = dataset.exists("predictions")
 
+            #
+            # Only include samples that do NOT have a value in their
+            # `predictions` field
+            #
+
+            view = dataset.exists("predictions", False)
+
         Args:
             field: the field
+            bool (True): whether to check if the field exists (True) or does
+                not exist (False)
 
         Returns:
             a :class:`fiftyone.core.view.DatasetView`
         """
-        return self._add_view_stage(fos.Exists(field))
+        return self._add_view_stage(fos.Exists(field, bool=bool))
 
     @view_stage
     def filter_field(self, field, filter):
diff --git a/fiftyone/core/dataset.py b/fiftyone/core/dataset.py
index cb3e9e9f9e7..022b750e2f9 100644
--- a/fiftyone/core/dataset.py
+++ b/fiftyone/core/dataset.py
@@ -914,19 +914,20 @@ def add_images_dir(self, images_dir, tags=None, recursive=True):
         sample_parser = foud.ImageSampleParser()
         return self.add_images(image_paths, sample_parser, tags=tags)
 
-    def add_images_patt(self, image_patt, tags=None):
+    def add_images_patt(self, images_patt, tags=None):
         """Adds the given glob pattern of images to the dataset.
 
         This operation does not read the images.
 
         Args:
-            image_patt: a glob pattern of images like ``/path/to/images/*.jpg``
+            images_patt: a glob pattern of images like
+                ``/path/to/images/*.jpg``
             tags (None): an optional list of tags to attach to each sample
 
         Returns:
             a list of IDs of the samples in the dataset
         """
-        image_paths = etau.parse_glob_pattern(image_patt)
+        image_paths = etau.get_glob_matches(images_patt)
         sample_parser = foud.ImageSampleParser()
         return self.add_images(image_paths, sample_parser, tags=tags)
 
@@ -1180,13 +1181,14 @@ def from_images_dir(cls, images_dir, name=None, tags=None, recursive=True):
         return dataset
 
     @classmethod
-    def from_images_patt(cls, image_patt, name=None, tags=None):
+    def from_images_patt(cls, images_patt, name=None, tags=None):
         """Creates a :class:`Dataset` from the given glob pattern of images.
 
         This operation does not read the images.
 
         Args:
-            image_patt: a glob pattern of images like ``/path/to/images/*.jpg``
+            images_patt: a glob pattern of images like
+                ``/path/to/images/*.jpg``
             name (None): a name for the dataset. By default,
                 :func:`get_default_dataset_name` is used
             tags (None): an optional list of tags to attach to each sample
@@ -1195,7 +1197,7 @@ def from_images_patt(cls, image_patt, name=None, tags=None):
             a :class:`Dataset`
         """
         dataset = cls(name)
-        dataset.add_images_patt(image_patt, tags=tags)
+        dataset.add_images_patt(images_patt, tags=tags)
         return dataset
 
     def aggregate(self, pipeline=None):
diff --git a/fiftyone/core/expressions.py b/fiftyone/core/expressions.py
index ce856e08fd6..0aa9b415e35 100644
--- a/fiftyone/core/expressions.py
+++ b/fiftyone/core/expressions.py
@@ -405,17 +405,57 @@ def trunc(self, place=0):
 
     # Array expression operators ##############################################
 
-    def __getitem__(self, idx):
-        """Returns the element at the given index in the expression, which must
-        resolve to an array ``self[idx]``.
+    def __getitem__(self, idx_or_slice):
+        """Returns the element or slice of the given expression, which must
+        resolve to an array.
+
+        All of the typical array slicing operations are supported, except for
+        specifying a non-unit step.
+
+        Examples::
+
+            expr[3]      # the fourth element of the array
+            expr[:10]    # the first (up to) 10 elements of the array
+            expr[-3:]    # the last (up to) 3 elements of the array
+            expr[3:10]   # the fourth through tenth elements of the array
 
         Args:
-            idx: the index
+            idx_or_slice: the index or slice
 
         Returns:
             a :class:`ViewExpression`
         """
-        return ViewExpression({"$arrayElemAt": [self, idx]})
+        if not isinstance(idx_or_slice, slice):
+            return ViewExpression({"$arrayElemAt": [self, idx_or_slice]})
+
+        s = idx_or_slice
+
+        if s.step is not None and s.step != 1:
+            raise ValueError(
+                "Unsupported slice '%s'; step is not supported" % s
+            )
+
+        if s.start is not None:
+            if s.stop is None:
+                n = s.start
+                return ViewExpression({"$slice": [self, n]})
+
+            position = s.start
+            n = s.stop - position
+            if position < 0:
+                position += self.length()
+
+            return ViewExpression({"$slice": [self, position, n]})
+
+        if s.stop is None:
+            return self
+
+        if s.stop < 0:
+            n = self.length() + s.stop
+            return ViewExpression({"$slice": [self, n]})
+
+        n = s.stop
+        return ViewExpression({"$slice": [self, n]})
 
     def __len__(self):
         # Annoyingly, Python enforces deep in its depths that __len__ must
diff --git a/fiftyone/core/stages.py b/fiftyone/core/stages.py
index 0bdd7d42f7e..349f4fc21ab 100644
--- a/fiftyone/core/stages.py
+++ b/fiftyone/core/stages.py
@@ -35,7 +35,11 @@ def __str__(self):
 
     def __repr__(self):
         kwargs_str = ", ".join(
-            ["%s=%s" % (k, _repr.repr(v)) for k, v in self._kwargs()]
+            [
+                "%s=%s" % (k, _repr.repr(v))
+                for k, v in self._kwargs()
+                if not k.startswith("_")
+            ]
         )
 
         return "%s(%s)" % (self.__class__.__name__, kwargs_str)
@@ -173,7 +177,7 @@ def to_mongo(self):
             a MongoDB aggregation pipeline (list of dicts)
         """
         sample_ids = [ObjectId(id) for id in self._sample_ids]
-        return Match({"_id": {"$not": {"$in": sample_ids}}}).to_mongo()
+        return [{"$match": {"_id": {"$not": {"$in": sample_ids}}}}]
 
     def _kwargs(self):
         return [["sample_ids", self._sample_ids]]
@@ -245,19 +249,25 @@ def _params(self):
         return [{"name": "field_names", "type": "list<str>"}]
 
     def _validate_params(self):
-        invalid_fields = set(self._field_names) & set(default_sample_fields())
-        if invalid_fields:
-            raise ValueError(
-                "Cannot exclude default fields: %s" % list(invalid_fields)
-            )
+        default_fields = set(default_sample_fields())
+        for field_name in self._field_names:
+            if field_name.startswith("_"):
+                raise ValueError(
+                    "Cannot exclude private field '%s'" % field_name
+                )
+
+            if (field_name == "id") or (field_name in default_fields):
+                raise ValueError(
+                    "Cannot exclude default field '%s'" % field_name
+                )
 
     def validate(self, sample_collection):
         _validate_fields_exist(sample_collection, self.field_names)
 
 
 class Exists(ViewStage):
-    """Returns a view containing the samples that have a non-``None`` value
-    for the given field.
+    """Returns a view containing the samples that have (or do not have) a
+    non-``None`` value for the given field.
 
     Examples::
 
@@ -273,32 +283,65 @@ class Exists(ViewStage):
         stage = Exists("predictions")
         view = dataset.add_stage(stage)
 
+        #
+        # Only include samples that do NOT have a value in their `predictions`
+        # field
+        #
+
+        stage = Exists("predictions", False)
+        view = dataset.add_stage(stage)
+
     Args:
         field: the field
+        bool (True): whether to check if the field exists (True) or does not
+            exist (False)
     """
 
-    def __init__(self, field):
+    def __init__(self, field, bool=True):
         self._field = field
+        self._bool = bool
 
     @property
     def field(self):
         """The field to check if exists."""
         return self._field
 
+    @property
+    def bool(self):
+        """Whether to check if the field exists (True) or does not exist
+        (False).
+        """
+        return self._bool
+
     def to_mongo(self):
         """Returns the MongoDB version of the stage.
 
         Returns:
             a MongoDB aggregation pipeline (list of dicts)
         """
-        return Match({self._field: {"$exists": True, "$ne": None}}).to_mongo()
+        if self._bool:
+            return [{"$match": {self._field: {"$exists": True, "$ne": None}}}]
+
+        return [
+            {
+                "$match": {
+                    "$or": [
+                        {self._field: {"$exists": False}},
+                        {self._field: {"$eq": None}},
+                    ]
+                }
+            }
+        ]
 
     def _kwargs(self):
-        return [["field", self._field]]
+        return [["field", self._field], ["bool", self._bool]]
 
     @classmethod
     def _params(cls):
-        return [{"name": "field", "type": "str"}]
+        return [
+            {"name": "field", "type": "str"},
+            {"name": "bool", "type": "bool", "default": "True"},
+        ]
 
 
 class FilterField(ViewStage):
@@ -591,6 +634,9 @@ def to_mongo(self):
         Returns:
             a MongoDB aggregation pipeline (list of dicts)
         """
+        if self._limit <= 0:
+            return [{"$match": {"_id": None}}]
+
         return [{"$limit": self._limit}]
 
     def _kwargs(self):
@@ -727,7 +773,7 @@ def to_mongo(self):
         Returns:
             a MongoDB aggregation pipeline (list of dicts)
         """
-        return Match({"tags": self._tag}).to_mongo()
+        return [{"$match": {"tags": self._tag}}]
 
     def _kwargs(self):
         return [["tag", self._tag]]
@@ -775,7 +821,7 @@ def to_mongo(self):
         Returns:
             a MongoDB aggregation pipeline (list of dicts)
         """
-        return Match({"tags": {"$in": self._tags}}).to_mongo()
+        return [{"$match": {"tags": {"$in": self._tags}}}]
 
     def _kwargs(self):
         return [["tags", self._tags]]
@@ -907,7 +953,7 @@ def to_mongo(self):
             a MongoDB aggregation pipeline (list of dicts)
         """
         sample_ids = [ObjectId(id) for id in self._sample_ids]
-        return Match({"_id": {"$in": sample_ids}}).to_mongo()
+        return [{"$match": {"_id": {"$in": sample_ids}}}]
 
     def _kwargs(self):
         return [["sample_ids", self._sample_ids]]
@@ -956,7 +1002,7 @@ class SelectFields(ViewStage):
     """
 
     def __init__(self, field_names=None):
-        default_fields = default_sample_fields()
+        default_fields = default_sample_fields(include_private=True)
 
         if field_names:
             if etau.is_str(field_names):
@@ -1025,9 +1071,9 @@ class Shuffle(ViewStage):
         seed (None): an optional random seed to use when shuffling the samples
     """
 
-    def __init__(self, seed=None):
+    def __init__(self, seed=None, _randint=None):
         self._seed = seed
-        self._randint = _get_rng(seed).randint(1e7, 1e10)
+        self._randint = _randint or _get_rng(seed).randint(1e7, 1e10)
 
     @property
     def seed(self):
@@ -1048,7 +1094,7 @@ def to_mongo(self):
         ]
 
     def _kwargs(self):
-        return [["seed", self._seed]]
+        return [["seed", self._seed], ["_randint", self._randint]]
 
     @classmethod
     def _params(self):
@@ -1091,6 +1137,9 @@ def to_mongo(self):
         Returns:
             a MongoDB aggregation pipeline (list of dicts)
         """
+        if self._skip <= 0:
+            return []
+
         return [{"$skip": self._skip}]
 
     def _kwargs(self):
@@ -1233,10 +1282,10 @@ class Take(ViewStage):
         seed (None): an optional random seed to use when selecting the samples
     """
 
-    def __init__(self, size, seed=None):
+    def __init__(self, size, seed=None, _randint=None):
         self._seed = seed
         self._size = size
-        self._randint = _get_rng(seed).randint(1e7, 1e10)
+        self._randint = _randint or _get_rng(seed).randint(1e7, 1e10)
 
     @property
     def size(self):
@@ -1255,7 +1304,7 @@ def to_mongo(self):
             a MongoDB aggregation pipeline (list of dicts)
         """
         if self._size <= 0:
-            return Match({"_id": None}).to_mongo()
+            return [{"$match": {"_id": None}}]
 
         # @todo avoid creating new field here?
         return [
@@ -1266,7 +1315,11 @@ def to_mongo(self):
         ]
 
     def _kwargs(self):
-        return [["size", self._size], ["seed", self._seed]]
+        return [
+            ["size", self._size],
+            ["seed", self._seed],
+            ["_randint", self._randint],
+        ]
 
     @classmethod
     def _params(cls):
@@ -1290,10 +1343,11 @@ def _validate_fields_exist(sample_collection, field_or_fields):
         field_or_fields = [field_or_fields]
 
     schema = sample_collection.get_field_schema()
+    default_fields = set(default_sample_fields(include_private=True))
     for field in field_or_fields:
         # We only validate that the root field exists
         field_name = field.split(".", 1)[0]
-        if field_name not in schema:
+        if field_name not in schema and field_name not in default_fields:
             raise ViewStageError("Field '%s' does not exist" % field_name)
 
 
diff --git a/fiftyone/core/state.py b/fiftyone/core/state.py
index 61cd98683cc..c520b73bfa5 100644
--- a/fiftyone/core/state.py
+++ b/fiftyone/core/state.py
@@ -248,7 +248,10 @@ def _get_label_classes(view, field_name, field):
     path = "%s.label" % path
     pipeline.append({"$group": {"_id": None, "labels": {"$addToSet": path}}})
 
-    return next(view.aggregate(pipeline))["labels"]
+    try:
+        return next(view.aggregate(pipeline))["labels"]
+    except StopIteration:
+        pass
 
 
 def _get_label_fields(custom_fields_schema):
@@ -292,7 +295,9 @@ def _get_field_count(view, field_name, field):
                     }
                 }
             ]
-
-            return next(view.aggregate(pipeline))["totalCount"]
+            try:
+                return next(view.aggregate(pipeline))["totalCount"]
+            except StopIteration:
+                return 0
 
     return len(view.exists(field_name))
diff --git a/fiftyone/utils/tf.py b/fiftyone/utils/tf.py
index 00536117f3c..c5152c5529b 100644
--- a/fiftyone/utils/tf.py
+++ b/fiftyone/utils/tf.py
@@ -38,11 +38,11 @@ def from_images_dir(images_dir, recursive=True, num_parallel_calls=None):
     return from_images(image_paths, num_parallel_calls=num_parallel_calls)
 
 
-def from_image_patt(image_patt, num_parallel_calls=None):
+def from_images_patt(images_patt, num_parallel_calls=None):
     """Creates a ``tf.data.Dataset`` for the given glob pattern of images.
 
     Args:
-        image_patt: a glob pattern of images like ``/path/to/images/*.jpg``
+        images_patt: a glob pattern of images like ``/path/to/images/*.jpg``
         num_parallel_calls (None): the number of samples to read
             asynchronously in parallel. See
             https://www.tensorflow.org/api_docs/python/tf/data/Dataset#map for
@@ -51,7 +51,7 @@ def from_image_patt(image_patt, num_parallel_calls=None):
     Returns:
         a ``tf.data.Dataset`` that emits decoded images
     """
-    image_paths = etau.parse_glob_pattern(image_patt)
+    image_paths = etau.get_glob_matches(images_patt)
     return from_images(image_paths, num_parallel_calls=num_parallel_calls)
 
 
diff --git a/tests/unittests.py b/tests/unittests.py
index 6621e35eabd..f8a81cb4c7d 100644
--- a/tests/unittests.py
+++ b/tests/unittests.py
@@ -1913,6 +1913,31 @@ def test_aggregate(self):
                 self.assertEqual(count, counts[tag])
 
 
+class SliceTests(unittest.TestCase):
+    @drop_datasets
+    def test_slice(self):
+        dataset = fo.Dataset()
+        dataset.add_samples(
+            [
+                fo.Sample("1.jpg", tags=["tag1"]),
+                fo.Sample("2.jpg", tags=["tag1", "tag2"]),
+                fo.Sample("3.jpg", tags=["tag2", "tag3"]),
+            ]
+        )
+
+        view = dataset[0:2]
+        self.assertEqual(len(view), 2)
+
+        view = dataset[1:3]
+        self.assertEqual(len(view), 2)
+
+        view = dataset[0:0]
+        self.assertEqual(len(view), 0)
+
+        view = dataset[3:3]
+        self.assertEqual(len(view), 0)
+
+
 class ViewStageTests(unittest.TestCase):
     @drop_datasets
     def setUp(self):


Code review comments:
diff_hunk: @@ -1002,7 +1002,7 @@ class SelectFields(ViewStage):
     """
 
     def __init__(self, field_names=None):
-        default_fields = default_sample_fields(include_private=False)
+        default_fields = default_sample_fields(include_private=True)
path: fiftyone/core/stages.py
text: @user1:
This broke a unit test:
```
______________________ ViewStageTests.test_select_fields _______________________

self = <unittests.ViewStageTests testMethod=test_select_fields>

    def test_select_fields(self):
        self.dataset.add_sample_field("select_fields_field", fo.IntField)
    
        for sample in self.dataset.select_fields():
>           self.assertSetEqual(
                sample.selected_field_names, set(default_sample_fields())
            )
E           AssertionError: Items in the first set but not the second:
E           '_rand'
```
Unsure if this or the test needs to be changed.

@user2:
We need `_id` returned, but not `_rand`, etc. `include_private` is too general

@user2:
I'll push a fix, one moment

@author:
No `_rand` needs to be included here. If you omit `_rand` when a `SelectFields` stage is included, then it will break pipelines that intend to include a `Take` or `Shuffle` stage after that.

I will handle fixing this. Working on other things at the moment, which is why I haven't acted on his PR yet

@user2:
:+1: