@Override
    public ActualProperties visitTableWriter(
        TableWriterNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      if (properties.isCoordinatorOnly()) {
        return ActualProperties.builder().global(coordinatorSingleStreamPartition()).build();
      }
      return ActualProperties.builder()
          .global(properties.isSingleNode() ? singleStreamPartition() : arbitraryPartition())
          .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 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 visitDistinctLimit(
        DistinctLimitNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      return ActualProperties.builderFrom(properties)
          .local(LocalProperties.grouped(node.getDistinctSymbols()))
          .build();
    }
  public static ActualProperties deriveProperties(
      PlanNode node,
      List<ActualProperties> inputProperties,
      Metadata metadata,
      Session session,
      Map<Symbol, Type> types,
      SqlParser parser) {
    ActualProperties output =
        node.accept(new Visitor(metadata, session, types, parser), inputProperties);

    // TODO: ideally this logic would be somehow moved to PlanSanityChecker
    verify(
        node instanceof SemiJoinNode
            || inputProperties.stream().noneMatch(ActualProperties::isNullsReplicated)
            || output.isNullsReplicated(),
        "SemiJoinNode is the only node that can strip null replication");

    return output;
  }
    @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 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();
    }
    @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 visitTableScan(
        TableScanNode node, List<ActualProperties> inputProperties) {
      checkArgument(node.getLayout().isPresent(), "table layout has not yet been chosen");

      TableLayout layout = metadata.getLayout(session, node.getLayout().get());
      Map<ColumnHandle, Symbol> assignments =
          ImmutableBiMap.copyOf(node.getAssignments()).inverse();

      ActualProperties.Builder properties = ActualProperties.builder();

      // Globally constant assignments
      Map<ColumnHandle, NullableValue> globalConstants = new HashMap<>();
      extractFixedValues(node.getCurrentConstraint())
          .orElse(ImmutableMap.of())
          .entrySet()
          .stream()
          .filter(entry -> !entry.getValue().isNull())
          .forEach(entry -> globalConstants.put(entry.getKey(), entry.getValue()));

      Map<Symbol, NullableValue> symbolConstants =
          globalConstants
              .entrySet()
              .stream()
              .filter(entry -> assignments.containsKey(entry.getKey()))
              .collect(toMap(entry -> assignments.get(entry.getKey()), Map.Entry::getValue));
      properties.constants(symbolConstants);

      // Partitioning properties
      properties.global(deriveGlobalProperties(layout, assignments, globalConstants));

      // Append the global constants onto the local properties to maximize their translation
      // potential
      List<LocalProperty<ColumnHandle>> constantAppendedLocalProperties =
          ImmutableList.<LocalProperty<ColumnHandle>>builder()
              .addAll(
                  globalConstants
                      .keySet()
                      .stream()
                      .map(column -> new ConstantProperty<>(column))
                      .iterator())
              .addAll(layout.getLocalProperties())
              .build();
      properties.local(
          LocalProperties.translate(
              constantAppendedLocalProperties,
              column -> Optional.ofNullable(assignments.get(column))));

      return properties.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 visitTableFinish(
     TableFinishNode node, List<ActualProperties> inputProperties) {
   return ActualProperties.builder().global(coordinatorSingleStreamPartition()).build();
 }
 @Override
 public ActualProperties visitValues(ValuesNode node, List<ActualProperties> context) {
   return ActualProperties.builder().global(singleStreamPartition()).build();
 }
    @Override
    public ActualProperties visitGroupId(GroupIdNode node, List<ActualProperties> inputProperties) {
      ActualProperties properties = Iterables.getOnlyElement(inputProperties);

      return properties.translate(translateGroupIdSymbols(node));
    }
 @Override
 public ActualProperties visitExplainAnalyze(
     ExplainAnalyzeNode node, List<ActualProperties> inputProperties) {
   return ActualProperties.builder().global(coordinatorSingleStreamPartition()).build();
 }
    @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 visitExchange(
        ExchangeNode node, List<ActualProperties> inputProperties) {
      checkArgument(
          node.getScope() != REMOTE
              || inputProperties.stream().noneMatch(ActualProperties::isNullsReplicated),
          "Null replicated inputs should not be remotely exchanged");

      Set<Map.Entry<Symbol, NullableValue>> entries = null;
      for (int sourceIndex = 0; sourceIndex < node.getSources().size(); sourceIndex++) {
        Map<Symbol, Symbol> inputToOutput = exchangeInputToOutput(node, sourceIndex);
        ActualProperties translated =
            inputProperties
                .get(sourceIndex)
                .translate(symbol -> Optional.ofNullable(inputToOutput.get(symbol)));

        entries =
            (entries == null)
                ? translated.getConstants().entrySet()
                : Sets.intersection(entries, translated.getConstants().entrySet());
      }
      checkState(entries != null);

      Map<Symbol, NullableValue> constants =
          entries.stream().collect(toMap(Map.Entry::getKey, Map.Entry::getValue));

      // Local exchanges are only created in AddLocalExchanges, at the end of optimization, and
      // local exchanges do not produce global properties as represented by ActualProperties.
      // This is acceptable because AddLocalExchanges does not use global properties and is only
      // interested in the local properties.
      // TODO: implement full properties for local exchanges
      if (node.getScope() == LOCAL) {
        return ActualProperties.builder().constants(constants).build();
      }

      switch (node.getType()) {
        case GATHER:
          boolean coordinatorOnly =
              node.getPartitioningScheme().getPartitioning().getHandle().isCoordinatorOnly();
          return ActualProperties.builder()
              .global(
                  coordinatorOnly ? coordinatorSingleStreamPartition() : singleStreamPartition())
              .constants(constants)
              .build();
        case REPARTITION:
          return ActualProperties.builder()
              .global(
                  partitionedOn(
                          node.getPartitioningScheme().getPartitioning(),
                          Optional.of(node.getPartitioningScheme().getPartitioning()))
                      .withReplicatedNulls(node.getPartitioningScheme().isReplicateNulls()))
              .constants(constants)
              .build();
        case REPLICATE:
          // TODO: this should have the same global properties as the stream taking the replicated
          // data
          return ActualProperties.builder()
              .global(arbitraryPartition())
              .constants(constants)
              .build();
      }

      throw new UnsupportedOperationException("not yet implemented");
    }