Please wait. This can take some minutes ...
Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance.
Project price only 1 $
You can buy this project and download/modify it how often you want.
org.openremote.manager.asset.AssetStorageService Maven / Gradle / Ivy
/*
* Copyright 2016, OpenRemote Inc.
*
* See the CONTRIBUTORS.txt file in the distribution for a
* full listing of individual contributors.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package org.openremote.manager.asset;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.apache.camel.builder.RouteBuilder;
import org.hibernate.Session;
import org.hibernate.jdbc.AbstractReturningWork;
import org.hibernate.jpa.AvailableHints;
import org.openremote.container.message.MessageBrokerService;
import org.openremote.container.persistence.PersistenceService;
import org.openremote.container.security.AuthContext;
import org.openremote.container.timer.TimerService;
import org.openremote.manager.asset.console.ConsoleResourceImpl;
import org.openremote.manager.event.ClientEventService;
import org.openremote.manager.event.EventSubscriptionAuthorizer;
import org.openremote.manager.gateway.GatewayService;
import org.openremote.manager.security.ManagerIdentityService;
import org.openremote.manager.web.ManagerWebService;
import org.openremote.model.Constants;
import org.openremote.model.Container;
import org.openremote.model.ContainerService;
import org.openremote.model.PersistenceEvent;
import org.openremote.model.asset.*;
import org.openremote.model.asset.impl.GroupAsset;
import org.openremote.model.asset.impl.ThingAsset;
import org.openremote.model.attribute.Attribute;
import org.openremote.model.attribute.AttributeEvent;
import org.openremote.model.attribute.AttributeMap;
import org.openremote.model.event.Event;
import org.openremote.model.event.RespondableEvent;
import org.openremote.model.event.shared.EventSubscription;
import org.openremote.model.event.shared.SharedEvent;
import org.openremote.model.query.AssetQuery;
import org.openremote.model.query.LogicGroup;
import org.openremote.model.query.filter.*;
import org.openremote.model.security.ClientRole;
import org.openremote.model.security.User;
import org.openremote.model.util.LockByKey;
import org.openremote.model.util.Pair;
import org.openremote.model.util.TextUtil;
import org.openremote.model.util.ValueUtil;
import org.postgresql.util.PGobject;
import java.sql.*;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import static java.util.logging.Level.*;
import static java.util.stream.Collectors.groupingBy;
import static org.openremote.container.persistence.PersistenceService.PERSISTENCE_TOPIC;
import static org.openremote.container.persistence.PersistenceService.isPersistenceEventForEntityType;
import static org.openremote.model.attribute.Attribute.getAddedOrModifiedAttributes;
import static org.openremote.model.query.AssetQuery.*;
import static org.openremote.model.query.AssetQuery.Access.*;
import static org.openremote.model.query.filter.ValuePredicate.asPredicateOrTrue;
import static org.openremote.model.util.TextUtil.isNullOrEmpty;
import static org.openremote.model.value.MetaItemType.ACCESS_PUBLIC_READ;
import static org.openremote.model.value.MetaItemType.ACCESS_RESTRICTED_READ;
public class AssetStorageService extends RouteBuilder implements ContainerService {
protected static class PreparedAssetQuery {
final protected String querySql;
final protected List binders;
public PreparedAssetQuery(String querySql, List binders) {
this.querySql = querySql;
this.binders = binders;
}
protected void apply(EntityManager em, org.hibernate.query.Query query) {
for (ParameterBinder binder : binders) {
binder.accept(em, query);
}
}
}
public interface ParameterBinder extends BiConsumer> {
@Override
default void accept(EntityManager em, org.hibernate.query.Query st) {
try {
acceptStatement(em, st);
} catch (SQLException ex) {
throw new RuntimeException(ex);
}
}
void acceptStatement(EntityManager em, org.hibernate.query.Query st) throws SQLException;
}
private static final Logger LOG = Logger.getLogger(AssetStorageService.class.getName());
public static final int PRIORITY = MED_PRIORITY;
// protected static final Field assetParentNameField;
// protected static final Field assetParentTypeField;
//
// static {
// try {
// assetParentNameField = Asset.class.getDeclaredField("parentName");
// assetParentNameField.setAccessible(true);
// assetParentTypeField = Asset.class.getDeclaredField("parentType");
// assetParentTypeField.setAccessible(true);
// } catch (NoSuchFieldException e) {
// LOG.log(Level.SEVERE, "Failed to find Asset parentName and/or parentType fields in the Asset class", e);
// throw new IllegalStateException();
// }
// }
@SuppressWarnings("unchecked")
public static EventSubscriptionAuthorizer assetInfoAuthorizer(ManagerIdentityService identityService, AssetStorageService assetStorageService) {
return (requestRealm, auth, sub) -> {
@SuppressWarnings("unchecked")
EventSubscription subscription = (EventSubscription)sub;
// Only Asset> filters allowed
if (subscription.getFilter() != null && !(subscription.getFilter() instanceof AssetFilter)) {
return false;
}
AssetFilter filter = (AssetFilter) subscription.getFilter();
if (filter == null) {
filter = new AssetFilter<>();
subscription.setFilter(filter);
}
// Superusers can get events for any asset in any realm
if (auth != null && auth.isSuperUser()) {
return true;
}
requestRealm = filter.getRealm() != null ? filter.getRealm() : requestRealm;
boolean isAnonymous = auth == null;
boolean isRestricted = identityService.getIdentityProvider().isRestrictedUser(auth);
String userId = !isAnonymous ? auth.getUserId() : null;
String realm = requestRealm != null ? requestRealm : !isAnonymous ? auth.getAuthenticatedRealmName() : null;
if (realm == null) {
LOG.info("Anonymous AssetInfo subscriptions must specify a realm");
return false;
}
if (isAnonymous || (requestRealm != null && !requestRealm.equals(auth.getAuthenticatedRealmName()))) {
// Users can only request public assets in different realms so force public events in the filter
filter.setPublicEvents(true);
}
// Regular user must have role
if (!filter.isPublicEvents() && (isAnonymous || !auth.hasResourceRole(ClientRole.READ_ASSETS.getValue(), Constants.KEYCLOAK_CLIENT_ID))) {
return false;
}
filter.setRealm(realm);
if (isRestricted) {
filter.setRestrictedEvents(true);
// Restricted user can only subscribe to assets they are linked to so go fetch these
// TODO: Update asset IDs when user asset links are modified
filter.setUserAssetIds(
assetStorageService.findUserAssetLinks(realm, userId, null)
.stream()
.map(userAssetLink -> userAssetLink.getId().getAssetId())
.toList()
);
}
if (filter.getAssetIds() != null) {
// Client can subscribe to several assets
for (String assetId : filter.getAssetIds()) {
Asset> asset = assetStorageService.find(assetId, false);
// If the asset doesn't exist, subscription must fail
if (asset == null)
return false;
if (isRestricted) {
// Restricted users can only get events for their linked assets
if (!filter.getUserAssetIds().contains(assetId))
return false;
} else {
// Regular users can only get events for assets in their realm
if (!asset.getRealm().equals(realm))
return false;
}
}
}
return true;
};
}
protected TimerService timerService;
protected PersistenceService persistenceService;
protected ManagerIdentityService identityService;
protected ClientEventService clientEventService;
protected GatewayService gatewayService;
protected ExecutorService executorService;
protected final LockByKey assetLocks = new LockByKey();
/**
* Will evaluate each {@link CalendarEventPredicate} and apply it depending on the {@link LogicGroup} type
* that each appears in. It tests the recurrence rule as simple start/end is checked in the DB query.
*/
protected static boolean calendarEventPredicateMatches(Supplier currentMillisSupplier, AssetQuery query, Asset> asset) {
if (query.attributes == null) {
return true;
}
return calendarEventPredicateMatches(currentMillisSupplier, query.attributes, asset);
}
protected static boolean calendarEventPredicateMatches(Supplier currentMillisSupplier, LogicGroup group, Asset> asset) {
boolean isOr = group.operator == LogicGroup.Operator.OR;
boolean matches = true;
if (group.items != null) {
for (AttributePredicate attributePredicate : group.items) {
if (attributePredicate.value instanceof CalendarEventPredicate) {
Predicate namePredicate = asPredicateOrTrue(currentMillisSupplier, attributePredicate.name);
Predicate valuePredicate = asPredicateOrTrue(currentMillisSupplier, attributePredicate.value);
List> matchedAttributes = asset.getAttributes().stream()
.filter(attr -> namePredicate.test(attr.getName())).toList();
matches = true;
if (!matchedAttributes.isEmpty()) {
for (Attribute> attribute : matchedAttributes) {
matches = valuePredicate.test(attribute.getValue().orElse(null));
if (isOr && matches) {
break;
}
if (!isOr && !matches) {
break;
}
}
}
if (isOr && matches) {
break;
}
if (!isOr && !matches) {
break;
}
}
}
}
if (isOr && matches) {
return true;
}
if (!isOr && !matches) {
return false;
}
if (group.groups != null) {
for (LogicGroup childGroup : group.groups) {
matches = calendarEventPredicateMatches(currentMillisSupplier, childGroup, asset);
if (isOr && matches) {
break;
}
if (!isOr && !matches) {
break;
}
}
}
return matches;
}
@Override
public int getPriority() {
return PRIORITY;
}
@Override
public void init(Container container) throws Exception {
timerService = container.getService(TimerService.class);
persistenceService = container.getService(PersistenceService.class);
identityService = container.getService(ManagerIdentityService.class);
clientEventService = container.getService(ClientEventService.class);
gatewayService = container.getService(GatewayService.class);
executorService = container.getExecutor();
EventSubscriptionAuthorizer assetEventAuthorizer = AssetStorageService.assetInfoAuthorizer(identityService, this);
clientEventService.addSubscriptionAuthorizer((realm, auth, subscription) -> {
if (!subscription.isEventType(AssetEvent.class)) {
return false;
}
return assetEventAuthorizer.authorise(realm, auth, subscription);
});
clientEventService.addEventAuthorizer((realm, auth, event) -> {
boolean authorize = event instanceof HasAssetQuery;
if (event instanceof ReadAssetEvent readAssetEvent) {
if (readAssetEvent.getAssetQuery() == null) {
LOG.info("Read asset event must specify an asset ID");
return false;
}
} else if (event instanceof ReadAttributeEvent readAttributeEvent) {
if (readAttributeEvent.getAssetQuery() == null) {
LOG.info("Read attribute event must specify an asset ID");
return false;
}
}
return authorize && authorizeAssetQuery(((HasAssetQuery)event).getAssetQuery(), auth, realm);
});
// TODO: Update once client event service supports interface subscriptions
clientEventService.addSubscription(ReadAssetEvent.class, this::onReadRequest);
clientEventService.addSubscription(ReadAssetsEvent.class, this::onReadRequest);
clientEventService.addSubscription(ReadAttributeEvent.class, this::onReadRequest);
container.getService(ManagerWebService.class).addApiSingleton(
new AssetResourceImpl(
container.getService(TimerService.class),
identityService,
this,
container.getService(MessageBrokerService.class),
clientEventService
)
);
container.getService(ManagerWebService.class).addApiSingleton(
new ConsoleResourceImpl(container.getService(TimerService.class),
identityService,
this,
clientEventService)
);
container.getService(MessageBrokerService.class).getContext().addRoutes(this);
}
@Override
public void start(Container container) throws Exception {
}
@Override
public void stop(Container container) throws Exception {
}
@SuppressWarnings("unchecked")
@Override
public void configure() throws Exception {
// If any asset was modified in the database, publish events
from(PERSISTENCE_TOPIC)
.routeId("Persistence-Asset")
.filter(isPersistenceEventForEntityType(Asset.class))
.process(exchange -> publishModificationEvents(exchange.getIn().getBody(PersistenceEvent.class)));
}
/**
* Authorizes an {@link AssetQuery} by validating it against security constraints and/or applying default options to the query
* based on security constraints.
*/
public boolean authorizeAssetQuery(AssetQuery query, AuthContext authContext, String requestRealm) {
boolean isAnonymous = authContext == null;
boolean isSuperUser = authContext != null && authContext.isSuperUser();
boolean isRestricted = identityService.getIdentityProvider().isRestrictedUser(authContext);
// Take realm from query, requestRealm or lastly auth context (super users can query with no realm)
String realm = query.realm != null ? query.realm.name : requestRealm != null ? requestRealm : (!isSuperUser && authContext != null ? authContext.getAuthenticatedRealmName() : null);
if (!isSuperUser) {
if (TextUtil.isNullOrEmpty(realm)) {
String msg = "Realm must be specified to read assets";
LOG.finest(msg);
return false;
}
if (isAnonymous) {
if (query.access != null && query.access != PUBLIC) {
String msg = "Only public access allowed for anonymous requests";
LOG.finest(msg);
return false;
}
query.access = PUBLIC;
} else if (isRestricted) {
if (query.access == PRIVATE) {
String msg = "Only public or restricted access allowed for restricted requests";
LOG.finest(msg);
return false;
}
if (query.access == null) {
query.access = PROTECTED;
}
}
if (query.access != PUBLIC && !authContext.hasResourceRole(ClientRole.READ_ASSETS.getValue(), Constants.KEYCLOAK_CLIENT_ID)) {
String msg = "User must have '" + ClientRole.READ_ASSETS.getValue() + "' role to read non public assets";
LOG.finest(msg);
return false;
}
if (query.access != PUBLIC && !realm.equals(authContext.getAuthenticatedRealmName())) {
String msg = "Realm must match authenticated realm for non public access queries";
LOG.finest(msg);
return false;
}
query.realm = new RealmPredicate(realm);
if (query.access == PROTECTED) {
query.userIds(authContext.getUserId());
}
}
if (!identityService.getIdentityProvider().isRealmActiveAndAccessible(authContext, realm)) {
String msg = "Realm is not present or is inactive";
LOG.finest(msg);
return false;
}
return true;
}
public Asset> find(String assetId) {
if (assetId == null)
throw new IllegalArgumentException("Can't query null asset identifier");
return find(new AssetQuery().ids(assetId));
}
@SuppressWarnings("unchecked")
public > T find(String assetId, Class assetType) {
Asset> asset = find(assetId);
if (asset != null && !assetType.isAssignableFrom(asset.getClass())) {
asset = null;
}
return (T)asset;
}
/**
* @param loadComplete If the whole asset data (including attributes) should be loaded.
*/
public Asset> find(String assetId, boolean loadComplete) {
if (assetId == null)
throw new IllegalArgumentException("Can't query null asset identifier");
return find(new AssetQuery().select(loadComplete ? null : new Select().excludeAttributes()).ids(assetId));
}
@SuppressWarnings("unchecked")
public > T find(String assetId, boolean loadComplete, Class assetType) {
Asset> asset = find(assetId, loadComplete);
if (asset != null && !assetType.isAssignableFrom(asset.getClass())) {
asset = null;
}
return (T)asset;
}
/**
* @param loadComplete If the whole asset data (including path and attributes) should be loaded.
*/
public Asset> find(EntityManager em, String assetId, boolean loadComplete) {
return find(em, assetId, loadComplete, PRIVATE);
}
/**
* @param loadComplete If the whole asset data (including path and attributes) should be loaded.
* @param access The required access permissions of the asset data.
*/
public Asset> find(String assetId, boolean loadComplete, Access access) {
if (assetId == null)
throw new IllegalArgumentException("Can't query null asset identifier");
return find(new AssetQuery()
.select(loadComplete
? null
: new Select().excludeAttributes())
.ids(assetId)
.access(access));
}
public Asset> find(AssetQuery query) {
return persistenceService.doReturningTransaction(em -> find(em, query));
}
public Asset> find(EntityManager em, AssetQuery query) {
query.limit = 1;
List> result = findAll(em, query);
if (result.isEmpty())
return null;
return result.get(0);
}
public List> findAll(AssetQuery query) {
return persistenceService.doReturningTransaction(em -> findAll(em, query));
}
public List findNames(String... ids) {
if (ids == null || ids.length == 0)
return new ArrayList<>();
// TODO: Do this in a loop in reasonably sized batches
return persistenceService.doReturningTransaction(em -> {
List result = em.createQuery("select a.id, a.name from Asset a where a.id in :ids",
Object[].class)
.setParameter("ids", Arrays.asList(ids))
.getResultList();
List names = new ArrayList<>();
for (String id : ids) {
for (Object[] tuple : result) {
if (tuple[0].equals(id)) {
names.add((String) tuple[1]);
break;
}
}
}
return names;
});
}
/**
* @return The current stored asset state.
* @throws IllegalArgumentException if the realm or parent is illegal, or other asset constraint is violated.
*/
public > T merge(T asset) throws IllegalStateException, ConstraintViolationException {
return merge(asset, false);
}
/**
* @param overrideVersion If true
, the merge will override the data in the database, independent of
* version.
* @return The current stored asset state.
* @throws IllegalArgumentException if the realm or parent is illegal, or other asset constraint is violated.
*/
public > T merge(T asset, boolean overrideVersion) throws IllegalStateException, ConstraintViolationException {
return merge(asset, overrideVersion, false, null);
}
/**
* @param userName the user which this asset needs to be assigned to.
* @return The current stored asset state.
* @throws IllegalArgumentException if the realm or parent is illegal, or other asset constraint is violated.
*/
public > T merge(T asset, String userName) throws IllegalStateException, ConstraintViolationException {
return merge(asset, false, false, userName);
}
/**
* Merge the requested {@link Asset} checking that it meets all constraint requirements before doing so; the
* timestamp of each {@link Attribute} will also be updated to the current system time if it has changed to assist
* with {@link Attribute} equality (see {@link Attribute#equals}).
* @param overrideVersion If true
, the merge will override the data in the database, independent of
* version.
* @param skipGatewayCheck Don't check if asset is a gateway asset and merge asset into local persistence service.
* @param userName the user which this asset needs to be assigned to.
* @return The current stored asset state.
* @throws IllegalArgumentException if the realm or parent is illegal, or other asset constraint is violated.
*/
@SuppressWarnings("unchecked")
public > T merge(T asset, boolean overrideVersion, boolean skipGatewayCheck, String userName) throws IllegalStateException, ConstraintViolationException {
if (LOG.isLoggable(Level.FINEST)) {
LOG.finest("Merging asset: " + asset);
}
if (asset.getId() != null || asset.getParentId() != null) {
String gatewayId = gatewayService.getLocallyRegisteredGatewayId(asset.getId(), asset.getParentId());
if (!skipGatewayCheck && gatewayId != null) {
String msg = "Cannot directly add or modify a descendant asset on a gateway asset, do this on the gateway itself: Gateway ID=" + gatewayId;
LOG.info(msg);
throw new IllegalStateException(msg);
}
}
String assetId = asset.getId() != null ? asset.getId() : "";
return withAssetLock(assetId, () -> persistenceService.doReturningTransaction(em -> {
long startTime = System.currentTimeMillis();
// Validate realm
if (asset.getRealm() == null) {
String msg = "Asset realm must be set : asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
// Do standard JSR-380 validation on the asset (includes custom validation using descriptors and constraints)
Set>> validationFailures = ValueUtil.validate(asset);
if (!validationFailures.isEmpty()) {
String msg = "Asset merge failed as asset has failed constraint validation: asset=" + asset;
ConstraintViolationException ex = new ConstraintViolationException(validationFailures);
LOG.log(Level.WARNING, msg + ", exception=" + ex.getMessage());
throw ex;
}
T existingAsset = TextUtil.isNullOrEmpty(asset.getId()) ? null : (T)em.find(Asset.class, asset.getId());
if (existingAsset != null) {
// Verify type has not been changed
if (!existingAsset.getType().equals(asset.getType())) {
String msg = "Asset type cannot be changed: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
if (!existingAsset.getRealm().equals(asset.getRealm())) {
String msg = "Asset realm cannot be changed: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
// Update timestamp on modified attributes this allows fast equality checking
asset.getAttributes().stream().forEach(attr ->
existingAsset.getAttribute(attr.getName()).ifPresent(existingAttr -> {
// If attribute is modified make sure the timestamp is also updated to allow simple equality
if (!attr.deepEquals(existingAttr) && attr.getTimestamp().orElse(0L) <= existingAttr.getTimestamp().orElse(0L)) {
// In the unlikely situation that we are in the same millisecond as last update
// we will always ensure a delta of >= 1ms
attr.setTimestamp(Math.max(existingAttr.getTimestamp().orElse(0L)+1, timerService.getCurrentTimeMillis()));
}
}));
// If this is real merge and desired, copy the persistent version number over the detached
// version, so the detached state always wins and this update will go through and ignore
// concurrent updates
if (overrideVersion) {
asset.setVersion(existingAsset.getVersion());
}
}
if (!identityService.getIdentityProvider().realmExists(asset.getRealm())) {
String msg = "Asset realm not found or is inactive: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
if (asset.getParentId() != null && asset.getParentId().equals(asset.getId())) {
String msg = "Asset parent cannot be the asset: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
// Validate parent only if asset is new or parent has changed
if ((existingAsset == null && asset.getParentId() != null)
|| (existingAsset != null && asset.getParentId() != null && !asset.getParentId().equals(existingAsset.getParentId()))) {
Asset> parent = find(em, asset.getParentId(), true);
// The parent must exist
if (parent == null) {
String msg = "Asset parent not found: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
// The parent can not be a child of the asset
if (parent.pathContains(asset.getId())) {
String msg = "Asset parent cannot be a descendant of the asset: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
// The parent should be in the same realm
if (!parent.getRealm().equals(asset.getRealm())) {
String msg = "Asset parent must be in the same realm: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
// if parent is of type group then this child asset must have the correct type
if (parent instanceof GroupAsset) {
String childAssetType = parent.getAttributes().getValue(GroupAsset.CHILD_ASSET_TYPE)
.orElseThrow(() -> {
String msg = "Asset parent is of type GROUP but the childAssetType attribute is invalid: asset=" + asset;
LOG.warning(msg);
return new IllegalStateException(msg);
});
// Look through type hierarchy for a match - this allows sub types
Class> clazz = asset.getClass();
boolean typeMatch = childAssetType.equals(clazz.getSimpleName());
while (!typeMatch && clazz != Asset.class) {
clazz = clazz.getSuperclass();
typeMatch = childAssetType.equals(clazz.getSimpleName());
}
if (!typeMatch) {
String msg = "Asset type does not match parent GROUP asset's childAssetType attribute: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
}
}
// Validate group child asset type attribute
if (asset instanceof GroupAsset) {
String childAssetType = ((GroupAsset)asset).getChildAssetType()
.map(childAssetTypeString -> TextUtil.isNullOrEmpty(childAssetTypeString) ? null : childAssetTypeString)
.orElseThrow(() -> {
String msg = "Asset of type GROUP childAssetType attribute must be a valid string: asset=" + asset;
LOG.warning(msg);
return new IllegalStateException(msg);
});
String existingChildAssetType = existingAsset != null ? ((GroupAsset)existingAsset)
.getChildAssetType()
.orElseThrow(() -> {
String msg = "Asset of type GROUP childAssetType attribute must be a valid string: asset=" + asset;
LOG.warning(msg);
return new IllegalStateException(msg);
}) : childAssetType;
if (!childAssetType.equals(existingChildAssetType)) {
String msg = "Asset of type GROUP so childAssetType attribute cannot be changed: asset=" + asset;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
}
// Update all empty attribute timestamps with server-time (a caller which doesn't have a
// reliable time source such as a browser should clear the timestamp when setting an attribute
// value).
asset.getAttributes().forEach(attribute -> {
if (!attribute.hasExplicitTimestamp()) {
attribute.setTimestamp(timerService.getCurrentTimeMillis());
}
});
// If username present
User user = null;
if (!TextUtil.isNullOrEmpty(userName)) {
user = identityService.getIdentityProvider().getUserByUsername(asset.getRealm(), userName);
if (user == null) {
String msg = "User not found: " + userName;
LOG.warning(msg);
throw new IllegalStateException(msg);
}
}
T updatedAsset = em.merge(asset);
if (LOG.isLoggable(FINE)) {
LOG.fine("Asset merge took: " + (System.currentTimeMillis() - startTime) + "ms");
}
if (user != null) {
createUserAssetLinks(em, Collections.singletonList(new UserAssetLink(user.getRealm(), user.getId(), updatedAsset.getId())));
}
if (existingAsset == null && updatedAsset instanceof ThingAsset && !ThingAsset.DESCRIPTOR.getName().equals(updatedAsset.getType())) {
// When an asset is first saved then any custom type is not persisted as JPA will set it to ThingAsset so we need to override
// We don't need to do this when updating an existing asset as JPA doesn't overwrite the type - if this changes in future it
// should be detected by tests
em.createNativeQuery("update ASSET set type = ? where id = ?;")
.setParameter(1, updatedAsset.getType())
.setParameter(2, updatedAsset.getId())
.executeUpdate();
}
return updatedAsset;
}));
}
/**
* @return true
if the assets were deleted, false if any of the assets still have children and can't be deleted.
*/
public boolean delete(List assetIds) {
return delete(assetIds, false);
}
public boolean delete(List assetIds, boolean skipGatewayCheck) {
List ids = new ArrayList<>(assetIds);
if (!skipGatewayCheck) {
// Don't allow deletion of gateway descendant assets (they must be deleted on the gateway itself)
boolean gatewayDescendant = ids.stream().anyMatch(id -> gatewayService.getLocallyRegisteredGatewayId(id, null) != null);
if (gatewayDescendant) {
String msg = "Cannot delete one or more requested assets as they are descendants of a gateway asset";
LOG.info(msg);
throw new IllegalStateException(msg);
}
List gatewayIds = ids.stream().filter(id -> gatewayService.isLocallyRegisteredGateway(id)).toList();
if (!gatewayIds.isEmpty()) {
// Handle gateway asset deletion in a special way
ids.removeAll(gatewayIds);
for (String gatewayId : gatewayIds) {
try {
boolean deleted = gatewayService.deleteGateway(gatewayId);
if (!deleted) {
return false;
}
} catch (Exception e) {
LOG.log(WARNING, "Failed to delete gateway asset: " + gatewayId, e);
return false;
}
}
}
}
if (!ids.isEmpty()) {
try {
// Get locks for each asset ID
ids.forEach(assetLocks::lock);
persistenceService.doTransaction(em -> {
List> assets = em
.createQuery("select a from Asset a where not exists(select child.id from Asset child where child.parentId = a.id and not child.id in :ids) and a.id in :ids", Asset.class)
.setParameter("ids", ids)
.getResultList().stream().map(asset -> (Asset>) asset).collect(Collectors.toList());
if (ids.size() != assets.size()) {
throw new IllegalArgumentException("Cannot delete one or more requested assets as they either have children or don't exist");
}
assets.sort(Comparator.comparingInt((Asset> asset) -> asset.getPath() == null ? 0 : asset.getPath().length).reversed());
assets.forEach(em::remove);
em.flush();
});
} catch (Exception e) {
LOG.log(SEVERE, "Failed to delete one or more requested assets: " + Arrays.toString(assetIds.toArray()), e);
return false;
} finally {
// Release all of the locks
ids.forEach(assetLocks::unlock);
}
}
return true;
}
public boolean isUserAsset(String assetId) {
return isUserAsset((String) null, assetId);
}
public boolean isUserAsset(String userId, String assetId) {
if (TextUtil.isNullOrEmpty(userId) || TextUtil.isNullOrEmpty(assetId)) {
return false;
}
return persistenceService.doReturningTransaction(entityManager -> {
try {
String queryStr = TextUtil.isNullOrEmpty(userId) ?
"select count(ual) from UserAssetLink ual where ual.id.assetId = :assetId" :
"select count(ual) from UserAssetLink ual where ual.id.userId = :userId and ual.id.assetId = :assetId";
TypedQuery query = entityManager.createQuery(
queryStr,
Long.class).setParameter("assetId", assetId);
if (!TextUtil.isNullOrEmpty(userId)) {
query.setParameter("userId", userId);
}
return query.getSingleResult() > 0;
} catch (NoResultException ex) {
return false;
}
});
}
public boolean isUserAsset(List userIds, String assetId) {
if (userIds == null || userIds.isEmpty() || TextUtil.isNullOrEmpty(assetId)) {
return false;
}
return persistenceService.doReturningTransaction(entityManager -> {
try {
return entityManager.createQuery(
"select count(ual) from UserAssetLink ual where ual.id.userId in :userIds and ual.id.assetId = :assetId",
Long.class)
.setParameter("userIds", userIds)
.setParameter("assetId", assetId)
.getSingleResult() > 0;
} catch (NoResultException ex) {
return false;
}
});
}
public boolean isUserAssets(String userId, List assetIds) {
if (TextUtil.isNullOrEmpty(userId) || assetIds == null || assetIds.isEmpty()) {
return false;
}
return persistenceService.doReturningTransaction(entityManager -> {
try {
return entityManager.createQuery(
"select count(ual) from UserAssetLink ual where ual.id.userId = :userId and ual.id.assetId in :assetIds",
Long.class)
.setParameter("userId", userId)
.setParameter("assetIds", assetIds)
.getSingleResult() == assetIds.size();
} catch (NoResultException ex) {
return false;
}
});
}
/**
* Indicates if the specified asset belongs to the specified realm
*/
public boolean isRealmAsset(String realm, String assetId) {
return isRealmAssets(realm, Collections.singletonList(assetId));
}
/**
* Indicates if the specified assets belong to the specified realm
*/
public boolean isRealmAssets(String realm, List assetIds) {
if (TextUtil.isNullOrEmpty(realm) || assetIds == null || assetIds.isEmpty()) {
return false;
}
return persistenceService.doReturningTransaction(entityManager -> {
try {
return entityManager.createQuery(
"select count(a) from Asset a where a.realm = :realm and a.id in :assetIds",
Long.class)
.setParameter("realm", realm)
.setParameter("assetIds", assetIds)
.getSingleResult() == assetIds.size();
} catch (NoResultException ex) {
return false;
}
});
}
public boolean isDescendantAsset(String parentAssetId, String assetId) {
return isDescendantAssets(parentAssetId, Collections.singletonList(assetId));
}
public boolean isDescendantAssets(String parentAssetId, List assetIds) {
return persistenceService.doReturningTransaction(entityManager -> entityManager.unwrap(Session.class).doReturningWork(new AbstractReturningWork<>() {
@Override
public Boolean execute(Connection connection) throws SQLException {
try (PreparedStatement st = connection.prepareStatement("select count(*) from Asset a where a.path ~ lquery(?) AND a.id = ANY(?)")) {
st.setString(1, "*." + parentAssetId + ".*");
st.setArray(2, st.getConnection().createArrayOf("text", assetIds.toArray()));
ResultSet rs = st.executeQuery();
return rs.next() && rs.getInt(1) == assetIds.size();
} catch (SQLException ex) {
LOG.log(Level.SEVERE, "Failed to execute isDescendantAssets query", ex);
return false;
}
}
}));
}
public List findUserAssetLinks(String realm, String userId, String assetId) {
return findUserAssetLinks(
realm,
userId != null ? Collections.singletonList(userId) : null,
assetId != null ? Collections.singletonList(assetId) : null);
}
public List findUserAssetLinks(String realm, Collection userIds, Collection assetIds) {
if (realm == null && (userIds == null || userIds.isEmpty()) && (assetIds == null || assetIds.isEmpty())) {
return Collections.emptyList();
}
return persistenceService.doReturningTransaction(em ->
buildFindUserAssetLinksQuery(em, realm, userIds, assetIds).getResultList());
}
protected TypedQuery buildFindUserAssetLinksQuery(EntityManager em, String realm, Collection userIds, Collection assetIds) {
StringBuilder sb = new StringBuilder();
Map parameters = new HashMap<>(3);
sb.append("select ua from UserAssetLink ua where 1=1");
if (!isNullOrEmpty(realm)) {
sb.append(" and ua.id.realm in :realm");
parameters.put("realm", realm);
}
if (userIds != null && !userIds.isEmpty()) {
sb.append(" and ua.id.userId in :userId");
parameters.put("userId", userIds);
}
if (assetIds != null && !assetIds.isEmpty()) {
sb.append(" and ua.id.assetId in :assetId");
parameters.put("assetId", assetIds);
}
sb.append(" order by ua.createdOn desc");
TypedQuery query = em.createQuery(sb.toString(), UserAssetLink.class);
parameters.forEach(query::setParameter);
return query;
}
/* ####################################################################################### */
/**
* Delete specific {@link UserAssetLink}s.
*/
public void deleteUserAssetLinks(List userAssetLinks) {
if (userAssetLinks == null || userAssetLinks.isEmpty()) {
return;
}
Set assetIds = new HashSet<>(userAssetLinks.size());
Set userIds = new HashSet<>(userAssetLinks.size());
userAssetLinks.forEach(userAssetLink -> {
userIds.add(userAssetLink.getId().getUserId());
assetIds.add(userAssetLink.getId().getAssetId());
});
List existingLinks = new ArrayList<>();
persistenceService.doTransaction(entityManager -> {
existingLinks.addAll(buildFindUserAssetLinksQuery(entityManager, null, userIds.stream().toList(), assetIds.stream().toList())
.getResultList().stream().filter(userAssetLinks::contains).toList());
if (existingLinks.size() != userAssetLinks.size()) {
throw new IllegalArgumentException("Cannot delete one or more requested user asset links as they don't exist");
}
StringBuilder sb = new StringBuilder("DELETE FROM user_asset_link WHERE (1=0");
IntStream.range(0, userAssetLinks.size()).forEach(i -> sb.append(" OR (asset_id=?")
.append((3*i)+1)
.append(" AND user_id=?")
.append((3*i)+2)
.append(" AND realm=?")
.append((3*i)+3)
.append(")"));
sb.append(")");
Query query = entityManager.createNativeQuery(sb.toString());
IntStream.range(0, userAssetLinks.size()).forEach(i -> {
UserAssetLink userAssetLink = userAssetLinks.get(i);
query.setParameter((3*i)+1, userAssetLink.getId().getAssetId());
query.setParameter((3*i)+2, userAssetLink.getId().getUserId());
query.setParameter((3*i)+3, userAssetLink.getId().getRealm());
});
int deleteCount = query.executeUpdate();
if (deleteCount != userAssetLinks.size()) {
throw new IllegalArgumentException("Cannot delete one or more requested user asset links as they don't exist");
}
});
existingLinks.forEach(userAssetLink ->
persistenceService.publishPersistenceEvent(
PersistenceEvent.Cause.DELETE,
null,
userAssetLink,
UserAssetLink.class,
null,
null));
if (LOG.isLoggable(FINE)) {
LOG.fine("Deleted user asset links: count=" + userAssetLinks.size() + ", links=" + userAssetLinks.stream().map(Object::toString).collect(Collectors.joining(", ")));
}
}
/**
* Delete all {@link UserAssetLink}s for the specified {@link User}
*/
public void deleteUserAssetLinks(String userId) {
persistenceService.doTransaction(entityManager -> {
Query query = entityManager.createQuery("DELETE FROM UserAssetLink ual WHERE ual.id.userId = ?1");
query.setParameter(1, userId);
int deleteCount = query.executeUpdate();
LOG.fine("Deleted all user asset links for user: user ID=" + userId + ", count=" + deleteCount);
});
}
/**
* Create specified {@link UserAssetLink}s.
*/
public void storeUserAssetLinks(List userAssetLinks) {
if (userAssetLinks == null || userAssetLinks.isEmpty()) {
return;
}
Set assetIds = new HashSet<>(userAssetLinks.size());
Set userIds = new HashSet<>(userAssetLinks.size());
userAssetLinks.forEach(userAssetLink -> {
userIds.add(userAssetLink.getId().getUserId());
assetIds.add(userAssetLink.getId().getAssetId());
});
persistenceService.doTransaction(em -> {
List existingLinks = buildFindUserAssetLinksQuery(em, null, userIds.stream().toList(), assetIds.stream().toList())
.getResultList();
List newLinks = userAssetLinks.stream()
.filter(userAssetLink -> !existingLinks.contains(userAssetLink))
.toList();
createUserAssetLinks(em, newLinks);
});
}
public R withAssetLock(String assetId, Supplier action) {
try {
assetLocks.lock(assetId);
return action.get();
} finally {
assetLocks.unlock(assetId);
}
}
public void withAssetLock(String assetId, Runnable action) {
withAssetLock(assetId, action);
}
protected void createUserAssetLinks(EntityManager em, List userAssets) {
em.unwrap(Session.class).doWork(connection -> {
if (LOG.isLoggable(FINE)) {
LOG.fine("Storing user asset links: count=" + userAssets.size() + ", links=" + userAssets.stream().map(Object::toString).collect(Collectors.joining(", ")));
}
PreparedStatement st;
try {
st = connection.prepareStatement("INSERT INTO USER_ASSET_LINK (asset_id, realm, user_id, created_on) VALUES (?, ?, ?, ?) ON CONFLICT (asset_id, realm, user_id) DO NOTHING");
for (UserAssetLink userAssetLink : userAssets) {
st.setString(1, userAssetLink.getId().getAssetId());
st.setString(2, userAssetLink.getId().getRealm());
st.setObject(3, userAssetLink.getId().getUserId());
st.setTimestamp(4, new Timestamp(timerService.getCurrentTimeMillis()));
st.addBatch();
}
st.executeBatch();
// Create a persistence event for each one
userAssets.forEach(userAssetLink ->
persistenceService.publishPersistenceEvent(
PersistenceEvent.Cause.CREATE,
userAssetLink,
null,
UserAssetLink.class,
null,
null)
);
} catch (Exception e) {
String msg = "Failed to create user asset links: count=" + userAssets.size();
LOG.log(Level.WARNING, msg, e);
throw new IllegalStateException(msg, e);
}
});
}
protected Asset> find(EntityManager em, String assetId, boolean loadComplete, Access access) {
if (assetId == null)
throw new IllegalArgumentException("Can't query null asset identifier");
return find(
em,
new AssetQuery()
.select(loadComplete
? null
: new Select().excludeAttributes())
.ids(assetId)
.access(access)
);
}
@SuppressWarnings("unchecked")
protected List> findAll(EntityManager em, AssetQuery query) {
long startMillis = System.currentTimeMillis();
if (query.access == null)
query.access = PRIVATE;
// Do some sanity checks on query values and return empty result set if empty query parameters
if (query.ids != null && query.ids.length == 0) {
return Collections.emptyList();
}
if (query.paths != null && query.paths.length == 0) {
return Collections.emptyList();
}
if (query.types != null && query.types.length == 0) {
return Collections.emptyList();
}
if (query.names != null && query.names.length == 0) {
return Collections.emptyList();
}
if (query.userIds != null && query.userIds.length == 0) {
return Collections.emptyList();
}
if (query.parents != null && query.parents.length == 0) {
return Collections.emptyList();
}
// Default to order by creation date if the query may return multiple results
if (query.orderBy == null && query.ids == null)
query.orderBy = new OrderBy(OrderBy.Property.CREATED_ON);
Pair queryAndContainsCalendarPredicate = buildQuery(query, timerService::getCurrentTimeMillis);
PreparedAssetQuery querySql = queryAndContainsCalendarPredicate.key;
boolean containsCalendarPredicate = queryAndContainsCalendarPredicate.value;
if (containsCalendarPredicate && (query.select != null && (query.select.attributes == null))) {
LOG.warning("Asset query contains a calendar event predicate which requires the attribute values and types to be included in the select (as calendar event predicate is applied post DB query)");
throw new IllegalArgumentException("Asset query contains a calendar event predicate which requires the attribute values and types to be included in the select (as calendar event predicate is applied post DB query)");
}
// RT: No longer used as parent info removed for simplicity and security reasons
// // Use a SqlResultSetMapping to allow auto hydration with retrieval of transient data as well
// // Using hibernate query object rather than JPA as postgres array parameter support doesn't work in JPQL without specifying the data type
// org.hibernate.query.Query jpql = em.createNativeQuery(querySql.querySql, "AssetMapping").unwrap(org.hibernate.query.Query.class);
// querySql.apply(em, jpql);
// List results = jpql.getResultList();
// Stream> assetStream = results.stream().map(objArr -> {
// Asset> asset = (Asset>)objArr[0];
//
// if (objArr.length == 3) {
// // We have transient parent info
// String parentName = (String)objArr[1];
// String parentType = (String)objArr[2];
// try {
// assetParentNameField.set(asset, parentName);
// assetParentTypeField.set(asset, parentType);
// } catch (IllegalAccessException e) {
// LOG.log(Level.WARNING, "Failed to set asset parent name and/or type fields", e);
// }
// }
// return asset;
// });
org.hibernate.query.Query jpql = em.createNativeQuery(querySql.querySql, Asset.class).unwrap(org.hibernate.query.Query.class)
.setHint(AvailableHints.HINT_READ_ONLY, true); // Make query readonly so no dirty checks are performed
querySql.apply(em, jpql);
List> assets = (List>)(Object)jpql.getResultList();
if (containsCalendarPredicate) {
return assets.stream().filter(asset -> calendarEventPredicateMatches(timerService::getCurrentTimeMillis, query, asset)).toList();
}
if (LOG.isLoggable(FINEST)) {
LOG.finest("Asset query took " + (System.currentTimeMillis() - startMillis) + "ms: return count=" + assets.size());
}
return assets;
}
/**
* This does a low level JDBC update so hibernate event interceptor doesn't get called and we 'manually'
* generate the {@link AttributeEvent}
*/
@SuppressWarnings("unchecked")
protected boolean updateAttributeValue(EntityManager em, AttributeEvent event) throws ConstraintViolationException {
long timestamp = event.getTimestamp() > 0 ? event.getTimestamp() : timerService.getCurrentTimeMillis();
try {
PGobject valueTimestampJSON = new PGobject();
valueTimestampJSON.setType("jsonb");
valueTimestampJSON.setValue("{\"value\":" + ValueUtil.asJSON(event.getValue().orElse(null)).orElse(ValueUtil.NULL_LITERAL) + ",\"timestamp\":" + timestamp + "}");
// TODO: Use jsonb type directly to optimise over wire data (couldn't get this to work even after seeing https://stackoverflow.com/questions/53847917/postgresql-throws-column-is-of-type-jsonb-but-expression-is-of-type-bytea-with)
Query query = em.createNativeQuery("UPDATE asset SET attributes[?] = attributes[?] || ?\\:\\:jsonb where id = ?")
.setParameter(1, event.getName())
.setParameter(2, event.getName())
.setParameter(3, "{\"value\":" + ValueUtil.asJSON(event.getValue().orElse(null)).orElse(ValueUtil.NULL_LITERAL) + ",\"timestamp\":" + timestamp + "}")
.setParameter(4, event.getId());
int affectedRows = query.executeUpdate();
boolean success = affectedRows == 1;
if (success) {
if (LOG.isLoggable(Level.FINEST)) {
LOG.finest("Updated attribute value assetID=" + event.getId() + ", attributeName=" + event.getName() + ", timestamp=" + timestamp);
}
} else {
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("Failed to update attribute value assetID=" + event.getId() + ", attributeName=" + event.getName() + ", timestamp=" + timestamp);
}
}
if (success) {
clientEventService.publishEvent(event);
}
return success;
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to store attribute value", e);
return false;
}
}
protected void publishModificationEvents(PersistenceEvent> persistenceEvent) {
Asset> asset = persistenceEvent.getEntity();
switch (persistenceEvent.getCause()) {
case CREATE -> {
// Fully load the asset
Asset> loadedAsset = find(new AssetQuery().ids(asset.getId()));
if (loadedAsset == null) {
return;
}
if (LOG.isLoggable(Level.FINEST)) {
LOG.finest("Asset created: " + loadedAsset.toStringAll());
} else {
LOG.fine("Asset created: " + loadedAsset);
}
clientEventService.publishEvent(
new AssetEvent(AssetEvent.Cause.CREATE, loadedAsset, null)
);
// Raise attribute event for each created attribute
asset.getAttributes().forEach(newAttribute ->
clientEventService.publishEvent(
new AttributeEvent(
asset,
newAttribute,
getClass().getSimpleName(),
newAttribute.getValue().orElse(null),
newAttribute.getTimestamp().orElse(0L),
newAttribute.getValue().orElse(null),
newAttribute.getTimestamp().orElse(0L))
.setSource(getClass().getSimpleName())
));
}
case UPDATE -> {
boolean nonAttributeChange = persistenceEvent.getPropertyNames().size() > 1 || !persistenceEvent.hasPropertyChanged("attributes");
boolean attributesChanged = persistenceEvent.hasPropertyChanged("attributes");
LOG.finest(() -> "Asset updated: " + persistenceEvent);
clientEventService.publishEvent(
new AssetEvent(AssetEvent.Cause.UPDATE, asset, persistenceEvent.getPropertyNames().toArray(String[]::new))
);
AttributeMap oldAttributes = attributesChanged ? persistenceEvent.getPreviousState("attributes") : asset.getAttributes();
AttributeMap newAttributes = attributesChanged ? persistenceEvent.getCurrentState("attributes") : asset.getAttributes();
// Publish events for deleted attributes
if (attributesChanged) {
// Get removed attributes and raise an attribute event with deleted flag in attribute state
oldAttributes.stream()
.filter(oldAttribute ->
newAttributes.stream().noneMatch(newAttribute ->
oldAttribute.getName().equals(newAttribute.getName())
))
.forEach(obsoleteAttribute ->
clientEventService.publishEvent(
new AttributeEvent(asset, obsoleteAttribute, getClass().getSimpleName(), null, timerService.getCurrentTimeMillis(), null, 0L)
.setSource(getClass().getSimpleName())
.setDeleted(true)
));
}
Stream> attributeStream;
if (nonAttributeChange) {
// If something other than attributes has changed then treat as if attributes changed as path etc could have changed
attributeStream = newAttributes.values().stream();
} else {
// Get new or modified attributes
attributeStream = getAddedOrModifiedAttributes(oldAttributes.values(), newAttributes.values());
}
attributeStream
.forEach(newOrModifiedAttribute -> {
Optional> oldAttribute = oldAttributes.get(newOrModifiedAttribute.getName());
clientEventService.publishEvent(new AttributeEvent(
asset,
newOrModifiedAttribute,
getClass().getSimpleName(),
newOrModifiedAttribute.getValue().orElse(null),
newOrModifiedAttribute.getTimestamp().orElse(0L),
oldAttribute.flatMap(Attribute::getValue).orElse(null),
oldAttribute.flatMap(Attribute::getTimestamp).orElse(0L)
).setSource(getClass().getSimpleName()));
});
}
case DELETE -> {
if (LOG.isLoggable(Level.FINEST)) {
LOG.finest("Asset deleted: " + asset.toStringAll());
} else {
LOG.fine("Asset deleted: " + asset);
}
clientEventService.publishEvent(
new AssetEvent(AssetEvent.Cause.DELETE, asset, null)
);
// Raise attribute event with deleted flag for each attribute
AttributeMap deletedAttributes = asset.getAttributes();
deletedAttributes.forEach(obsoleteAttribute ->
clientEventService.publishEvent(
new AttributeEvent(asset, obsoleteAttribute, getClass().getSimpleName(), null, timerService.getCurrentTimeMillis(), null, 0L)
.setSource(getClass().getSimpleName())
.setDeleted(true)
));
}
}
}
public String toString() {
return getClass().getSimpleName() + "{" +
'}';
}
/* SQL BUILDER METHODS */
protected static Pair buildQuery(AssetQuery query, Supplier timeProvider) {
LOG.finest("Building: " + query);
StringBuilder sb = new StringBuilder();
boolean recursive = query.recursive;
List binders = new ArrayList<>();
sb.append(buildSelectString(query, 1, binders, timeProvider));
sb.append(buildFromString(query, 1));
boolean containsCalendarPredicate = appendWhereClause(sb, query, 1, binders, timeProvider);
if (recursive) {
sb.insert(0, "WITH RECURSIVE top_level_assets AS ((");
sb.append(") UNION (");
sb.append(buildSelectString(query, 2, binders, timeProvider));
sb.append(buildFromString(query, 2));
containsCalendarPredicate = !containsCalendarPredicate && appendWhereClause(sb, query, 2, binders, timeProvider);
sb.append("))");
sb.append(buildSelectString(query, 3, binders, timeProvider));
sb.append(buildFromString(query, 3));
containsCalendarPredicate = !containsCalendarPredicate && appendWhereClause(sb, query, 3, binders, timeProvider);
}
sb.append(buildOrderByString(query));
sb.append(buildLimitString(query));
return new Pair<>(new PreparedAssetQuery(sb.toString(), binders), containsCalendarPredicate);
}
protected static String buildSelectString(AssetQuery query, int level, List binders, Supplier timeProvider) {
// level = 1 is main query select
// level = 2 is union select
// level = 3 is CTE select
StringBuilder sb = new StringBuilder();
AssetQuery.Select select = query.select;
sb.append("select A.ID as ID, A.NAME as NAME, A.ACCESS_PUBLIC_READ as ACCESS_PUBLIC_READ");
sb.append(", A.CREATED_ON AS CREATED_ON, A.TYPE AS TYPE, A.PARENT_ID AS PARENT_ID");
sb.append(", A.REALM AS REALM, A.VERSION as VERSION");
if (!query.recursive || level == 3) {
sb.append(", A.PATH as PATH");
} else {
sb.append(", NULL as PATH");
}
if (select == null || select.attributes == null || select.attributes.length > 0) {
if (query.recursive && level != 3) {
sb.append(", A.ATTRIBUTES as ATTRIBUTES");
} else {
sb.append(buildAttributeSelect(query, binders, timeProvider));
}
} else {
sb.append(", NULL as ATTRIBUTES");
}
return sb.toString();
}
protected static String buildAttributeSelect(AssetQuery query, List binders, Supplier timeProvider) {
Select select = query.select;
boolean hasAttributeFilter = select != null && select.attributes != null && select.attributes.length > 0;
if (!hasAttributeFilter && query.access == PRIVATE) {
return ", A.ATTRIBUTES as ATTRIBUTES";
}
StringBuilder sb = new StringBuilder();
sb.append(", (");
sb.append("select json_object_agg(AX.key, AX.value) from jsonb_each(A.attributes) as AX");
sb.append(" where true");
// Filter attributes
if (select != null && select.attributes != null && select.attributes.length > 0) {
final int pos = binders.size() + 1;
sb.append(" AND AX.key = ANY(")
.append("?")
.append(pos)
.append(")");
binders.add((em, st) -> st.setParameter(pos, select.attributes));
}
if (query.access != PRIVATE) {
String metaName = query.access == PROTECTED ? ACCESS_RESTRICTED_READ.getName() : ACCESS_PUBLIC_READ.getName();
sb.append(" AND AX.VALUE #>> '{meta,").append(metaName).append("}' = 'true'");
}
sb.append(") AS ATTRIBUTES");
return sb.toString();
}
protected static String buildFromString(AssetQuery query, int level) {
// level = 1 is main query
// level = 2 is union
// level = 3 is CTE
StringBuilder sb = new StringBuilder();
boolean recursive = query.recursive;
if (level == 1) {
sb.append(" from Asset A ");
} else if (level == 2) {
sb.append(" from top_level_assets P ");
sb.append("join Asset A on A.PARENT_ID = P.ID ");
} else {
sb.append(" from top_level_assets A ");
}
if ((!recursive || level == 3) && query.userIds != null) {
sb.append("right join USER_ASSET_LINK UA on A.ID = UA.ASSET_ID ");
}
return sb.toString();
}
protected static String buildOrderByString(AssetQuery query) {
StringBuilder sb = new StringBuilder();
if (query.ids != null && !query.recursive) {
return sb.toString();
}
if (query.orderBy != null && query.orderBy.property != null) {
sb.append(" order by ");
switch (query.orderBy.property) {
case CREATED_ON -> sb.append(" A.CREATED_ON ");
case ASSET_TYPE -> sb.append(" A.TYPE ");
case NAME -> sb.append(" A.NAME ");
case PARENT_ID -> sb.append(" A.PARENT_ID ");
case REALM -> sb.append(" A.REALM ");
}
sb.append(query.orderBy.descending ? "desc " : "asc ");
}
return sb.toString();
}
protected static String buildLimitString(AssetQuery query) {
if (query.limit > 0) {
return " LIMIT " + query.limit;
}
return "";
}
protected static boolean appendWhereClause(StringBuilder sb, AssetQuery query, int level, List binders, Supplier timeProvider) {
// level = 1 is main query
// level = 2 is union
// level = 3 is CTE
boolean containsCalendarPredicate = false;
boolean recursive = query.recursive;
sb.append(" where true");
if (level == 2) {
return false;
}
if (level == 1 && query.ids != null) {
final int pos = binders.size() + 1;
sb.append(" and A.ID = ANY(?")
.append(pos)
.append(")");
binders.add((em, st) -> st.setParameter(pos, query.ids));
}
if (level == 1 && query.names != null) {
sb.append(" and (");
boolean isFirst = true;
for (StringPredicate pred : query.names) {
if (!isFirst) {
sb.append(" or ");
}
isFirst = false;
final int pos = binders.size() + 1;
sb.append(pred.caseSensitive ? "A.NAME " : "upper(A.NAME)");
sb.append(StringPredicate.toSQLParameter(pred, pos, false));
binders.add((em, st) -> st.setParameter(pos, pred.prepareValue()));
}
sb.append(")");
}
if (query.parents != null) {
sb.append(" and (");
boolean isFirst = true;
for (ParentPredicate pred : query.parents) {
if (!isFirst) {
sb.append(" or (");
} else {
sb.append("(");
}
isFirst = false;
if (level == 1 && pred.id != null) {
final int pos = binders.size() + 1;
sb.append("A.PARENT_ID = ?").append(pos);
binders.add((em, st) -> st.setParameter(pos, pred.id));
} else if (level == 1) {
sb.append("A.PARENT_ID is null");
} else {
sb.append("true");
}
sb.append(")");
}
sb.append(")");
}
if (level == 1 && query.paths != null) {
sb.append(" and (");
Arrays.stream(query.paths)
.map(p -> String.join(".", p.path))
.forEach(lqueryStr -> {
int pos = binders.size() + 1;
sb.append("A.PATH ~ lquery(?").append(pos).append(") or ");
binders.add((em, st) -> st.setParameter(pos, "*." + lqueryStr + ".*"));
});
sb.append("false)");
}
if (!recursive || level == 3) {
if (query.realm != null && !TextUtil.isNullOrEmpty(query.realm.name)) {
final int pos = binders.size() + 1;
sb.append(" and A.REALM = ?").append(pos);
binders.add((em, st) -> st.setParameter(pos, query.realm.name));
}
if (query.userIds != null) {
final int pos = binders.size() + 1;
sb.append(" and UA.USER_ID = ANY(?")
.append(pos)
.append(")");
binders.add((em, st) -> st.setParameter(pos, query.userIds));
}
if (level == 1 && query.access == Access.PUBLIC) {
sb.append(" and A.ACCESS_PUBLIC_READ is true");
}
if (query.types != null) {
String[] resolvedTypes = getResolvedAssetTypes(query.types);
final int pos = binders.size() + 1;
sb.append(" and A.TYPE = ANY(?")
.append(pos)
.append(")");
binders.add((em, st) -> st.setParameter(pos, resolvedTypes));
}
if (query.attributes != null) {
sb.append(" and A.id in (select A.id from ");
AtomicInteger offset = new AtomicInteger(sb.length());
Consumer selectInserter = (str) -> sb.insert(offset.getAndAdd(str.length()), str);
sb.append(" where true AND ");
containsCalendarPredicate = addAttributePredicateGroupQuery(sb, binders, 0, selectInserter, query.attributes, timeProvider);
sb.append(")");
}
}
return containsCalendarPredicate;
}
protected static boolean addAttributePredicateGroupQuery(StringBuilder sb, List binders, int groupIndex, Consumer selectInserter, LogicGroup attributePredicateGroup, Supplier timeProvider) {
boolean containsCalendarPredicate = false;
LogicGroup.Operator operator = attributePredicateGroup.operator;
if (operator == null) {
operator = LogicGroup.Operator.AND;
}
sb.append("(");
if (!attributePredicateGroup.getItems().isEmpty()) {
Collection> grouped;
if (operator == LogicGroup.Operator.AND) {
// Group predicates by their attribute name predicate
grouped = attributePredicateGroup.getItems().stream().collect(groupingBy(predicate -> predicate.name != null ? predicate.name : "")).values();
} else {
grouped = new ArrayList<>();
grouped.add(attributePredicateGroup.getItems());
}
boolean isFirst = true;
for (List group : grouped) {
if (!isFirst) {
sb.append(operator == LogicGroup.Operator.OR ? " or " : " and ");
}
isFirst = false;
selectInserter.accept((groupIndex > 0 ? ", " : "") + "jsonb_each(A.attributes) as AX" + groupIndex);
containsCalendarPredicate = !containsCalendarPredicate && addNameValuePredicates(group, sb, binders, "AX" + groupIndex, selectInserter, operator == LogicGroup.Operator.OR, timeProvider);
groupIndex++;
}
}
if (attributePredicateGroup.groups != null && attributePredicateGroup.groups.size() > 0) {
for (LogicGroup group : attributePredicateGroup.groups) {
sb.append(operator == LogicGroup.Operator.OR ? " or " : " and ");
boolean containsCalPred = addAttributePredicateGroupQuery(sb, binders, groupIndex, selectInserter, group, timeProvider);
if (!containsCalendarPredicate && containsCalPred) {
containsCalendarPredicate = true;
}
}
}
sb.append(")");
return containsCalendarPredicate;
}
protected static boolean addNameValuePredicates(List extends NameValuePredicate> nameValuePredicates, StringBuilder sb, List binders, String jsonObjName, Consumer selectInserter, boolean useOr, Supplier timeProvider) {
boolean containsCalendarPredicate = false;
boolean isFirst = true;
int metaIndex = 0;
for (NameValuePredicate nameValuePredicate : nameValuePredicates) {
if (!containsCalendarPredicate && nameValuePredicate.value instanceof CalendarEventPredicate) {
containsCalendarPredicate = true;
}
if (!isFirst) {
sb.append(useOr ? " or " : " and ");
}
isFirst = false;
sb.append("(");
sb.append(buildNameValuePredicateFilter(nameValuePredicate, jsonObjName, binders, timeProvider));
if (nameValuePredicate instanceof AttributePredicate attributePredicate) {
if (attributePredicate.meta != null && attributePredicate.meta.length > 0) {
String metaJsonObjName = jsonObjName + "_AM" + metaIndex++;
selectInserter.accept(" LEFT JOIN jsonb_each(" + jsonObjName + ".VALUE #> '{meta}') as " + metaJsonObjName + " ON true");
sb.append(" and (");
addNameValuePredicates(Arrays.asList(attributePredicate.meta.clone()), sb, binders, metaJsonObjName, selectInserter, true, timeProvider);
sb.append(")");
}
}
sb.append(")");
}
return containsCalendarPredicate;
}
protected static String buildNameValuePredicateFilter(NameValuePredicate nameValuePredicate, String jsonObjName, List binders, Supplier timeProvider) {
if (nameValuePredicate.name == null && nameValuePredicate.value == null) {
return "TRUE";
}
StringBuilder attributeBuilder = new StringBuilder();
// Only append not on the outer filter if value predicate is not set otherwise it will match any attribute with different name and value
if (nameValuePredicate.negated && (nameValuePredicate.value == null || nameValuePredicate.name == null)) {
attributeBuilder.append("NOT (");
}
if (nameValuePredicate.name != null) {
attributeBuilder.append(nameValuePredicate.name.caseSensitive
? jsonObjName + ".key"
: "upper(" + jsonObjName + ".key)"
);
final int pos = binders.size() + 1;
attributeBuilder.append(StringPredicate.toSQLParameter(nameValuePredicate.name, pos, false));
binders.add((em, st) -> st.setParameter(pos, nameValuePredicate.name.prepareValue()));
}
if (nameValuePredicate.value != null) {
if (nameValuePredicate.name != null) {
attributeBuilder.append(" and ");
if (nameValuePredicate.negated) {
attributeBuilder.append(" NOT(");
}
}
// Inserts the SQL string and adds the parameters
BiConsumer> valuePathInserter;
boolean isAttributePredicate = nameValuePredicate instanceof AttributePredicate;
boolean isTextCompare = (nameValuePredicate.value instanceof ValueEmptyPredicate) || (nameValuePredicate.value instanceof StringPredicate);
final String operator = isTextCompare ? "#>>" : "#>";
if (nameValuePredicate.path == null || nameValuePredicate.path.getPaths().length == 0) {
valuePathInserter = (sb, b) -> {
if (isAttributePredicate) {
sb.append("(").append(jsonObjName).append(".VALUE ").append(operator).append(" '{value}')");
} else {
sb.append(jsonObjName).append(".VALUE");
if (isTextCompare) {
sb.append(" #>> '{}'");
}
}
};
} else {
List paths = new ArrayList<>();
if (isAttributePredicate) {
paths.add("value");
}
paths.addAll(Arrays.stream(nameValuePredicate.path.getPaths()).map(Object::toString).toList());
valuePathInserter = (sb, b) -> {
final int pos = binders.size() + 1;
sb.append("(").append(jsonObjName).append(".VALUE ").append(operator).append(" ?").append(pos).append(")");
binders.add((em, st) -> st.setParameter(pos, paths.toArray(new String[0])));
};
}
if (nameValuePredicate.value instanceof StringPredicate stringPredicate) {
if (!stringPredicate.caseSensitive) {
attributeBuilder.append("upper(");
}
valuePathInserter.accept(attributeBuilder, binders);
if (!stringPredicate.caseSensitive) {
attributeBuilder.append(")");
}
final int pos = binders.size() + 1;
attributeBuilder.append(StringPredicate.toSQLParameter(stringPredicate, pos, false));
binders.add((em, st) -> st.setParameter(pos, stringPredicate.prepareValue()));
} else if (nameValuePredicate.value instanceof BooleanPredicate booleanPredicate) {
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" = to_jsonb(")
.append(booleanPredicate.value)
.append(")");
} else if (nameValuePredicate.value instanceof DateTimePredicate dateTimePredicate) {
attributeBuilder.append("(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{}')\\:\\:timestamp");
Pair fromAndTo = dateTimePredicate.asFromAndTo(timeProvider.get());
final int pos = binders.size() + 1;
binders.add((em, st) -> st.setParameter(pos, new java.sql.Timestamp(fromAndTo.key != null ? fromAndTo.key : 0L)));
attributeBuilder.append(buildOperatorFilter(dateTimePredicate.operator, dateTimePredicate.negate, pos));
if (dateTimePredicate.operator == Operator.BETWEEN) {
final int pos2 = binders.size() + 1;
binders.add((em, st) -> st.setParameter(pos2, new java.sql.Timestamp(fromAndTo.value != null ? fromAndTo.value : Long.MAX_VALUE)));
}
} else if (nameValuePredicate.value instanceof NumberPredicate numberPredicate) {
attributeBuilder.append("(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{}')\\:\\:numeric");
final int pos = binders.size() + 1;
attributeBuilder.append(buildOperatorFilter(numberPredicate.operator, numberPredicate.negate, pos));
binders.add((em, st) -> st.setParameter(pos, numberPredicate.value));
if (numberPredicate.operator == Operator.BETWEEN) {
final int pos2 = binders.size() + 1;
binders.add((em, st) -> st.setParameter(pos2, numberPredicate.rangeValue));
}
} else if (nameValuePredicate.value instanceof ArrayPredicate arrayPredicate) {
if (arrayPredicate.negated) {
attributeBuilder.append("NOT(");
}
if (arrayPredicate.value != null) {
valuePathInserter.accept(attributeBuilder, binders);
if (arrayPredicate.index != null) {
attributeBuilder
.append(" -> ")
.append(arrayPredicate.index);
}
final int pos = binders.size() + 1;
attributeBuilder.append(" @> ?").append(pos).append(" \\:\\:jsonb");
binders.add((em, st) -> st.setParameter(pos, ValueUtil.asJSON(arrayPredicate.value).orElse(ValueUtil.NULL_LITERAL)));
} else {
attributeBuilder.append("true");
}
if (arrayPredicate.lengthEquals != null) {
attributeBuilder.append(" and jsonb_array_length(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(") = ")
.append(arrayPredicate.lengthEquals);
}
if (arrayPredicate.lengthGreaterThan != null) {
attributeBuilder.append(" and jsonb_array_length(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(") > ")
.append(arrayPredicate.lengthGreaterThan);
}
if (arrayPredicate.lengthLessThan != null) {
attributeBuilder.append(" and jsonb_array_length(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(") < ")
.append(arrayPredicate.lengthLessThan);
}
if (arrayPredicate.negated) {
attributeBuilder.append(")");
}
} else if (nameValuePredicate.value instanceof GeofencePredicate) {
if (nameValuePredicate.value instanceof RadialGeofencePredicate location) {
attributeBuilder.append("ST_DistanceSphere(ST_MakePoint((");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{coordinates,0}')\\:\\:numeric")
.append(", (");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{coordinates,1}')\\:\\:numeric")
.append("), ST_MakePoint(")
.append(location.lng)
.append(",")
.append(location.lat)
.append(location.negated ? ")) > " : ")) <= ")
.append(location.radius);
} else if (nameValuePredicate.value instanceof RectangularGeofencePredicate location) {
if (location.negated) {
attributeBuilder.append("NOT");
}
attributeBuilder.append(" ST_Within(ST_MakePoint((");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{coordinates,0}')\\:\\:numeric")
.append(", (");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{coordinates,1}')\\:\\:numeric")
.append(")")
.append(", ST_MakeEnvelope(")
.append(location.lngMin)
.append(",")
.append(location.latMin)
.append(",")
.append(location.lngMax)
.append(",")
.append(location.latMax)
.append("))");
}
} else if (nameValuePredicate.value instanceof ValueEmptyPredicate) {
// Two situations - key is present and not null (cannot use IS NULL for this) or key is not present at all (have to use IS NULL for this)
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder.append(((ValueEmptyPredicate) nameValuePredicate.value).negate ? " IS NOT NULL" : " IS NULL");
} else if (nameValuePredicate.value instanceof CalendarEventPredicate) {
final int pos = binders.size() + 1;
java.sql.Timestamp when = new java.sql.Timestamp(((CalendarEventPredicate)nameValuePredicate.value).timestamp.getTime());
// The recurrence logic is applied post DB query just check start key is present and in the past and also
// that the end key is numeric and in the future if no recurrence value
attributeBuilder.append("(jsonb_typeof(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #> '{start}') = 'number' AND jsonb_typeof(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #> '{end}') = 'number' AND to_timestamp((");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{start}')\\:\\:float / 1000) <= ?").append(pos).append(" AND (to_timestamp((");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #>> '{end}')\\:\\:float / 1000) > ?").append(pos+1).append(" OR jsonb_typeof(");
valuePathInserter.accept(attributeBuilder, binders);
attributeBuilder
.append(" #> '{recurrence}') = 'string'))");
binders.add((em, st) -> st.setParameter(pos, when));
binders.add((em, st) -> st.setParameter(pos+1, when));
} else {
throw new UnsupportedOperationException("Attribute value predicate is not supported: " + nameValuePredicate.value);
}
}
if (nameValuePredicate.negated) {
attributeBuilder.append(")");
if (nameValuePredicate.value == null) {
// We have to include `is null` in where clause also as technically name not equals X is satisfied by null - mostly useful for meta items
attributeBuilder.append(" or ").append(jsonObjName).append(".key IS NULL");
}
}
return attributeBuilder.toString();
}
protected static String buildOperatorFilter(AssetQuery.Operator operator, boolean negate, int pos) {
switch (operator) {
case EQUALS -> {
if (negate) {
return " <> ?" + pos + " ";
}
return " = ?" + pos + " ";
}
case GREATER_THAN -> {
if (negate) {
return " <= ?" + pos + " ";
}
return " > ?" + pos + " ";
}
case GREATER_EQUALS -> {
if (negate) {
return " < ?" + pos + " ";
}
return " >= ?" + pos + " ";
}
case LESS_THAN -> {
if (negate) {
return " >= ?" + pos + " ";
}
return " < ?" + pos + " ";
}
case LESS_EQUALS -> {
if (negate) {
return " > ?" + pos + " ";
}
return " <= ?" + pos + " ";
}
case BETWEEN -> {
if (negate) {
return " NOT BETWEEN ?" + pos + " AND ?" + (pos + 1) + " ";
}
return " BETWEEN ?" + pos + " AND ?" + (pos + 1) + " ";
}
}
throw new IllegalArgumentException("Unsupported operator: " + operator);
}
protected void onReadRequest(T event) {
AssetQuery assetQuery = event.getAssetQuery();
Event response = null;
if (event.getResponseConsumer() == null) {
LOG.warning("Cannot respond to read request event as response consumer is not set");
return;
}
if (event instanceof ReadAssetsEvent) {
List> assets = findAll(assetQuery);
response = new AssetsEvent(assets);
} else {
Asset> asset = find(assetQuery);
String assetId;
String attributeName = null;
if (asset != null) {
if (event instanceof ReadAttributeEvent readAttributeEvent) {
assetId = readAttributeEvent.getAttributeRef().getId();
attributeName = readAttributeEvent.getAttributeRef().getName();
} else {
assetId = ((ReadAssetEvent) event).getAssetId();
}
if (!TextUtil.isNullOrEmpty(attributeName)) {
Attribute> assetAttribute = asset.getAttributes().get(attributeName).orElse(null);
if (assetAttribute != null) {
// Check access constraints
if (assetQuery.access == null
|| assetQuery.access == PRIVATE
|| (assetQuery.access == PUBLIC && assetAttribute.getMetaValue(ACCESS_PUBLIC_READ).orElse(false))
|| (assetQuery.access == PROTECTED && assetAttribute.getMetaValue(ACCESS_RESTRICTED_READ).orElse(false))) {
response = new AttributeEvent(assetId, attributeName, assetAttribute.getValue().orElse(null), assetAttribute.getTimestamp().orElse(0L));
}
}
} else {
response = new AssetEvent(AssetEvent.Cause.READ, asset, null);
}
}
}
if (response != null) {
if (!isNullOrEmpty(((SharedEvent) event).getMessageID())) {
response.setMessageID(((SharedEvent) event).getMessageID());
}
event.getResponseConsumer().accept(response);
}
}
}