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

com.sap.cds.impl.JDBCClient Maven / Gradle / Ivy

The newest version!
/************************************************************************
 * © 2019-2024 SAP SE or an SAP affiliate company. All rights reserved. *
 ************************************************************************/
package com.sap.cds.impl;

import static com.sap.cds.DataStoreConfiguration.MAX_BATCH_SIZE;
import static com.sap.cds.DataStoreConfiguration.MAX_BATCH_SIZE_DEFAULT;
import static com.sap.cds.ResultBuilder.selectedRows;
import static com.sap.cds.impl.ContextImpl.context;
import static com.sap.cds.impl.ExceptionHandler.chainNextExceptions;
import static com.sap.cds.impl.ExceptionHandler.dataStoreException;
import static com.sap.cds.impl.docstore.DocStoreUtils.targetsDocStore;
import static com.sap.cds.jdbc.spi.SessionVariableSetter.APPLICATIONUSER;
import static com.sap.cds.jdbc.spi.SessionVariableSetter.LOCALE;
import static com.sap.cds.jdbc.spi.SessionVariableSetter.TENANT;
import static com.sap.cds.jdbc.spi.SessionVariableSetter.VALID_FROM;
import static com.sap.cds.jdbc.spi.SessionVariableSetter.VALID_TO;
import static com.sap.cds.util.CdsModelUtils.entity;
import static com.sap.cds.util.CqnStatementUtils.$JSON;
import static com.sap.cds.util.DataUtils.resolvePathAndAdd;
import static java.lang.System.arraycopy;
import static java.util.Collections.emptyList;
import static java.util.Collections.emptyMap;

import java.io.Reader;
import java.lang.reflect.UndeclaredThrowableException;
import java.sql.BatchUpdateException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Stream;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.VisibleForTesting;
import com.sap.cds.CdsDataStore;
import com.sap.cds.CdsDataStoreConnector.Capabilities;
import com.sap.cds.CdsDataStoreException;
import com.sap.cds.CdsException;
import com.sap.cds.CdsLockTimeoutException;
import com.sap.cds.ResultBuilder;
import com.sap.cds.SessionContext;
import com.sap.cds.impl.PreparedCqnStmt.Parameter;
import com.sap.cds.impl.localized.LocaleUtils;
import com.sap.cds.impl.parser.JsonParser;
import com.sap.cds.impl.parser.StructDataParser;
import com.sap.cds.impl.parser.token.Jsonizer;
import com.sap.cds.impl.sql.SQLStatementBuilder;
import com.sap.cds.impl.sql.SQLStatementBuilder.SQLStatement;
import com.sap.cds.jdbc.spi.DbContext;
import com.sap.cds.jdbc.spi.ExceptionAnalyzer;
import com.sap.cds.jdbc.spi.ValueBinder;
import com.sap.cds.jdbc.spi.ValueBinder.Getter;
import com.sap.cds.jdbc.spi.ValueBinder.Setter;
import com.sap.cds.ql.CQL;
import com.sap.cds.ql.CdsDataException;
import com.sap.cds.ql.ElementRef;
import com.sap.cds.ql.cqn.CqnElementRef;
import com.sap.cds.ql.cqn.CqnExpand;
import com.sap.cds.ql.cqn.CqnSelect;
import com.sap.cds.ql.cqn.CqnSelectListItem;
import com.sap.cds.ql.cqn.CqnSelectListValue;
import com.sap.cds.ql.cqn.CqnStatement;
import com.sap.cds.ql.cqn.CqnStructuredTypeRef;
import com.sap.cds.ql.impl.ExpandProcessor;
import com.sap.cds.reflect.CdsArrayedType;
import com.sap.cds.reflect.CdsBaseType;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.reflect.CdsModel;
import com.sap.cds.reflect.CdsStructuredType;
import com.sap.cds.reflect.CdsType;
import com.sap.cds.transaction.TransactionManager;
import com.sap.cds.transaction.TransactionRequiredException;
import com.sap.cds.util.CqnStatementUtils;
import com.sap.cds.util.DataUtils;

public class JDBCClient implements ConnectedClient {
	private static final Logger logger = LoggerFactory.getLogger(JDBCClient.class);
	private static final TimingLogger timed = new TimingLogger(logger, false);
	private static final Object INITIAL = new Object();

