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

is.codion.framework.model.DefaultEntitySearchModel Maven / Gradle / Ivy

There is a newer version: 0.18.22
Show newest version
/*
 * This file is part of Codion.
 *
 * Codion 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.
 *
 * Codion 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 Codion.  If not, see .
 *
 * Copyright (c) 2008 - 2024, Björn Darri Sigurðsson.
 */
package is.codion.framework.model;

import is.codion.common.db.exception.DatabaseException;
import is.codion.common.state.State;
import is.codion.common.state.StateObserver;
import is.codion.common.value.Value;
import is.codion.common.value.Value.Notify;
import is.codion.common.value.ValueSet;
import is.codion.framework.db.EntityConnection.Select;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.EntityDefinition;
import is.codion.framework.domain.entity.EntityType;
import is.codion.framework.domain.entity.attribute.Column;
import is.codion.framework.domain.entity.condition.Condition;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;

import static is.codion.framework.domain.entity.condition.Condition.and;
import static is.codion.framework.domain.entity.condition.Condition.or;
import static java.util.Collections.unmodifiableCollection;
import static java.util.Collections.unmodifiableMap;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;

final class DefaultEntitySearchModel implements EntitySearchModel {

	private static final Supplier NULL_CONDITION = () -> null;
	private static final Function DEFAULT_TO_STRING = Object::toString;
	private static final String DEFAULT_SEPARATOR = ",";
	private static final String WILDCARD_MULTIPLE = "%";
	private static final String WILDCARD_SINGLE = "_";

	private final State searchStringModified = State.state();
	private final State selectionEmpty = State.state(true);

	private final EntityType entityType;
	private final Collection> columns;
	private final ValueSet entities = ValueSet.builder()
					.notify(Notify.WHEN_SET)
					.validator(new EntityValidator())
					.listener(this::reset)
					.consumer(selectedEntities -> selectionEmpty.set(selectedEntities.isEmpty()))
					.build();
	private final EntityConnectionProvider connectionProvider;
	private final Map, Settings> settings;
	private final Value searchString = Value.builder()
					.nonNull("")
					.notify(Notify.WHEN_SET)
					.listener(() -> searchStringModified.set(!searchStringRepresentsSelection()))
					.build();
	private final Value separator = Value.builder()
					.nonNull(DEFAULT_SEPARATOR)
					.listener(this::reset)
					.build();
	private final boolean singleSelection;
	private final Value> condition = Value.builder()
					.nonNull(NULL_CONDITION)
					.build();
	private final Value> stringFunction = Value.builder()
					.nonNull(DEFAULT_TO_STRING)
					.build();
	private final Value limit;
	private final String description;

	private DefaultEntitySearchModel(DefaultBuilder builder) {
		this.entityType = builder.entityType;
		this.connectionProvider = builder.connectionProvider;
		this.separator.set(builder.separator);
		this.columns = unmodifiableCollection(builder.columns);
		this.settings = unmodifiableMap(columns.stream()
						.collect(toMap(Function.identity(), column -> new DefaultSettings())));
		this.stringFunction.set(builder.stringFunction);
		this.description = builder.description == null ? createDescription() : builder.description;
		this.singleSelection = builder.singleSelection;
		this.limit = Value.value(builder.limit);
	}

	@Override
	public EntityType entityType() {
		return entityType;
	}

	@Override
	public EntityConnectionProvider connectionProvider() {
		return connectionProvider;
	}

	@Override
	public Collection> columns() {
		return columns;
	}

	@Override
	public String description() {
		return description;
	}

	@Override
	public Value entity() {
		return entities.value();
	}

	@Override
	public ValueSet entities() {
		return entities;
	}

	@Override
	public Map, Settings> settings() {
		return settings;
	}

	@Override
	public Value limit() {
		return limit;
	}

	@Override
	public Value> condition() {
		return condition;
	}

	@Override
	public Value> stringFunction() {
		return stringFunction;
	}

	@Override
	public void reset() {
		searchString.set(entitiesToString());
	}

	@Override
	public List search() {
		try {
			List result = connectionProvider.connection().select(select());
			result.sort(connectionProvider.entities().definition(entityType).comparator());

			return result;
		}
		catch (DatabaseException e) {
			throw new RuntimeException(e);
		}
	}

	@Override
	public Value searchString() {
		return searchString;
	}

	@Override
	public Value separator() {
		return separator;
	}

	@Override
	public boolean singleSelection() {
		return singleSelection;
	}

	@Override
	public StateObserver searchStringModified() {
		return searchStringModified.observer();
	}

	@Override
	public StateObserver selectionEmpty() {
		return selectionEmpty.observer();
	}

	/**
	 * @return a select instance based on this search model including any additional search condition
	 * @throws IllegalStateException in case no search columns are specified
	 */
	private Select select() {
		if (columns.isEmpty()) {
			throw new IllegalStateException("No search columns provided for search model: " + entityType);
		}
		Collection conditions = new ArrayList<>();
		String[] searchStrings = singleSelection ? new String[] {searchString.get()} : searchString.get().split(separator.get());
		for (Column column : columns) {
			Settings columnSettings = settings.get(column);
			for (String rawSearchString : searchStrings) {
				String preparedSearchString = prepareSearchString(rawSearchString, columnSettings);
				boolean containsWildcards = containsWildcards(preparedSearchString);
				if (columnSettings.caseSensitive().get()) {
					conditions.add(containsWildcards ? column.like(preparedSearchString) : column.equalTo(preparedSearchString));
				}
				else {
					conditions.add(containsWildcards ? column.likeIgnoreCase(preparedSearchString) : column.equalToIgnoreCase(preparedSearchString));
				}
			}
		}

		return Select.where(createCombinedCondition(conditions))
						.limit(limit.get())
						.build();
	}

	private Condition createCombinedCondition(Collection conditions) {
		Condition conditionCombination = or(conditions);
		Condition additionalCondition = condition.get().get();

		return additionalCondition == null ? conditionCombination : and(additionalCondition, conditionCombination);
	}

	private static String prepareSearchString(String rawSearchString, Settings settings) {
		boolean wildcardPrefix = settings.wildcardPrefix().get();
		boolean wildcardPostfix = settings.wildcardPostfix().get();
		rawSearchString = settings.spaceAsWildcard().get() ? rawSearchString.replace(' ', '%') : rawSearchString;

		return rawSearchString.equals(WILDCARD_MULTIPLE) ? WILDCARD_MULTIPLE :
						((wildcardPrefix ? WILDCARD_MULTIPLE : "") + rawSearchString.trim() + (wildcardPostfix ? WILDCARD_MULTIPLE : ""));
	}

	private boolean searchStringRepresentsSelection() {
		return (entities.get().isEmpty() && searchString.get().isEmpty()) ||
						(!entities.get().isEmpty() && entitiesToString().equals(searchString.get()));
	}

	private String createDescription() {
		EntityDefinition definition = connectionProvider.entities().definition(entityType);

		return columns.stream()
						.map(column -> definition.columns().definition(column).caption())
						.collect(joining(", "));
	}

	private String entitiesToString() {
		return entities.get().stream()
						.map(stringFunction.get())
						.collect(joining(separator.get()));
	}

	private void validateType(Entity entity) {
		if (!entity.entityType().equals(entityType)) {
			throw new IllegalArgumentException("Entities of type " + entityType + " exptected, got " + entity.entityType());
		}
	}

	private final class EntityValidator implements Value.Validator> {

		@Override
		public void validate(Set entitySet) {
			if (entitySet != null) {
				if (entitySet.size() > 1 && singleSelection) {
					throw new IllegalArgumentException("This EntitySearchModel does not allow the selection of multiple entities");
				}
				entitySet.forEach(DefaultEntitySearchModel.this::validateType);
			}
		}
	}

	private static boolean containsWildcards(String value) {
		return value.contains(WILDCARD_MULTIPLE) || value.contains(WILDCARD_SINGLE);
	}

	private static final class DefaultSettings implements Settings {

		private final State wildcardPrefixState = State.state(true);
		private final State wildcardPostfixState = State.state(true);
		private final State caseSensitiveState = State.state(false);
		private final State spaceAsWildcard = State.state(true);

		@Override
		public State wildcardPrefix() {
			return wildcardPrefixState;
		}

		@Override
		public State wildcardPostfix() {
			return wildcardPostfixState;
		}

		@Override
		public State spaceAsWildcard() {
			return spaceAsWildcard;
		}

		@Override
		public State caseSensitive() {
			return caseSensitiveState;
		}
	}

	static final class DefaultBuilder implements Builder {

		private final EntityType entityType;
		private final EntityConnectionProvider connectionProvider;
		private Collection> columns;
		private Function stringFunction = DEFAULT_TO_STRING;
		private String description;
		private boolean singleSelection = false;
		private String separator = DEFAULT_SEPARATOR;
		private Integer limit = DEFAULT_LIMIT.get();

		DefaultBuilder(EntityType entityType, EntityConnectionProvider connectionProvider) {
			this.entityType = requireNonNull(entityType);
			this.connectionProvider = requireNonNull(connectionProvider);
			this.columns = connectionProvider.entities().definition(entityType).columns().searchable();
		}

		@Override
		public Builder columns(Collection> columns) {
			if (requireNonNull(columns).isEmpty()) {
				throw new IllegalArgumentException("One or more search column is required");
			}
			validateColumns(columns);
			this.columns = columns;
			return this;
		}

		@Override
		public Builder stringFunction(Function stringFunction) {
			this.stringFunction = requireNonNull(stringFunction);
			return this;
		}

		@Override
		public Builder description(String description) {
			this.description = requireNonNull(description);
			return this;
		}

		@Override
		public Builder singleSelection(boolean singleSelection) {
			this.singleSelection = singleSelection;
			return this;
		}

		@Override
		public Builder separator(String separator) {
			this.separator = requireNonNull(separator);
			return this;
		}

		@Override
		public Builder limit(int limit) {
			this.limit = limit;
			return this;
		}

		@Override
		public EntitySearchModel build() {
			return new DefaultEntitySearchModel(this);
		}

		private void validateColumns(Collection> columns) {
			for (Column column : columns) {
				if (!entityType.equals(column.entityType())) {
					throw new IllegalArgumentException("Column '" + column + "' is not part of entity " + entityType);
				}
			}
		}
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy