All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.neo4j.gds.GdsCypher Maven / Gradle / Ivy

/*
 * Copyright (c) "Neo4j"
 * Neo4j Sweden AB [http://neo4j.com]
 *
 * This file is part of Neo4j.
 *
 * Neo4j is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see .
 */
package org.neo4j.gds;

import org.immutables.builder.Builder;
import org.immutables.value.Value;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Expression;
import org.neo4j.cypherdsl.core.SymbolicName;
import org.neo4j.cypherdsl.core.renderer.Configuration;
import org.neo4j.cypherdsl.core.renderer.Renderer;
import org.neo4j.gds.annotation.ValueClass;
import org.neo4j.gds.api.DefaultValue;
import org.neo4j.gds.config.GraphProjectFromStoreConfig;
import org.neo4j.gds.config.ImmutableGraphProjectFromStoreConfig;
import org.neo4j.gds.core.Aggregation;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static org.neo4j.gds.ElementProjection.PROJECT_ALL;
import static org.neo4j.gds.ElementProjection.PROPERTIES_KEY;
import static org.neo4j.gds.NodeLabel.ALL_NODES;
import static org.neo4j.gds.Orientation.NATURAL;
import static org.neo4j.gds.PropertyMapping.DEFAULT_VALUE_KEY;
import static org.neo4j.gds.PropertyMapping.PROPERTY_KEY;
import static org.neo4j.gds.RelationshipProjection.AGGREGATION_KEY;
import static org.neo4j.gds.RelationshipProjection.INDEX_INVERSE_KEY;
import static org.neo4j.gds.RelationshipProjection.ORIENTATION_KEY;
import static org.neo4j.gds.RelationshipProjection.TYPE_KEY;
import static org.neo4j.gds.RelationshipType.ALL_RELATIONSHIPS;
import static org.neo4j.gds.core.Aggregation.DEFAULT;

@Value.Style(builderVisibility = Value.Style.BuilderVisibility.PACKAGE, depluralize = true, deepImmutablesDetection = true)
public abstract class GdsCypher {

    private static final Pattern PERIOD = Pattern.compile(Pattern.quote("."));

    public static QueryBuilder call(String graphName) {
        return new QueryBuilder(graphName);
    }

    interface ExecutionMode {
    }

    public enum ExecutionModes implements ExecutionMode {
        WRITE, STATS, STREAM, MUTATE, TRAIN
    }


    public static final class QueryBuilder {
        private final String graphName;

        private QueryBuilder(String graphName) {
            this.graphName = graphName;
        }

        public GraphProjectBuilder graphProject() {
            return new GraphProjectBuilder(graphName);
        }

        public ModeBuildStage algo(Iterable namespace, String algoName) {
            return new AlgoBuilder(graphName, namespace, algoName);
        }

        public ModeBuildStage algo(String algoName) {
            Objects.requireNonNull(algoName, "algoName");
            List nameParts = PERIOD.splitAsStream(algoName).collect(Collectors.toList());
            if (nameParts.isEmpty()) {
                // let builder implementation deal with the empty case
                return algo(Collections.emptyList(), "");
            }
            String actualAlgoName = nameParts.remove(nameParts.size() - 1);
            return algo(nameParts, actualAlgoName);
        }

        public ModeBuildStage algo(String... namespacedAlgoName) {
            Objects.requireNonNull(namespacedAlgoName, "namespacedAlgoName");
            if (namespacedAlgoName.length == 0) {
                // let builder implementation deal with the empty case
                return algo(Collections.emptyList(), "");
            }
            int lastIndex = namespacedAlgoName.length - 1;
            String[] nameParts = Arrays.copyOf(namespacedAlgoName, lastIndex);
            String actualAlgoName = namespacedAlgoName[lastIndex];
            return algo(Arrays.asList(nameParts), actualAlgoName);
        }
    }

    public interface ModeBuildStage {

        ParametersBuildStage executionMode(ExecutionMode mode);

        ParametersBuildStage estimationMode(ExecutionMode mode);

        default ParametersBuildStage writeMode() {
            return executionMode(ExecutionModes.WRITE);
        }

        default ParametersBuildStage mutateMode() {
            return executionMode(ExecutionModes.MUTATE);
        }

        default ParametersBuildStage statsMode() {
            return executionMode(ExecutionModes.STATS);
        }

        default ParametersBuildStage streamMode() {
            return executionMode(ExecutionModes.STREAM);
        }

        default ParametersBuildStage trainMode() {
            return executionMode(ExecutionModes.TRAIN);
        }

        default ParametersBuildStage writeEstimation() {
            return estimationMode(ExecutionModes.WRITE);
        }

        default ParametersBuildStage mutateEstimation() {
            return estimationMode(ExecutionModes.MUTATE);
        }

        default ParametersBuildStage statsEstimation() {
            return estimationMode(ExecutionModes.STATS);
        }

        default ParametersBuildStage streamEstimation() {
            return estimationMode(ExecutionModes.STREAM);
        }

        default ParametersBuildStage trainEstimation() {
            return estimationMode(ExecutionModes.TRAIN);
        }
    }

    public interface ParametersBuildStage {

        ParametersBuildStage addParameter(String key, Object value);

        ParametersBuildStage addParameter(Map.Entry entry);

        ParametersBuildStage addPlaceholder(String key, String placeholder);

        ParametersBuildStage addVariable(String key, String variable);

        ParametersBuildStage addAllParameters(Map entries);

        @Language("Cypher")
        default String yields(String... elements) {
            return yields(Arrays.asList(elements));
        }

        @Language("Cypher")
        String yields(Iterable elements);
    }

    enum SpecialExecution {
        NORMAL, ESTIMATE
    }

    @Language("Cypher")
    @SuppressWarnings("TypeMayBeWeakened")
    @Builder.Factory
    static String call(
        List algoNamespace,
        String algoName,
        ExecutionMode executionMode,
        @Builder.Switch(defaultName = "NORMAL") SpecialExecution specialExecution,
        String graphName,
        Map parameters,
        List yields
    ) {
        var procedureName = procedureName(algoNamespace, algoName, executionMode, specialExecution);
        var queryArguments = queryArguments(graphName, parameters, specialExecution == SpecialExecution.ESTIMATE);
        var yieldsFields = yieldsFields(yields);

        var query = Cypher.call(procedureName).withArgs(queryArguments);
        var statement = yieldsFields.map(fields -> query.yield(fields).build()).orElseGet(query::build);

        var cypherRenderer = Renderer.getRenderer(Configuration.defaultConfig());
        return cypherRenderer.render(statement);
    }

    private static String[] procedureName(
        Collection algoNamespace,
        String algoName,
        ExecutionMode executionMode,
        SpecialExecution specialExecution
    ) {
        var procedureName = new ArrayList(algoNamespace.size());
        if (algoNamespace.isEmpty()) {
            procedureName.add("gds");
        } else {
            procedureName.addAll(algoNamespace);
        }
        procedureName.add(algoName);
        if (executionMode instanceof ExecutionModes) {
            procedureName.add(((ExecutionModes) executionMode).name().toLowerCase(Locale.ENGLISH));
        }
        if (specialExecution == SpecialExecution.ESTIMATE) {
            procedureName.add("estimate");
        }

        return procedureName.toArray(new String[0]);
    }

    private static Expression[] queryArguments(
        String graphName,
        GraphProjectFromStoreConfig config,
        Map parameters
    ) {
        var queryArguments = new ArrayList();
        queryArguments.add(Cypher.literalOf(graphName));

        Map newParameters = new LinkedHashMap<>(parameters.size());

        Optional nodeProjection = toMinimalObject(config.nodeProjections()).toObject();
        Optional relationshipProjection = toMinimalObject(config.relationshipProjections()).toObject();
        queryArguments.add(nodeProjection.map(GdsCypher::toExpression).orElseGet(() -> Cypher.literalOf("")));
        queryArguments.add(relationshipProjection.map(GdsCypher::toExpression).orElseGet(() -> Cypher.literalOf("")));

        toMinimalObject(config.nodeProperties(), false)
            .toObject()
            .ifPresent(np -> newParameters.put("nodeProperties", np));
        toMinimalObject(config.relationshipProperties(), false)
            .toObject()
            .ifPresent(rp -> newParameters.put("relationshipProperties", rp));

        newParameters.putAll(parameters);
        parameters = newParameters;

        if (!parameters.isEmpty()) {
            queryArguments.add(Objects.requireNonNullElseGet(toExpression(parameters), Cypher::mapOf));
        }

        return queryArguments.toArray(new Expression[0]);
    }

    private static Expression[] queryArguments(
        String graphName,
        Map parameters,
        boolean isEstimationMode
    ) {
        var queryArguments = new ArrayList();
        queryArguments.add(Cypher.literalOf(graphName));

        if (!parameters.isEmpty() || isEstimationMode) {
            queryArguments.add(Objects.requireNonNullElseGet(toExpression(parameters), Cypher::mapOf));
        }

        return queryArguments.toArray(new Expression[0]);
    }

    private static final Pattern COMMA = Pattern.compile(",");

    private static Optional yieldsFields(Collection yields) {
        if (yields.isEmpty()) {
            return Optional.empty();
        }

        var yieldNames = yields.stream()
            .flatMap(COMMA::splitAsStream)
            .map(String::trim)
            .map(GdsCypher::name)
            .toArray(SymbolicName[]::new);
        return Optional.of(yieldNames);
    }

    private static SymbolicName name(String name) {
        try {
            return Cypher.name(name);
        } catch (IllegalArgumentException e) {
            var message = String.format(
                Locale.ENGLISH,
                "`%s` is not a valid Cypher name: %s",
                name,
                e.getMessage()
            );
            throw new IllegalArgumentException(message, e);
        }
    }


    public static final class GraphProjectBuilder {
        private final String graphName;
        private final InlineGraphProjectConfigBuilder graphProjectConfigBuilder;
        private final Map parameters;

        private GraphProjectBuilder(String graphName) {
            this.graphName = graphName;
            graphProjectConfigBuilder = new InlineGraphProjectConfigBuilder().graphName(graphName);
            parameters = new LinkedHashMap<>();
        }

        public GraphProjectBuilder withGraphProjectConfig(GraphProjectFromStoreConfig config) {
            graphProjectConfigBuilder
                .graphName(config.graphName())
                .nodeProjections(config.nodeProjections().projections())
                .relProjections(config.relationshipProjections().projections())
                .nodeProperties(config.nodeProperties())
                .relProperties(config.relationshipProperties());

            return this;
        }

        /**
         * Loads all nodes of any label and relationships of any type in the
         * {@link org.neo4j.gds.Orientation#NATURAL} projection.
         *
         * Does not load any properties.
         *
         * To load properties, call one of {@link #withNodeProperty(String)}
         * or {@link #withRelationshipProperty(String)} or their variants.
         */
        public GraphProjectBuilder loadEverything() {
            return loadEverything(NATURAL);
        }

        /**
         * Loads all nodes of any label and relationships of any type in the
         * given {@code projection}.
         *
         * Does not load any properties.
         *
         * To load properties, call one of {@link #withNodeProperty(String)}
         * or {@link #withRelationshipProperty(String)} or their variants.
         */
        public GraphProjectBuilder loadEverything(Orientation orientation) {
            return this
                .withNodeLabel(ALL_NODES.name, NodeProjection.all())
                .withRelationshipType(
                    ALL_RELATIONSHIPS.name(),
                    RelationshipProjection.builder().from(RelationshipProjection.ALL).orientation(orientation).build()
                );
        }

        public GraphProjectBuilder withAnyLabel() {
            return withNodeLabel(ALL_NODES.name, NodeProjection.all());
        }

        public GraphProjectBuilder withNodeLabel(String label) {
            return withNodeLabels(label);
        }

        public GraphProjectBuilder withNodeLabel(String label, NodeProjection nodeProjection) {
            return withNodeLabels(Map.of(label, nodeProjection));
        }

        public GraphProjectBuilder withNodeLabels(String... labels) {
            return withNodeLabels(Arrays.stream(labels).collect(Collectors.toMap(
                Function.identity(),
                label -> NodeProjection.builder().label(label).build()
            )));
        }

        public GraphProjectBuilder withNodeLabels(Map nodeProjections) {
            nodeProjections.forEach((label, nodeProjection) -> graphProjectConfigBuilder.putNodeProjection(
                NodeLabel.of(label),
                nodeProjection
            ));
            return this;
        }

        public GraphProjectBuilder withAnyRelationshipType() {
            return withRelationshipType(ALL_RELATIONSHIPS.name(), RelationshipProjection.ALL);
        }

        public GraphProjectBuilder withRelationshipType(String type) {
            return withRelationshipType(type, RelationshipProjection.builder().type(type).build());
        }

        public GraphProjectBuilder withRelationshipType(String type, Orientation orientation) {
            return withRelationshipType(
                type,
                RelationshipProjection.builder().type(type).orientation(orientation).build()
            );
        }

        public GraphProjectBuilder withRelationshipType(String type, String neoType) {
            return withRelationshipType(type, RelationshipProjection.builder().type(neoType).build());
        }

        public GraphProjectBuilder withRelationshipType(
            String type,
            RelationshipProjection relationshipProjection
        ) {
            graphProjectConfigBuilder.putRelProjection(RelationshipType.of(type), relationshipProjection);
            return this;
        }

        public GraphProjectBuilder withNodeProperty(String nodeProperty) {
            return withNodeProperty(ImmutablePropertyMapping.builder().propertyKey(nodeProperty).build());
        }

        public GraphProjectBuilder withNodeProperty(String propertyKey, String neoPropertyKey) {
            return withNodeProperty(ImmutablePropertyMapping
                .builder()
                .propertyKey(propertyKey)
                .neoPropertyKey(neoPropertyKey)
                .build());
        }

        public GraphProjectBuilder withNodeProperty(String neoPropertyKey, DefaultValue defaultValue) {
            return withNodeProperty(PropertyMapping.of(neoPropertyKey, defaultValue));
        }

        public GraphProjectBuilder withNodeProperty(
            String propertyKey,
            String neoPropertyKey,
            DefaultValue defaultValue
        ) {
            return withNodeProperty(PropertyMapping.of(propertyKey, neoPropertyKey, defaultValue));
        }

        public GraphProjectBuilder withNodeProperty(
            String propertyKey,
            DefaultValue defaultValue,
            Aggregation aggregation
        ) {
            return withNodeProperty(PropertyMapping.of(propertyKey, defaultValue, aggregation));
        }

        public GraphProjectBuilder withNodeProperty(
            String propertyKey,
            Aggregation aggregation
        ) {
            return withNodeProperty(PropertyMapping.of(propertyKey, aggregation));
        }

        public GraphProjectBuilder withNodeProperty(
            String propertyKey,
            String neoPropertyKey,
            DefaultValue defaultValue,
            Aggregation aggregation
        ) {
            return withNodeProperty(PropertyMapping.of(
                propertyKey,
                neoPropertyKey,
                defaultValue,
                aggregation
            ));
        }

        public GraphProjectBuilder withNodeProperty(PropertyMapping propertyMapping) {
            graphProjectConfigBuilder.addNodeProperty(propertyMapping);
            return this;
        }

        public GraphProjectBuilder withNodeProperties(Iterable properties, DefaultValue defaultValue) {
            properties.forEach(property -> withNodeProperty(property, defaultValue));
            return this;
        }

        public GraphProjectBuilder withRelationshipProperty(String relationshipProperty) {
            return withRelationshipProperty(ImmutablePropertyMapping
                .builder()
                .propertyKey(relationshipProperty)
                .build());
        }

        public GraphProjectBuilder withRelationshipProperty(String propertyKey, String neoPropertyKey) {
            return withRelationshipProperty(ImmutablePropertyMapping
                .builder()
                .propertyKey(propertyKey)
                .neoPropertyKey(neoPropertyKey)
                .build());
        }

        public GraphProjectBuilder withRelationshipProperty(String neoPropertyKey, DefaultValue defaultValue) {
            return withRelationshipProperty(PropertyMapping.of(neoPropertyKey, defaultValue));
        }

        public GraphProjectBuilder withRelationshipProperty(
            String propertyKey,
            String neoPropertyKey,
            DefaultValue defaultValue
        ) {
            return withRelationshipProperty(PropertyMapping.of(propertyKey, neoPropertyKey, defaultValue));
        }

        public GraphProjectBuilder withRelationshipProperty(
            String propertyKey,
            DefaultValue defaultValue,
            Aggregation aggregation
        ) {
            return withRelationshipProperty(PropertyMapping.of(propertyKey, defaultValue, aggregation));
        }

        public GraphProjectBuilder withRelationshipProperty(
            String propertyKey,
            Aggregation aggregation
        ) {
            return withRelationshipProperty(PropertyMapping.of(propertyKey, aggregation));
        }

        public GraphProjectBuilder withRelationshipProperty(
            String propertyKey,
            String neoPropertyKey,
            DefaultValue defaultValue,
            Aggregation aggregation
        ) {
            return withRelationshipProperty(PropertyMapping.of(
                propertyKey,
                neoPropertyKey,
                defaultValue,
                aggregation
            ));
        }

        public GraphProjectBuilder withRelationshipProperty(PropertyMapping propertyMapping) {
            graphProjectConfigBuilder.addRelProperty(propertyMapping);
            return this;
        }

        public GraphProjectBuilder addParameter(String key, Object value) {
            parameters.put(key, value);
            return this;
        }

        @Language("Cypher")
        public String yields(String... elements) {
            return yields(Arrays.asList(elements));
        }

        @Language("Cypher")
        public String yields(Collection elements) {
            var procedureName = "gds.graph.project";

            var queryArguments = queryArguments(graphName, graphProjectConfigBuilder.build(), parameters);
            var yieldsFields = yieldsFields(elements);

            var query = Cypher.call(procedureName).withArgs(queryArguments);
            var statement = yieldsFields.map(fields -> query.yield(fields).build()).orElseGet(query::build);

            var cypherRenderer = Renderer.getRenderer(Configuration.defaultConfig());
            return cypherRenderer.render(statement);
        }
    }

    private static final class AlgoBuilder implements ModeBuildStage, ParametersBuildStage {

        private final CallBuilder builder;


        private AlgoBuilder(String graphName, Iterable namespace, String algoName) {
            builder = new CallBuilder();
            builder.graphName(graphName);
            builder.algoName(algoName);
            builder.addAllAlgoNamespace(namespace);
        }

        @Override
        public AlgoBuilder executionMode(ExecutionMode mode) {
            builder.executionMode(mode);
            return this;
        }

        @Override
        public AlgoBuilder estimationMode(ExecutionMode mode) {
            builder
                .executionMode(mode)
                .estimateSpecialExecution();
            return this;
        }

        @Override
        public AlgoBuilder addParameter(String key, Object value) {
            builder.putParameter(key, value);
            return this;
        }

        @Override
        public AlgoBuilder addPlaceholder(String key, String placeholder) {
            builder.putParameter(key, Cypher.parameter(placeholder));
            return this;
        }

        @Override
        public AlgoBuilder addVariable(String key, String variable) {
            builder.putParameter(key, GdsCypher.name(variable));
            return this;
        }

        @Override
        public AlgoBuilder addParameter(Map.Entry entry) {
            builder.putParameter(entry);
            return this;
        }

        @Override
        public AlgoBuilder addAllParameters(Map entries) {
            builder.putAllParameters(entries);
            return this;
        }

        @Language("Cypher")
        @Override
        public String yields(Iterable elements) {
            builder.yields(elements);
            return build();
        }

        @Language("Cypher")
        private String build() {
            return builder.build();
        }
    }


    @Builder.Factory
    static GraphProjectFromStoreConfig inlineGraphProjectConfig(
        Optional graphName,
        Map nodeProjections,
        Map relProjections,
        List nodeProperties,
        List relProperties
    ) {
        if (nodeProjections.isEmpty()) {
            nodeProjections = new HashMap<>();
            nodeProjections.put(ALL_NODES, NodeProjection.all());
        }

        if (relProjections.isEmpty()) {
            relProjections = new HashMap<>();
            relProjections.put(ALL_RELATIONSHIPS, RelationshipProjection.ALL);
        }

        return ImmutableGraphProjectFromStoreConfig.builder()
            .graphName(graphName.orElse(""))
            .nodeProjections(NodeProjections.create(nodeProjections))
            .relationshipProjections(ImmutableRelationshipProjections.builder().putAllProjections(relProjections).build())
            .nodeProperties(ImmutablePropertyMappings.of(nodeProperties))
            .relationshipProperties(ImmutablePropertyMappings.of(relProperties))
            .build();
    }