	private final Supplier adapter;
	private final TransactionManager transactionManager;
	private final Supplier ds;
	private final ValueBinder binder;
	private final ExceptionAnalyzer exceptionAnalyzer;
	private final Capabilities capabilities;
	private final Map oldSessionVars = new HashMap<>();

	private Context context;
	private int maxBatchSize;

	public JDBCClient(Context context, Supplier ds, TransactionManager transactionManager) {
		this.context = context;
		this.ds = ds;
		this.transactionManager = transactionManager;
		DbContext dbContext = context.getDbContext();
		this.binder = dbContext.getBinder(context.getSessionContext().getTimeZone());
		this.adapter = () -> new JdbcDataSourceAdapter(this.context);
		this.exceptionAnalyzer = context.getDbContext().getExceptionAnalyzer();
		this.capabilities = context.getDbContext().getCapabilities();
		this.maxBatchSize = getMaxBatchSize(context);
	}

	@Override
	public PreparedCqnStatement prepare(CqnStatement statement) {
		if (statement.isSelect()) {
			return prepare(statement.asSelect());
		}
		SQLStatement stmt = adapter.get().process(statement);
		CdsEntity root = entity(context.getCdsModel(), statement.ref());
		CqnStructuredTypeRef ref = null;
		try {
			ref = statement.ref();
		} catch (CdsException ignore) {
			// ignore for now.
		}
		return PreparedCqnStmt.createUpdate(stmt.sql(), stmt.params(), ref, root);
	}

	public PreparedCqnStmt prepare(CqnSelect select) {
		CdsStructuredType targetType = CqnStatementUtils.targetType(context.getCdsModel(), select);
		CqnStructuredTypeRef ref = null;
		if (!CqnStatementUtils.containsPathExpression(select.where())) {
			ref = CqnStatementUtils.targetRef(select);
		}

		return prepare(select, ref, targetType);
	}

	private PreparedCqnStmt prepare(CqnSelect select, CqnStructuredTypeRef ref, CdsStructuredType targetType) {
		List expandProcessors = prepareExpands(select, context.getCdsModel(), targetType, ref);
		SQLStatementBuilder.SQLStatement stmt = adapter.get().process(select);

		return PreparedCqnStmt.create(stmt.sql(), select.items(), expandProcessors, select.excluding(), stmt.params(),
				ref, targetType);
	}

	private static List prepareExpands(CqnSelect select, CdsModel model, CdsStructuredType targetType,
			CqnStructuredTypeRef ref) {
		if (targetsDocStore(targetType)) {
			return emptyList();
		}
		List expands = CqnStatementUtils.removeExpands(select);
		List expandProcessors = new ArrayList<>(expands.size());
		if (!expands.isEmpty()) {
			boolean addMissingKeys = !select.isDistinct() && CqnStatementUtils.isNoAggregation(select);
			boolean optimizeToManyExpands = addMissingKeys && ref != null;
			Map keyAliases = CqnStatementUtils.selectedKeys(select, targetType, addMissingKeys);
			for (CqnExpand expand : expands) {
				ExpandProcessor expandProcessor = ExpandProcessor.create(select, model, ref, targetType, keyAliases,
						expand, optimizeToManyExpands);
				expandProcessor.addMappingKeys(select);
				expandProcessors.add(expandProcessor);
			}
		}
		return expandProcessors;
	}

	private static int append(int[] arr, int[] elements, int pos) {
		arraycopy(elements, 0, arr, pos, elements.length);
		return pos + elements.length;
	}

	private static void rejectAutoCommit(Connection conn) throws SQLException {
		if (conn.getAutoCommit()) {
			throw new TransactionRequiredException("Connection must not be in auto-commit mode");
		}
	}

	@Override
	public ResultBuilder executeQuery(PreparedCqnStatement preparedStmt, Map paramValues,
			CdsDataStore dataStore, boolean isTransactionRequired) {
		if (isTransactionRequired) {
			requireTransaction();
		}
		PreparedCqnStmt stmt = (PreparedCqnStmt) preparedStmt;
		while (stmt != null) {
			try {
				List> rows = executeQuery(stmt, paramValues);
				if (!rows.isEmpty()) {
					executeExpands(stmt.expands(), paramValues, dataStore, stmt.targetType(), rows);
					List excluding = stmt.excluding();
					if (!excluding.isEmpty()) {
						rows.forEach(row -> row.keySet().removeAll(excluding));
					}
				}
				return selectedRows(rows);

			} catch (SQLException ex) {
				chainNextExceptions(ex);
				if (exceptionAnalyzer.isLockTimeout(ex)) {
					throw new CdsLockTimeoutException(stmt.targetType());
				}
				throw dataStoreException(ex, stmt.toNative());
			}
		}
		throw new IllegalStateException("PreparedCqnStatement must not be null");
	}

