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

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

There is a newer version: 3.4.0
Show newest version
/************************************************************************
 * © 2019-2023 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.docstore.DocStoreUtils.targetsDocStore;
import static com.sap.cds.util.CdsModelUtils.element;
import static com.sap.cds.util.CdsModelUtils.entity;
import static com.sap.cds.util.CdsTypeUtils.sqlTimestamp;
import static com.sap.cds.util.CqnStatementUtils.$JSON;
import static com.sap.cds.util.CqnStatementUtils.containsRef;
import static com.sap.cds.util.CqnStatementUtils.isMediaType;
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 static java.util.stream.Collectors.toList;

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.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;

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

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.Select;
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.CdsElement;
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);
	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) {
		CdsModel model = context.getCdsModel();
		CdsStructuredType targetType = CqnStatementUtils.targetType(model, select);
		CqnStructuredTypeRef ref = null;
		if (!CqnStatementUtils.containsPathExpression(select.where())) {
			ref = CqnStatementUtils.targetRef(select);
		}
		List expandProcessors = emptyList();
		if (!targetsDocStore(targetType)) {
			boolean addKeys = !select.isDistinct() && select.groupBy().isEmpty() && containsRef(select.items());
			Map> items = select.items().stream()
					.collect(Collectors.partitioningBy(CqnSelectListItem::isExpand));
			((Select) select).columns(items.getOrDefault(Boolean.FALSE, emptyList()));
			if (addKeys) { // TODO always select the keys if not explicitly excluded? include assoc keys
				Set keys = targetType.concreteNonAssociationElements()
						.filter(element -> element.isKey() && !element.getType().isStructured())
						.map(CdsElement::getName).collect(Collectors.toSet());
				CqnStatementUtils.selectHidden(keys, select);
			}
			List expands = items.get(Boolean.TRUE);
			if (expands != null) {
				boolean optimizeToManyExpands = addKeys && ref != null;
				expandProcessors = new ArrayList<>(expands.size());
				for (CqnSelectListItem expand : expands) {
					ExpandProcessor expandProcessor = ExpandProcessor.create(model, ref, targetType, (CqnExpand) expand,
							optimizeToManyExpands, select.top());
					expandProcessor.addMappingKeys(select);
					expandProcessors.add(expandProcessor);
				}
			}
		}
		SQLStatementBuilder.SQLStatement stmt = adapter.get().process(select);
		return PreparedCqnStmt.create(stmt.sql(), select.items(), expandProcessors, select.excluding(), stmt.params(),
				ref, targetType);
	}

	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");
		}
	}

	private void setContextVariables(Map contextVariables) {
		DbContext dbCtx = context.getDbContext();
		try (Connection conn = ds.get()) {
			dbCtx.getSessionVariableSetter().set(conn, contextVariables);
			oldSessionVars.putAll(contextVariables);
		} catch (SQLException e) {
			throw new CdsDataStoreException(String.format("Failed to set context variables %s", contextVariables), e);
		}
	}

	@Override
	public ResultBuilder executeQuery(PreparedCqnStatement preparedStmt, Map parameterValues,
			CdsDataStore dataStore, boolean isTransactionRequired) {
		if (isTransactionRequired) {
			requireTransaction();
		}
		PreparedCqnStmt pcqn = (PreparedCqnStmt) preparedStmt;
		String sql = pcqn.toNative();

		CdsStructuredType targetType = pcqn.targetType();
		List params = pcqn.parameters();
		Setter[] setters = createSetters(params);
		List> rows;
		try (Connection conn = ds.get()) {
			rows = timed.debugSql(() -> {
				try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
					bindValues(pstmt, parameterValues, params, setters, targetType);
					try (ResultSet rs = pstmt.executeQuery()) {
						return result(pcqn, rs, dataStore);
					}
				}
			}, sql, List::size);
		} catch (SQLException ex) {
			chainNextExceptions(ex);
			if (exceptionAnalyzer.isLockTimeout(ex)) {
				throw new CdsLockTimeoutException(targetType);
			}
			throw new CdsDataStoreException("Error executing the statement", ex);
		} catch (UndeclaredThrowableException ex) { // NOSONAR
			throw new CdsDataStoreException("Error executing the statement", ex);
		}

		if (!rows.isEmpty()) {
			// optimized to-many EXPANDs using path
			pcqn.expands().stream().filter(ExpandProcessor::isPathExpand).forEach(
					processor -> processor.expand(rows, dataStore, parameterValues));
			if (!pcqn.excluding().isEmpty()) {
				rows.forEach(row -> row.keySet().removeAll(pcqn.excluding()));
			}
		}
		return selectedRows(rows);
	}

	@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()) {
			int[] rowCount = timed.debugSql(() -> {
				rejectAutoCommit(conn);
				try (PreparedStatement pstmt = conn.prepareStatement(sql)) {
					List params = pcqn.parameters();
					if (parameterValues.size() > 1) {
						return executeBatch(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());

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

	private int[] executeBatch(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);
		}
	}

	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, CdsDataStore dataStore)
			throws SQLException {
		CdsStructuredType targetType = pcqn.targetType();
		int columnCount = dbResult.getMetaData().getColumnCount();
		List selectValues = pcqn.selectListItems().stream().flatMap(CqnSelectListItem::ofValue)
				.collect(toList());
		List parentKeyExpands = pcqn.expands().stream().filter(ExpandProcessor::isParentKeyExpand)
				.collect(Collectors.toList());
		ColumnHandler[] columnHandlers = columnHandlers(targetType, selectValues, columnCount);

		try {
			if (parentKeyExpands.isEmpty()) {
				List> rows = new ArrayList<>();
				while (dbResult.next()) {
					rows.add(extractData(dbResult, selectValues, null, columnHandlers));
				}
				return rows;
			}
			// EXPAND using parent-keys (n+1 selects) TODO optimize (cds-java 590)
			return expandByParentKeys(dbResult, dataStore, parentKeyExpands, targetType, selectValues, columnHandlers);

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

	private List> expandByParentKeys(ResultSet dbResult, CdsDataStore dataStore,
			List expands, CdsStructuredType targetType, List selectList,
			ColumnHandler[] columnHandlers) throws SQLException {

		List> rows = new ArrayList<>();
		while (dbResult.next()) {
			AssociationLoader assocLoader = new AssociationLoader(dataStore, targetType);
			Map data = extractData(dbResult, selectList, assocLoader, columnHandlers);
			for (ExpandProcessor expandProcessor : expands) {
				CqnExpand expand = expandProcessor.getExpand();
				if (logger.isDebugEnabled()) {
					logger.debug("Expand {} using parent-keys", expand.ref());
				}
				assocLoader.expand(expand, data);
			}
			rows.add(data);
		}
		return rows;
	}

	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) throws SQLException {
		// 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 (slv.value().isRef()) {
			CqnElementRef ref = slv.asRef();
			CdsType type = element(targetType, ref).getType();
			if (type.isArrayed()) {
				CdsType itemsType = type.as(CdsArrayedType.class).getItemsType();
				Getter typeMapper = binder.getter(CdsBaseType.LARGE_STRING, false);
				Getter valueExtractor = (result, col) -> {
					String json = (String) typeMapper.get(result, col);
					return StructDataParser.parseArrayOf(itemsType, json);
				};
				return new ColumnHandler(displayName, valueExtractor);
			} else if (displayName.endsWith($JSON)) {
				Getter typeMapper = binder.getter(CdsBaseType.LARGE_STRING, true);
				Getter valueExtractor = (result, col) -> {
					Reader 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);
			}
		}
		Optional cdsType = CqnStatementUtils.getCdsType(targetType, slv.value());
		if (!cdsType.isPresent()) {
			logger.debug("Cannot determine CDS type of {}", slv.value());
		}
		boolean mediaType = isMediaType(targetType, slv);
		Getter valueExtractor = binder.getter(cdsType.orElse(null), mediaType);
		return new ColumnHandler(displayName, valueExtractor);
	}

	@SuppressWarnings("unchecked")
	private Map extractData(ResultSet result, List selectList,
			AssociationLoader assocLoader, 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) { // JSON element
				mergeObject(row, slv, (Map) value, column.displayName);
			} else {
				if (assocLoader != null) {
					assocLoader.addValueOfRootEntity(slv, value);
				}
				if (column.hidden) {
					DataUtils.createPath(row, column.displayName, value != null);
				} else if (column.structuringAlias) {
					resolvePathAndAdd(row, column.displayName, value);
				} else if (value != null) {
					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);

		Map contextVariables = new HashMap<>();
		String locale = LocaleUtils.getLocaleString(session.getLocale());
		if (!Objects.equals(oldSessionVars.getOrDefault("LOCALE", INITIAL), locale)) {
			contextVariables.put("LOCALE", locale);
		}
		int timestampPrecision = capabilities.timestampPrecision();
		String validFrom = sqlTimestamp(session.getValidFrom(), timestampPrecision);
		if (!Objects.equals(oldSessionVars.getOrDefault("VALID-FROM", INITIAL), validFrom)) {
			contextVariables.put("VALID-FROM", validFrom);
		}
		String validTo = sqlTimestamp(session.getValidTo(), timestampPrecision);
		if (!Objects.equals(oldSessionVars.getOrDefault("VALID-TO", INITIAL), validTo)) {
			contextVariables.put("VALID-TO", validTo);
		}
		String user = session.getUserContext().getId() != null ? session.getUserContext().getId() : null;
		if (!Objects.equals(oldSessionVars.getOrDefault("APPLICATIONUSER", INITIAL), user)) {
			contextVariables.put("APPLICATIONUSER", user);
		}
		if (!contextVariables.isEmpty()) {
			setContextVariables(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();
	}
}