    private static @Nullable Expression toExpression(@Nullable Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof Expression) {
            return (Expression) value;
        }
        if (value instanceof Iterable) {
            return list((Iterable) value);
        }
        if (value instanceof Map) {
            return map((Map) value);
        }
        if (value instanceof Enum) {
            value = ((Enum) value).name();
        }
        if (value instanceof Number && Double.isNaN(((Number) value).doubleValue())) {
            return Cypher.literalOf(0.0).divide(Cypher.literalOf(0.0));
        }
        return Cypher.literalOf(value);
    }

    private static @Nullable Expression list(@NotNull Iterable values) {
        var list = new ArrayList();
        for (Object value : values) {
            var expression = toExpression(value);
            if (expression != null) {
                list.add(expression);
            }
        }
        if (list.isEmpty()) {
            return null;
        }
        return Cypher.listOf(list.toArray(new Expression[0]));
    }

    private static @Nullable Expression map(@NotNull Map values) {
        var entries = new ArrayList<>();
        values.forEach((key, value) -> {
            var expression = toExpression(value);
            if (expression != null) {
                entries.add(String.valueOf(key));
                entries.add(expression);
            }
        });
        if (entries.isEmpty()) {
            return null;
        }
        return Cypher.mapOf(entries.toArray());
    }

    @ValueClass
    @Value.Immutable(singleton = true)
    interface MinimalObject {
        Optional string();

        Optional> map();

        default boolean isEmpty() {
            return string().isEmpty() && map().isEmpty();
        }

        default MinimalObject map(
            Function string,
            Function, MinimalObject> map
        ) {
            return fold(string, map).orElse(empty());
        }

        default MinimalObject map(Function, MinimalObject> map) {
            return fold(MinimalObject::string, map).orElse(empty());
        }

        default Optional toObject() {
            return fold(s -> s, m -> m);
        }

        default  Optional fold(
            Function stringFunc,
            Function, R> mapFunc
        ) {
            return string().map(stringFunc).or(() -> map().map(mapFunc));
        }

        @Value.Check
        default void validate() {
            if (string().isPresent() && map().isPresent()) {
                throw new IllegalStateException("Cannot be both string and map");
            }
        }

        static MinimalObject string(String value) {
            return ImmutableMinimalObject.builder().string(value).build();
        }

        static MinimalObject map(Map value) {
            if (value.isEmpty()) {
                return empty();
            }
            return ImmutableMinimalObject.builder().map(value).build();
        }

        static MinimalObject map(String key, Object value) {
            return map(Map.of(key, value));
        }

        static MinimalObject empty() {
            return ImmutableMinimalObject.of();
        }
    }

    private static  MinimalObject toMinimalObject(
        AbstractProjections allProjections
    ) {
        Map projections = allProjections.projections();
        if (projections.isEmpty()) {
            return MinimalObject.empty();
        }
        if (projections.size() == 1) {
            Map.Entry entry = projections.entrySet().iterator().next();
            I identifier = entry.getKey();
            P projection = entry.getValue();
            if (identifier.projectAll().equals(identifier) && isAllDefault(projection)) {
                return MinimalObject.string(PROJECT_ALL);
            }
            MinimalObject projectionObject = toMinimalObject(projection, identifier);
            return projectionObject.map(m -> MinimalObject.map(identifier.name, m));
        }
        Map value = new LinkedHashMap<>();
        projections.forEach((identifier, projection) ->
            toMinimalObject(projection, identifier)
                .toObject()
                .ifPresent(o -> value.put(identifier.name, o)));
        return MinimalObject.map(value);
    }

    private static boolean isAllDefault(ElementProjection projection) {
        if (projection instanceof RelationshipProjection) {
            RelationshipProjection rel = (RelationshipProjection) projection;
            if (rel.orientation() != NATURAL || rel.aggregation() != DEFAULT) {
                return false;
            }
        }
        return !projection.properties().hasMappings();
    }

    private static MinimalObject toMinimalObject(
        ElementProjection projection,
        ElementIdentifier identifier
    ) {
        if (projection instanceof NodeProjection) {
            return toMinimalObject(((NodeProjection) projection), identifier);
        }
        if (projection instanceof RelationshipProjection) {
            return toMinimalObject(((RelationshipProjection) projection), identifier);
        }
        throw new IllegalArgumentException("Unexpected projection type: " + projection.getClass().getName());
    }

    private static MinimalObject toMinimalObject(
        NodeProjection projection,
        ElementIdentifier identifier
    ) {
        MinimalObject properties = toMinimalObject(projection.properties(), false);
        if (properties.isEmpty() && matchesLabel(identifier.name, projection)) {
            return MinimalObject.string(identifier.name);
        }

        Map value = new LinkedHashMap<>();
        value.put(NodeProjection.LABEL_KEY, projection.label());
        properties.toObject().ifPresent(o -> value.put(PROPERTIES_KEY, o));
        return MinimalObject.map(value);
    }

    private static boolean matchesLabel(String label, NodeProjection projection) {
        return Objects.equals(projection.label(), label);
    }

    private static MinimalObject toMinimalObject(
        RelationshipProjection projection,
        ElementIdentifier identifier
    ) {
        MinimalObject properties = toMinimalObject(projection.properties(), true);

        if (properties.isEmpty() && matchesType(identifier.name, projection) && allDefaults(projection)) {
            return MinimalObject.string(identifier.name);
        }

        Map value = new LinkedHashMap<>();
        value.put(TYPE_KEY, projection.type());
        if (projection.orientation() != NATURAL) {
            value.put(ORIENTATION_KEY, projection.orientation().name());
        }
        if (projection.aggregation() != DEFAULT) {
            value.put(AGGREGATION_KEY, projection.aggregation().name());
        }
        if (projection.indexInverse()) {
            value.put(INDEX_INVERSE_KEY, projection.indexInverse());
        }
        properties.toObject().ifPresent(o -> value.put(PROPERTIES_KEY, o));
        return MinimalObject.map(value);
    }

    private static boolean allDefaults(RelationshipProjection projection) {
        return projection.orientation() == NATURAL && projection.aggregation() == DEFAULT && !projection.indexInverse();
    }

    private static boolean matchesType(String type, RelationshipProjection projection) {
        return projection.orientation() == NATURAL
               && projection.aggregation() == DEFAULT
               && projection.type().equals(type);
    }

    private static MinimalObject toMinimalObject(
        PropertyMappings propertyMappings,
        boolean includeAggregation
    ) {
        List mappings = propertyMappings.mappings();
        if (mappings.isEmpty()) {
            return MinimalObject.empty();
        }
        if (mappings.size() == 1) {
            return toMinimalObject(mappings.get(0), includeAggregation, true);
        }
        Map properties = new LinkedHashMap<>();
        for (PropertyMapping mapping : mappings) {
            toMinimalObject(mapping, includeAggregation, false)
                .map()
                .ifPresent(properties::putAll);
        }
        return MinimalObject.map(properties);
    }

    private static MinimalObject toMinimalObject(
        PropertyMapping propertyMapping,
        boolean includeAggregation,
        boolean allowStringShortcut
    ) {
        String propertyKey = propertyMapping.propertyKey();
        String neoPropertyKey = propertyMapping.neoPropertyKey();
        if (propertyKey == null || neoPropertyKey == null) {
            return MinimalObject.empty();
        }
        Map value = new LinkedHashMap<>();
        value.put(PROPERTY_KEY, neoPropertyKey);
        Object defaultValue = propertyMapping.defaultValue().getObject();
        if (defaultValue != null) {
            value.put(DEFAULT_VALUE_KEY, defaultValue);
        }
        Aggregation aggregation = propertyMapping.aggregation();
        if (includeAggregation && aggregation != DEFAULT) {
            value.put(AGGREGATION_KEY, aggregation.name());
        }
        if (allowStringShortcut && value.size() == 1 && propertyKey.equals(propertyMapping.neoPropertyKey())) {
            return MinimalObject.string(propertyKey);
        }
        return MinimalObject.map(propertyKey, value);
    }
}