	private List> executeQuery(PreparedCqnStmt pcqn, Map paramValues) throws SQLException {
		String sql = pcqn.toNative();
		List params = pcqn.parameters();
		Setter[] setters = createSetters(params);
		try (Connection conn = ds.get()) {
			return timed.sql(() -> {
				establishSessionVariables(conn);
				try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
					bindValues(pstmt, paramValues, params, setters, pcqn.targetType());
					try (ResultSet rs = pstmt.executeQuery()) {
						return result(pcqn, rs);
					}
				}
			}, sql, List::size);
		} catch (UndeclaredThrowableException ex) { // NOSONAR
			Throwable rootCause = ExceptionAnalyzer.getRootCause(ex);
			if (rootCause instanceof SQLException e) {
				throw e;
			}
			throw dataStoreException(ex, sql);
		}
	}

	private void executeExpands(List expands, Map paramValues, CdsDataStore dataStore,
			CdsStructuredType targetType, List> rows) {
		AssociationLoader assocLoader = new AssociationLoader(dataStore, context.getDbContext(), targetType);
		for (ExpandProcessor expandProcessor : expands) {
			if (expandProcessor.isPathExpand()) { // to-many EXPANDs using path
				expandProcessor.expand(rows, dataStore, paramValues);
			} else { // parent-keys expand
				assocLoader.expand(expandProcessor, rows);
			}
			if (expandProcessor.hasCountAndLimit()) {
				expandProcessor.inlineCount(rows, dataStore, paramValues);
			}
		}
	}

	@Override
	public int[] executeUpdate(PreparedCqnStatement preparedStmt, List> parameterValues) {
		PreparedCqnStmt pcqn = (PreparedCqnStmt) preparedStmt;
		requireTransaction();

		String sql = pcqn.toNative();
		CdsEntity entity = pcqn.targetType();
		try (Connection conn = ds.get()) {
			return timed.sql(() -> {
				rejectAutoCommit(conn);
				establishSessionVariables(conn);
				try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
					List params = pcqn.parameters();
					if (parameterValues.size() > 1) {
						return executeBatch(sql, pstmt, params, parameterValues, entity);
					}
					Map values = firstEntry(parameterValues);
					Setter[] setters = createSetters(params);
					bindValues(pstmt, values, params, setters, entity);
					return new int[] { pstmt.executeUpdate() };
				}

			}, sql, rc -> Arrays.stream(rc).sum());

		} catch (CdsException e) {
			throw e;
		} catch (Exception e) {
			ExceptionHandler exHandler = new ExceptionHandler(entity, exceptionAnalyzer);
			throw exHandler.cdsException(firstEntry(parameterValues), e, sql);
		}
	}

	private int[] executeBatch(String sql, PreparedStatement pstmt, List params,
			List> entries, CdsEntity entity) throws SQLException {
		int row = 0;
		int rcPosition = 0;
		int[] result = new int[entries.size()];
		try {
			Setter[] setters = createSetters(params);
			for (Map entry : entries) {
				bindValues(pstmt, entry, params, setters, entity);
				pstmt.addBatch();
				row++;
				if (row % maxBatchSize == 0) {
					int[] rc = pstmt.executeBatch();
					rcPosition = append(result, rc, rcPosition);
				}
			}
			int[] rc = pstmt.executeBatch();
			rcPosition = append(result, rc, rcPosition); // NOSONAR
			return result;

		} catch (BatchUpdateException ex) {
			chainNextExceptions(ex);
			throw new ExceptionHandler(entity, exceptionAnalyzer).cdsBatchException(entries, rcPosition, ex, sql);
		}
	}

	private Setter[] createSetters(List params) {
		int size = params.size();
		Setter[] setters = new Setter[size + 1];
		for (int col = 1; col <= size; col++) {
			Parameter param = params.get(col - 1);
			Setter setter = binder.setter(param.type());
			setters[col] = setter::set;
		}
		return setters;
	}

	private void requireTransaction() {
		if (!transactionManager.isActive()) {
			throw new TransactionRequiredException();
		}
	}

	private void bindValues(PreparedStatement pstmt, Map values, List params,
			Setter[] binders, CdsStructuredType entity) throws SQLException {
		for (int col = 1; col <= params.size(); col++) {
			Parameter param = params.get(col - 1);
			Object value = param.get(values);
			if (value != null && Collection.class.isAssignableFrom(value.getClass())) {
				// TODO: docstore - refactor arrayed elements parameters to JSON parameter
				value = Jsonizer.json(value);
			}
			try {
				binders[col].set(pstmt, col, value);
			} catch (IllegalArgumentException | NullPointerException e) {
				throw new CdsDataException(
						"Invalid value for '" + entity + "." + param.name() + "' of type " + param.type(), e);
			}
		}
	}

	private List> result(PreparedCqnStmt pcqn, ResultSet dbResult) throws SQLException {
		CdsStructuredType targetType = pcqn.targetType();
		int columnCount = dbResult.getMetaData().getColumnCount();
		List selectValues = pcqn.selectListItems().stream().flatMap(CqnSelectListItem::ofValue)
				.toList();
		ColumnHandler[] columnHandlers = columnHandlers(targetType, selectValues, columnCount);
		try {
			List> rows = new ArrayList<>(); // TODO use top as size?
			while (dbResult.next()) {
				rows.add(extractData(dbResult, selectValues, columnHandlers));
			}
			return rows;

		} catch (SQLException e) {
			chainNextExceptions(e);
			throw new CdsDataStoreException("Failed to process result set", e);
		}
	}

	private class ColumnHandler {
		final String displayName;
		final boolean hidden;
		final boolean structuringAlias;
		final Getter valueExtractor;

		ColumnHandler(String displayName, Getter valueExtractor) {
			this.displayName = displayName;
			this.hidden = displayName.endsWith("?");
			this.structuringAlias = displayName.contains(".");
			this.valueExtractor = valueExtractor;
		}
	}

	private ColumnHandler[] columnHandlers(CdsStructuredType targetType, List selectList,
			int columnCount) {
		// on docstore entities ($json) the column count can be lower than the length of
		// selectList
		ColumnHandler[] columnHandlers = new ColumnHandler[columnCount];
		for (int i = 0; i < columnCount; i++) {
			CqnSelectListValue slv = selectList.get(i);
			String displayName = slv.displayName();
			columnHandlers[i] = createHandler(targetType, slv, displayName);
		}
		return columnHandlers;
	}

	private ColumnHandler createHandler(CdsStructuredType targetType, CqnSelectListValue slv, String displayName) {
		if (displayName.endsWith($JSON)) { // HANA JSON Document Store
			Getter typeMapper = binder.getter(CdsBaseType.LARGE_STRING, true);
			Getter valueExtractor = (result, col) -> {
				Reader reader = typeMapper.get(result, col);
				if (reader == null) {
					return emptyMap();
				}
				return JsonParser.map(reader);
			};
			String prefix = displayName.substring(0, displayName.lastIndexOf($JSON));
			return new ColumnHandler(prefix, valueExtractor);
		}
		Getter valueExtractor = binder.getter(targetType, slv);
		return new ColumnHandler(displayName, valueExtractor);
	}

	@SuppressWarnings("unchecked")
	private Map extractData(ResultSet result, List selectList,
			ColumnHandler[] columnHandlers) throws SQLException {
		Map row = new HashMap<>(selectList.size());
		for (int i = 1; i <= columnHandlers.length; i++) {
			ColumnHandler column = columnHandlers[i - 1];
			Object value = column.valueExtractor.get(result, i);
			CqnSelectListValue slv = selectList.get(i - 1);
			if (value instanceof Map && slv.ofRef().anyMatch(r -> r.lastSegment().equals($JSON))) {
				// $JSON element: TODO refactor
				mergeObject(row, slv, (Map) value, column.displayName);
			} else {
				if (column.hidden) {
					DataUtils.createPath(row, column.displayName, value != null);
				} else if (column.structuringAlias) {
					resolvePathAndAdd(row, column.displayName, value);
				} else {
					row.put(column.displayName, value);
				}
			}
		}
		return row;
	}

	private void mergeObject(Map data, CqnSelectListValue slv, Map mapValue,
			String displayName) {
		CqnElementRef jsonRef = slv.asRef();

		mapValue.forEach((k, v) -> {
			ElementRef innerRef = CQL.to(jsonRef.segments().subList(0, jsonRef.segments().size() - 1)).get(k);
			CqnSelectListValue innerSlv = innerRef.as(displayName + k);
			resolvePathAndAdd(data, innerSlv.displayName(), v);
		});
	}

	private Map firstEntry(List> valueList) {
		return valueList.isEmpty() ? emptyMap() : valueList.get(0);
	}

	@Override
	public void setSessionContext(SessionContext session) {
		this.context = context(context.getCdsModel(), context.getDbContext(), session,
				context.getDataStoreConfiguration());
		this.maxBatchSize = getMaxBatchSize(context);

		// eagerly set session variables
		if (transactionManager.isActive()) {
			try (Connection conn = ds.get()) {
				establishSessionVariables(conn);
			} catch (SQLException e) {
				throw new CdsDataStoreException("Failed to eagerly set context variables on transaction", e);
			}
		}
	}

	@VisibleForTesting
	void establishSessionVariables(Connection conn) {
		SessionContext session = context.getSessionContext();

		boolean txIsActive = transactionManager.isActive();
		ContextVars contextVars = new ContextVars(session, !txIsActive);
		contextVars.putIfRequired(LOCALE, s -> LocaleUtils.getLocaleString(s.getLocale()));
		contextVars.putIfRequired(VALID_FROM, s -> s.getValidFrom());
		contextVars.putIfRequired(VALID_TO, s -> s.getValidTo());
		contextVars.putIfRequired(APPLICATIONUSER, s -> s.getUserContext() != null ? s.getUserContext().getId() : null);
		contextVars.putIfRequired(TENANT, s -> s.getUserContext() != null ? s.getUserContext().getTenant() : null);

		if (!contextVars.isEmpty()) {
			Map vars = contextVars.get();
			try {
				context.getDbContext().getSessionVariableSetter().set(conn, vars);
				// only changed vars need to be updated in case of tx
				// so let's keep track of them
				if (txIsActive) {
					oldSessionVars.putAll(vars);
				}
			} catch (SQLException e) {
				throw new CdsDataStoreException("Failed to set context variables %s".formatted(vars), e);
			}
		}
	}

	private class ContextVars {
		private final SessionContext session;
		private final boolean enforce;

		Map contextVariables = new HashMap<>();

		public ContextVars(SessionContext session, boolean enforce) {
			this.session = session;
			this.enforce = enforce;
		}

		private void putIfRequired(String key, Function valueSupplier) {
			Object value = valueSupplier.apply(session);
			if (enforce || !Objects.equals(oldSessionVars.getOrDefault(key, INITIAL), value)) {
				contextVariables.put(key, value);
			}
		}

		private boolean isEmpty() {
			return contextVariables.isEmpty();
		}

		private Map get() {
			return contextVariables;
		}

	}

	private static int getMaxBatchSize(Context context) {
		return Math.max(1, context.getDataStoreConfiguration().getProperty(MAX_BATCH_SIZE, MAX_BATCH_SIZE_DEFAULT));
	}

	@Override
	public Capabilities capabilities() {
		return capabilities;
	}

	@Override
	public void setRollbackOnly() {
		transactionManager.setRollbackOnly();
	}

	@Override
	public void deleteAll(Stream entities) {
		DbContext db = context.getDbContext();

		int i = 0;

		try (Connection conn = ds.get()) {
			establishSessionVariables(conn);
			try (Statement stmt = conn.createStatement()) {
				Iterator iter = entities.map(e -> deleteStatement(db, e)).iterator();
				while (iter.hasNext()) {
					stmt.addBatch(iter.next());
					if (++i % maxBatchSize == 0)
						stmt.executeBatch();
				}
				stmt.executeBatch();
			}
		} catch (SQLException e) {
			throw new CdsDataStoreException("Failed to delete all entities", e);
		}
	}

	private static String deleteStatement(DbContext db, CdsEntity entity) {
		String tableName = db.getSqlMapping(entity).tableName();
		return db.getStatementResolver().deleteAll(tableName);
	}

}