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

ac.simons.neo4j.migrations.core.DefaultMigrationContext Maven / Gradle / Ivy

/*
 * Copyright 2020-2022 the original author or 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
 *
 *      https://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.
 */
package ac.simons.neo4j.migrations.core;

import ac.simons.neo4j.migrations.core.catalog.Catalog;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collection;
import java.util.function.UnaryOperator;

import org.neo4j.driver.AccessMode;
import org.neo4j.driver.Bookmark;
import org.neo4j.driver.Driver;
import org.neo4j.driver.Record;
import org.neo4j.driver.Result;
import org.neo4j.driver.Session;
import org.neo4j.driver.SessionConfig;
import org.neo4j.driver.TransactionWork;
import org.neo4j.driver.exceptions.ClientException;
import org.neo4j.driver.summary.DatabaseInfo;
import org.neo4j.driver.summary.Notification;
import org.neo4j.driver.summary.ResultSummary;
import org.neo4j.driver.summary.ServerInfo;

/**
 * Default implementation of the {@link MigrationContext}, including the logic of wrapping driver and blocking sessions
 * into proxy objects taking care of bookmarks.
 *
 * @author Michael J. Simons
 * @since 1.3.0
 */
final class DefaultMigrationContext implements MigrationContext {

	private static final Method WITH_IMPERSONATED_USER = findWithImpersonatedUser();
	private final UnaryOperator applySchemaDatabase;

	private static Method findWithImpersonatedUser() {
		try {
			return SessionConfig.Builder.class.getMethod("withImpersonatedUser", String.class);
		} catch (NoSuchMethodException e) {
			return null; // This is fine
		}
	}

	private final MigrationsConfig config;

	private final BookmarkManager bookmarkManager;

	private final Driver driver;

	@SuppressWarnings("squid:S3077") // This will always be an immutable instance.s
	private volatile ConnectionDetails connectionDetails;

	private final VersionedCatalog catalog = new DefaultCatalog();

	DefaultMigrationContext(MigrationsConfig config, Driver driver) {

		if (config.getOptionalImpersonatedUser().isPresent() && WITH_IMPERSONATED_USER == null) {
			throw new IllegalArgumentException(
				"User impersonation requires a driver that supports `withImpersonatedUser`.");
		}

		this.config = config;
		this.bookmarkManager = new BookmarkManager();
		this.driver = (Driver) Proxy.newProxyInstance(this.getClass().getClassLoader(),
			new Class[] { Driver.class }, new DriverProxy(bookmarkManager, driver));
		this.applySchemaDatabase = this.config.getOptionalSchemaDatabase().map(schemaDatabase ->
			(UnaryOperator) builder -> builder.withDatabase(schemaDatabase)
		).orElseGet(UnaryOperator::identity);
	}

	@Override
	public MigrationsConfig getConfig() {
		return config;
	}

	@Override
	public Driver getDriver() {
		return driver;
	}

	@Override
	public SessionConfig getSessionConfig() {
		return getSessionConfig(UnaryOperator.identity());
	}

	@Override
	public SessionConfig getSessionConfig(UnaryOperator configCustomizer) {

		SessionConfig.Builder builder = SessionConfig.builder()
			.withDefaultAccessMode(AccessMode.WRITE)
			.withBookmarks(bookmarkManager.getBookmarks());
		this.config.getOptionalDatabase().ifPresent(builder::withDatabase);
		this.config.getOptionalImpersonatedUser().ifPresent(user -> setWithImpersonatedUser(builder, user));

		return configCustomizer.apply(builder).build();
	}

	static void setWithImpersonatedUser(SessionConfig.Builder builder, String user) {
		try {
			// This is fine, when an impersonated user is present, the availability of
			// this method has been checked.
			// noinspection ConstantConditions
			WITH_IMPERSONATED_USER.invoke(builder, user);
		} catch (IllegalAccessException | InvocationTargetException e) {
			throw new MigrationsException("Could not impersonate a user on the driver level", e);
		}
	}

	static SessionConfig.Builder copyIntoBuilder(SessionConfig sessionConfig) {

		SessionConfig.Builder builder = SessionConfig.builder();
		builder.withBookmarks(sessionConfig.bookmarks());
		sessionConfig.database().ifPresent(builder::withDatabase);
		builder.withDefaultAccessMode(sessionConfig.defaultAccessMode());
		sessionConfig.fetchSize().ifPresent(builder::withFetchSize);
		sessionConfig.impersonatedUser().ifPresent(user -> setWithImpersonatedUser(builder, user));

		return builder;
	}

	@Override
	public Session getSchemaSession() {
		return getDriver().session(getSessionConfig(applySchemaDatabase));
	}

	@Override
	public ConnectionDetails getConnectionDetails() {

		ConnectionDetails availableConnectionDetails = this.connectionDetails;
		if (availableConnectionDetails == null) {
			synchronized (this) {
				availableConnectionDetails = this.connectionDetails;
				if (availableConnectionDetails == null) {
					this.connectionDetails = getConnectionDetails0();
					availableConnectionDetails = this.connectionDetails;
				}
			}
		}
		return availableConnectionDetails;
	}

	@Override
	public Catalog getCatalog() {
		return catalog;
	}

	private boolean hasDbmsProcedures() {

		try (Session session = this.getSchemaSession()) {
			ResultSummary consume = session.run("EXPLAIN CALL dbms.procedures() YIELD name RETURN count(*)").consume();
			return consume.notifications().stream().map(Notification::code)
				.noneMatch(Neo4jCodes.FEATURE_DEPRECATION_WARNING::equals);
		} catch (ClientException e) {
			if (Neo4jCodes.PROCEDURE_NOT_FOUND.equals(e.code())) {
				return false;
			}
			throw e;
		}
	}

	static class ExtendedResultSummary {
		final boolean showCurrentUserExists;
		final String version;
		final ServerInfo server;
		final DatabaseInfo database;
		final String edition;

		ExtendedResultSummary(boolean showCurrentUserExists, String version, String edition,
			ResultSummary actualSummary) {

			this.showCurrentUserExists = showCurrentUserExists;
			this.version = version;
			this.edition = edition;
			this.server = actualSummary.server();
			this.database = actualSummary.database();
		}
	}

	private ConnectionDetails getConnectionDetails0() {

		TransactionWork extendedResultSummaryTransactionWork;
		if (hasDbmsProcedures()) {
			extendedResultSummaryTransactionWork = tx -> {
				Result result = tx.run(""
					+ "CALL dbms.procedures() YIELD name "
					+ "WHERE name = 'dbms.showCurrentUser' "
					+ "WITH count(*) > 0 AS showCurrentUserExists "
					+ "CALL dbms.components() YIELD versions, edition "
					+ "RETURN showCurrentUserExists, 'Neo4j/' + versions[0] AS version, edition"
				);
				Record singleResultRecord = result.single();
				boolean showCurrentUserExists = singleResultRecord.get("showCurrentUserExists").asBoolean();
				String version = singleResultRecord.get("version").asString();
				String edition = singleResultRecord.get("edition").asString();
				ResultSummary summary = result.consume();
				return new ExtendedResultSummary(showCurrentUserExists, version, edition, summary);
			};
		} else {
			extendedResultSummaryTransactionWork = tx -> {
				boolean showCurrentUserExists = tx.run("SHOW PROCEDURES YIELD name WHERE name = 'dbms.showCurrentUser' RETURN count(*)").single().get(0).asInt() == 1;
				Result result = tx.run(""
					+ "CALL dbms.components() YIELD versions, edition "
					+ "RETURN 'Neo4j/' + versions[0] AS version, edition"
				);
				Record singleResultRecord = result.single();
				String version = singleResultRecord.get("version").asString();
				String edition = singleResultRecord.get("edition").asString();
				ResultSummary summary = result.consume();
				return new ExtendedResultSummary(showCurrentUserExists, version, edition, summary);
			};
		}

		try (Session session = this.getSchemaSession()) {

			ExtendedResultSummary databaseInformation = session.readTransaction(extendedResultSummaryTransactionWork);

			// Auth maybe disabled. In such cases, we cannot get the current user. This is usually the case if the method
			// used here does not exist.
			String username = "anonymous";
			if (databaseInformation.showCurrentUserExists) {
				username = session.readTransaction(tx ->
					tx.run("CALL dbms.showCurrentUser() YIELD username RETURN username").single().get("username").asString()
				);
			}

			ServerInfo serverInfo = databaseInformation.server;
			String schemaDatabase = databaseInformation.database == null ? null : databaseInformation.database.name();
			String targetDatabase = getConfig().getMigrationTargetIn(this).orElse(schemaDatabase);
			return new DefaultConnectionDetails(serverInfo.address(), databaseInformation.version,
					databaseInformation.edition, username, targetDatabase,
					schemaDatabase);
		}
	}

	/**
	 * This proxy catches all calls to {@link Driver#session()} and {@link Driver#session(SessionConfig)} and
	 * makes sure that a session config with the current set of bookmarks is used correctly.
	 */
	static class DriverProxy implements InvocationHandler {

		private final BookmarkManager bookmarkManager;
		private final Driver target;

		DriverProxy(BookmarkManager bookmarkManager, Driver target) {
			this.bookmarkManager = bookmarkManager;
			this.target = target;
		}

		@Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

			try {
				if ("session".equals(method.getName())) {
					SessionConfig sessionConfig;
					Collection existingBookmarks = bookmarkManager.getBookmarks();
					if (args.length == 0) {
						// There is no session config
						sessionConfig = SessionConfig.builder().withBookmarks(existingBookmarks).build();
					} else {
						SessionConfig existingConfig = (SessionConfig) args[0];
						// {@literal null} is the default, so if there's something non-null in,
						// probably someone had thought about already
						if (existingConfig.bookmarks() != null) {
							sessionConfig = existingConfig;
						} else {
							sessionConfig = copyIntoBuilder(existingConfig)
								.withBookmarks(existingBookmarks)
								.build();
						}
					}
					Session session = target.session(sessionConfig);
					return Proxy.newProxyInstance(this.getClass().getClassLoader(),
						new Class[] { Session.class },
						new SessionProxy(bookmarkManager, existingBookmarks, session));
				} else {
					return method.invoke(target, args);
				}
			} catch (InvocationTargetException ite) {
				throw ite.getCause();
			}
		}
	}

	/**
	 * This proxy catches the {@link Session#close()} call on the blocking session, retrieving the latest bookmark
	 * and stores it with the bookmark manager.
	 */
	static class SessionProxy implements InvocationHandler {

		private final BookmarkManager bookmarkManager;
		/**
		 * The bookmarks used when the {@link #target target session} has been initialized. They need to
		 * be kept around separately, as the session doesn't allow to retrieve the config back after creation.
		 */
		private final Collection usedBookmarks;
		private final Session target;

		SessionProxy(BookmarkManager bookmarkManager, Collection usedBookmarks, Session target) {
			this.bookmarkManager = bookmarkManager;
			this.usedBookmarks = usedBookmarks;
			this.target = target;
		}

		@Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

			try {
				if ("close".equals(method.getName())) {
					bookmarkManager.updateBookmarks(usedBookmarks, target.lastBookmark());
					target.close();
					return null;
				}
				return method.invoke(target, args);
			} catch (InvocationTargetException ite) {
				throw ite.getCause();
			}
		}
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy