@Override
    public ActualProperties visitJoin(JoinNode node, List<ActualProperties> inputProperties) {
      // TODO: include all equivalent columns in partitioning properties
      ActualProperties probeProperties = inputProperties.get(0);
      ActualProperties buildProperties = inputProperties.get(1);

      switch (node.getType()) {
        case INNER:
          return ActualProperties.builderFrom(probeProperties)
              .constants(
                  ImmutableMap.<Symbol, NullableValue>builder()
                      .putAll(probeProperties.getConstants())
                      .putAll(buildProperties.getConstants())
                      .build())
              .build();
        case LEFT:
          return ActualProperties.builderFrom(probeProperties)
              .constants(probeProperties.getConstants())
              .build();
        case RIGHT:
          return ActualProperties.builderFrom(buildProperties)
              .constants(buildProperties.getConstants())
              .local(ImmutableList.of())
              .build();
        case FULL:
          // We can't say anything about the partitioning scheme because any partition of
          // a hash-partitioned join can produce nulls in case of a lack of matches
          return ActualProperties.builder()
              .global(
                  probeProperties.isSingleNode() ? singleStreamPartition() : arbitraryPartition())
              .build();
        default:
          throw new UnsupportedOperationException("Unsupported join type: " + node.getType());
      }
    }
    @Override
    public ActualProperties visitDistinctLimit(
        DistinctLimitNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      return ActualProperties.builderFrom(properties)
          .local(LocalProperties.grouped(node.getDistinctSymbols()))
          .build();
    }
    @Override
    public ActualProperties visitSort(SortNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      List<SortingProperty<Symbol>> localProperties =
          node.getOrderBy()
              .stream()
              .map(column -> new SortingProperty<>(column, node.getOrderings().get(column)))
              .collect(toImmutableList());

      return ActualProperties.builderFrom(properties).local(localProperties).build();
    }
    @Override
    public ActualProperties visitFilter(FilterNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      DomainTranslator.ExtractionResult decomposedPredicate =
          DomainTranslator.fromPredicate(metadata, session, node.getPredicate(), types);

      Map<Symbol, NullableValue> constants = new HashMap<>(properties.getConstants());
      constants.putAll(
          extractFixedValues(decomposedPredicate.getTupleDomain()).orElse(ImmutableMap.of()));

      return ActualProperties.builderFrom(properties).constants(constants).build();
    }
    @Override
    public ActualProperties visitTopNRowNumber(
        TopNRowNumberNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      ImmutableList.Builder<LocalProperty<Symbol>> localProperties = ImmutableList.builder();
      localProperties.add(new GroupingProperty<>(node.getPartitionBy()));
      for (Symbol column : node.getOrderBy()) {
        localProperties.add(new SortingProperty<>(column, node.getOrderings().get(column)));
      }

      return ActualProperties.builderFrom(properties).local(localProperties.build()).build();
    }
    @Override
    public ActualProperties visitIndexJoin(
        IndexJoinNode node, List<ActualProperties> inputProperties) {
      // TODO: include all equivalent columns in partitioning properties
      ActualProperties probeProperties = inputProperties.get(0);
      ActualProperties indexProperties = inputProperties.get(1);

      switch (node.getType()) {
        case INNER:
          return ActualProperties.builderFrom(probeProperties)
              .constants(
                  ImmutableMap.<Symbol, NullableValue>builder()
                      .putAll(probeProperties.getConstants())
                      .putAll(indexProperties.getConstants())
                      .build())
              .build();
        case SOURCE_OUTER:
          return ActualProperties.builderFrom(probeProperties)
              .constants(probeProperties.getConstants())
              .build();
        default:
          throw new UnsupportedOperationException("Unsupported join type: " + node.getType());
      }
    }
    @Override
    public ActualProperties visitProject(ProjectNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      Map<Symbol, Symbol> identities = computeIdentityTranslations(node.getAssignments());

      ActualProperties translatedProperties =
          properties.translate(column -> Optional.ofNullable(identities.get(column)));

      // Extract additional constants
      Map<Symbol, NullableValue> constants = new HashMap<>();
      for (Map.Entry<Symbol, Expression> assignment : node.getAssignments().entrySet()) {
        Expression expression = assignment.getValue();

        IdentityHashMap<Expression, Type> expressionTypes =
            getExpressionTypes(
                session,
                metadata,
                parser,
                types,
                expression,
                emptyList() /* parameters already replaced */);
        Type type = requireNonNull(expressionTypes.get(expression));
        ExpressionInterpreter optimizer =
            ExpressionInterpreter.expressionOptimizer(
                expression, metadata, session, expressionTypes);
        // TODO:
        // We want to use a symbol resolver that looks up in the constants from the input subplan
        // to take advantage of constant-folding for complex expressions
        // However, that currently causes errors when those expressions operate on arrays or row
        // types
        // ("ROW comparison not supported for fields with null elements", etc)
        Object value = optimizer.optimize(NoOpSymbolResolver.INSTANCE);

        if (value instanceof SymbolReference) {
          Symbol symbol = Symbol.from((SymbolReference) value);
          NullableValue existingConstantValue = constants.get(symbol);
          if (existingConstantValue != null) {
            constants.put(assignment.getKey(), new NullableValue(type, value));
          }
        } else if (!(value instanceof Expression)) {
          constants.put(assignment.getKey(), new NullableValue(type, value));
        }
      }
      constants.putAll(translatedProperties.getConstants());

      return ActualProperties.builderFrom(translatedProperties).constants(constants).build();
    }
    @Override
    public ActualProperties visitAggregation(
        AggregationNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      ActualProperties translated =
          properties.translate(
              symbol ->
                  node.getGroupingKeys().contains(symbol)
                      ? Optional.of(symbol)
                      : Optional.<Symbol>empty());

      return ActualProperties.builderFrom(translated)
          .local(LocalProperties.grouped(node.getGroupingKeys()))
          .build();
    }
    @Override
    public ActualProperties visitWindow(WindowNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      // If the input is completely pre-partitioned and sorted, then the original input properties
      // will be respected
      if (ImmutableSet.copyOf(node.getPartitionBy()).equals(node.getPrePartitionedInputs())
          && node.getPreSortedOrderPrefix() == node.getOrderBy().size()) {
        return properties;
      }

      ImmutableList.Builder<LocalProperty<Symbol>> localProperties = ImmutableList.builder();

      // If the WindowNode has pre-partitioned inputs, then it will not change the order of those
      // inputs at output,
      // so we should just propagate those underlying local properties that guarantee the
      // pre-partitioning.
      // TODO: come up with a more general form of this operation for other streaming operators
      if (!node.getPrePartitionedInputs().isEmpty()) {
        GroupingProperty<Symbol> prePartitionedProperty =
            new GroupingProperty<>(node.getPrePartitionedInputs());
        for (LocalProperty<Symbol> localProperty : properties.getLocalProperties()) {
          if (!prePartitionedProperty.isSimplifiedBy(localProperty)) {
            break;
          }
          localProperties.add(localProperty);
        }
      }

      if (!node.getPartitionBy().isEmpty()) {
        localProperties.add(new GroupingProperty<>(node.getPartitionBy()));
      }
      for (Symbol column : node.getOrderBy()) {
        localProperties.add(new SortingProperty<>(column, node.getOrderings().get(column)));
      }

      return ActualProperties.builderFrom(properties)
          .local(LocalProperties.normalizeAndPrune(localProperties.build()))
          .build();
    }