diff --git a/gradle.properties b/gradle.properties index b83bef029e..6d66d6cff5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 GROUP=com.atlan -VERSION_NAME=2.1.2-SNAPSHOT +VERSION_NAME=2.2.0-SNAPSHOT POM_URL=https://github.com/atlanhq/atlan-java POM_SCM_URL=git@github.com:atlanhq/atlan-java.git diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a19b639a0..e54d2720c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,13 +3,13 @@ jackson = "2.18.0" slf4j = "2.0.16" elasticsearch = "8.15.2" freemarker = "2.3.33" -classgraph = "4.8.176" +classgraph = "4.8.177" testng = "7.10.2" log4j = "2.24.1" wiremock = "3.9.1" jnanoid = "2.0.0" numaflow = "0.8.0" -awssdk = "2.28.11" +awssdk = "2.28.13" gcs = "26.47.0" system-stubs = "2.1.7" fastcsv = "3.3.1" diff --git a/sdk/src/main/java/com/atlan/api/UsersEndpoint.java b/sdk/src/main/java/com/atlan/api/UsersEndpoint.java index 638e16d9cb..f9d11b9609 100644 --- a/sdk/src/main/java/com/atlan/api/UsersEndpoint.java +++ b/sdk/src/main/java/com/atlan/api/UsersEndpoint.java @@ -348,6 +348,36 @@ public List getByUsernames(List users, RequestOptions options } } + /** + * Retrieves the user with a unique ID (GUID) that exactly matches the provided string. + * + * @param guid unique identifier by which to retrieve the user + * @return the user whose GUID matches the provided string, or null if there is none + * @throws AtlanException on any error during API invocation + */ + public AtlanUser getByGuid(String guid) throws AtlanException { + return getByGuid(guid, null); + } + + /** + * Retrieves the user with a unique ID (GUID) that exactly matches the provided string. + * + * @param guid unique identifier by which to retrieve the user + * @param options to override default client settings + * @return the user whose GUID matches the provided string, or null if there is none + * @throws AtlanException on any error during API invocation + */ + public AtlanUser getByGuid(String guid, RequestOptions options) throws AtlanException { + UserResponse response = list("{\"id\":\"" + guid + "\"}", options); + if (response != null + && response.getRecords() != null + && !response.getRecords().isEmpty()) { + return response.getRecords().get(0); + } else { + return null; + } + } + /** * Create a new user. * diff --git a/sdk/src/main/java/com/atlan/cache/AbstractAssetCache.java b/sdk/src/main/java/com/atlan/cache/AbstractAssetCache.java index 82040e4fdb..c9bd205850 100644 --- a/sdk/src/main/java/com/atlan/cache/AbstractAssetCache.java +++ b/sdk/src/main/java/com/atlan/cache/AbstractAssetCache.java @@ -65,8 +65,9 @@ protected AbstractAssetCache(AtlanClient client) { * Add an entry to the cache. * * @param asset to be cached + * @return the guid of the asset that was cached, or null if none was provided */ - protected void cache(Asset asset) { + protected String cache(Asset asset) { if (asset != null) { ObjectName name = getName(asset); if (name != null) { @@ -75,8 +76,10 @@ protected void cache(Asset asset) { guid2Asset.put(guid, asset); qualifiedName2Guid.put(asset.getQualifiedName(), guid); name2Guid.put(name, guid); + return guid; } } + return null; } /** diff --git a/sdk/src/main/java/com/atlan/cache/AbstractMassCache.java b/sdk/src/main/java/com/atlan/cache/AbstractMassCache.java index fc8665e62a..a222c1e9d8 100644 --- a/sdk/src/main/java/com/atlan/cache/AbstractMassCache.java +++ b/sdk/src/main/java/com/atlan/cache/AbstractMassCache.java @@ -6,8 +6,11 @@ import com.atlan.exception.ErrorCode; import com.atlan.exception.InvalidRequestException; import com.atlan.exception.NotFoundException; +import com.atlan.model.core.AtlanObject; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; /** @@ -15,27 +18,56 @@ * a cache is populated en-masse through batch refreshing. */ @Slf4j -public abstract class AbstractMassCache { +public abstract class AbstractMassCache { - private final Map mapIdToName = new ConcurrentHashMap<>(); - private final Map mapNameToId = new ConcurrentHashMap<>(); + protected final Map mapIdToName = new ConcurrentHashMap<>(); + protected final Map mapNameToId = new ConcurrentHashMap<>(); + protected final Map mapIdToObject = new ConcurrentHashMap<>(); + + /** Whether to refresh the cache by retrieving all objects up-front (true) or lazily, on-demand (false). */ + @Getter + protected AtomicBoolean bulkRefresh = new AtomicBoolean(true); /** * Logic to refresh the cache of objects from Atlan. * * @throws AtlanException on any error communicating with Atlan to refresh the cache of objects */ - public abstract void refreshCache() throws AtlanException; + public synchronized void refreshCache() throws AtlanException { + mapIdToName.clear(); + mapNameToId.clear(); + mapIdToObject.clear(); + } + + /** + * Logic to look up a single object for the cache. + * + * @param id unique internal identifier for the object + * @throws AtlanException on any error communicating with Atlan + */ + public abstract void lookupById(String id) throws AtlanException; + + /** + * Logic to look up a single object for the cache. + * + * @param name unique name for the object + * @throws AtlanException on any error communicating with Atlan + */ + public abstract void lookupByName(String name) throws AtlanException; /** * Add an entry to the cache * * @param id Atlan-internal ID * @param name human-readable name + * @param object the object to cache (if any) */ - protected void cache(String id, String name) { + protected void cache(String id, String name, T object) { mapIdToName.put(id, name); mapNameToId.put(name, id); + if (object != null) { + mapIdToObject.put(id, object); + } } /** @@ -88,7 +120,11 @@ public String getIdForName(String name, boolean allowRefresh) throws AtlanExcept String id = mapNameToId.get(name); if (id == null && allowRefresh) { // If not found, refresh the cache and look again (could be stale) - refreshCache(); + if (bulkRefresh.get()) { + refreshCache(); + } else { + lookupByName(name); + } id = mapNameToId.get(name); } if (id == null) { @@ -128,7 +164,11 @@ public String getNameForId(String id, boolean allowRefresh) throws AtlanExceptio String name = mapIdToName.get(id); if (name == null && allowRefresh) { // If not found, refresh the cache and look again (could be stale) - refreshCache(); + if (bulkRefresh.get()) { + refreshCache(); + } else { + lookupById(id); + } name = mapIdToName.get(id); } if (name == null) { @@ -139,4 +179,80 @@ public String getNameForId(String id, boolean allowRefresh) throws AtlanExceptio throw new InvalidRequestException(ErrorCode.MISSING_ID); } } + + /** + * Retrieve the actual object by Atlan-internal ID string. + * + * @param id Atlan-internal ID string + * @return the object with that ID + * @throws AtlanException on any API communication problem if the cache needs to be refreshed + * @throws NotFoundException if the object cannot be found (does not exist) in Atlan + * @throws InvalidRequestException if no ID was provided for the object to retrieve + */ + public T getById(String id) throws AtlanException { + return getById(id, true); + } + + /** + * Retrieve the actual object by Atlan-internal ID string. + * + * @param id Atlan-internal ID string + * @param allowRefresh whether to allow a refresh of the cache (true) or not (false) + * @return the object with that ID + * @throws AtlanException on any API communication problem if the cache needs to be refreshed + * @throws NotFoundException if the object cannot be found (does not exist) in Atlan + * @throws InvalidRequestException if no ID was provided for the object to retrieve + */ + public T getById(String id, boolean allowRefresh) throws AtlanException { + if (id != null && !id.isEmpty()) { + T result = mapIdToObject.get(id); + if (result == null && allowRefresh) { + // If not found, refresh the cache and look again (could be stale) + if (bulkRefresh.get()) { + refreshCache(); + } else { + lookupById(id); + } + result = mapIdToObject.get(id); + } + if (result == null) { + throw new NotFoundException(ErrorCode.NAME_NOT_FOUND_BY_ID, id); + } + return result; + } else { + throw new InvalidRequestException(ErrorCode.MISSING_ID); + } + } + + /** + * Retrieve the actual object by human-readable name. + * + * @param name human-readable name of the object + * @return the object with that name + * @throws AtlanException on any API communication problem if the cache needs to be refreshed + * @throws NotFoundException if the object cannot be found (does not exist) in Atlan + * @throws InvalidRequestException if no name was provided for the object to retrieve + */ + public T getByName(String name) throws AtlanException { + return getByName(name, true); + } + + /** + * Retrieve the actual object by human-readable name. + * + * @param name human-readable name of the object + * @param allowRefresh whether to allow a refresh of the cache (true) or not (false) + * @return the object with that name + * @throws AtlanException on any API communication problem if the cache needs to be refreshed + * @throws NotFoundException if the object cannot be found (does not exist) in Atlan + * @throws InvalidRequestException if no name was provided for the object to retrieve + */ + public T getByName(String name, boolean allowRefresh) throws AtlanException { + if (name != null && !name.isEmpty()) { + String id = getIdForName(name, allowRefresh); + return getById(id, false); + } else { + throw new InvalidRequestException(ErrorCode.MISSING_NAME); + } + } } diff --git a/sdk/src/main/java/com/atlan/cache/AtlanTagCache.java b/sdk/src/main/java/com/atlan/cache/AtlanTagCache.java index d10964c117..287c1c62a2 100644 --- a/sdk/src/main/java/com/atlan/cache/AtlanTagCache.java +++ b/sdk/src/main/java/com/atlan/cache/AtlanTagCache.java @@ -17,7 +17,7 @@ * Atlan tags. */ @Slf4j -public class AtlanTagCache extends AbstractMassCache { +public class AtlanTagCache extends AbstractMassCache { private Map mapIdToSourceTagsAttrId = new ConcurrentHashMap<>(); private Set deletedIds = ConcurrentHashMap.newKeySet(); @@ -32,6 +32,7 @@ public AtlanTagCache(TypeDefsEndpoint typeDefsEndpoint) { /** {@inheritDoc} */ @Override public synchronized void refreshCache() throws AtlanException { + super.refreshCache(); log.debug("Refreshing cache of Atlan tags..."); TypeDefResponse response = typeDefsEndpoint.list(List.of(AtlanTypeCategory.ATLAN_TAG, AtlanTypeCategory.STRUCT)); @@ -46,7 +47,7 @@ public synchronized void refreshCache() throws AtlanException { deletedNames = ConcurrentHashMap.newKeySet(); for (AtlanTagDef clsDef : tags) { String typeId = clsDef.getName(); - cache(typeId, clsDef.getDisplayName()); + cache(typeId, clsDef.getDisplayName(), clsDef); List attrs = clsDef.getAttributeDefs(); String sourceTagsId = ""; if (attrs != null && !attrs.isEmpty()) { @@ -60,6 +61,18 @@ public synchronized void refreshCache() throws AtlanException { } } + /** {@inheritDoc} */ + @Override + public void lookupByName(String name) { + // Nothing to do here, can only be looked up by internal ID + } + + /** {@inheritDoc} */ + @Override + public void lookupById(String id) { + // Since we can only look up in one direction, we should only allow bulk refresh + } + /** {@inheritDoc} */ @Override public String getIdForName(String name, boolean allowRefresh) throws AtlanException { diff --git a/sdk/src/main/java/com/atlan/cache/CustomMetadataCache.java b/sdk/src/main/java/com/atlan/cache/CustomMetadataCache.java index 431e713eb4..a115b5c5aa 100644 --- a/sdk/src/main/java/com/atlan/cache/CustomMetadataCache.java +++ b/sdk/src/main/java/com/atlan/cache/CustomMetadataCache.java @@ -21,9 +21,8 @@ * custom metadata (including attributes). */ @Slf4j -public class CustomMetadataCache extends AbstractMassCache { +public class CustomMetadataCache extends AbstractMassCache { - private Map cacheById = new ConcurrentHashMap<>(); private Map attrCacheById = new ConcurrentHashMap<>(); private Map> mapAttrIdToName = new ConcurrentHashMap<>(); @@ -40,6 +39,7 @@ public CustomMetadataCache(TypeDefsEndpoint typeDefsEndpoint) { @Override public synchronized void refreshCache() throws AtlanException { log.debug("Refreshing cache of custom metadata..."); + super.refreshCache(); TypeDefResponse response = typeDefsEndpoint.list(List.of(AtlanTypeCategory.CUSTOM_METADATA, AtlanTypeCategory.STRUCT)); if (response == null @@ -48,15 +48,13 @@ public synchronized void refreshCache() throws AtlanException { throw new AuthenticationException(ErrorCode.EXPIRED_API_TOKEN); } List customMetadata = response.getCustomMetadataDefs(); - cacheById = new ConcurrentHashMap<>(); attrCacheById = new ConcurrentHashMap<>(); mapAttrIdToName = new ConcurrentHashMap<>(); mapAttrNameToId = new ConcurrentHashMap<>(); archivedAttrIds = new ConcurrentHashMap<>(); for (CustomMetadataDef bmDef : customMetadata) { String typeId = bmDef.getName(); - cacheById.put(typeId, bmDef); - cache(typeId, bmDef.getDisplayName()); + cache(typeId, bmDef.getDisplayName(), bmDef); mapAttrIdToName.put(typeId, new ConcurrentHashMap<>()); mapAttrNameToId.put(typeId, new ConcurrentHashMap<>()); for (AttributeDef attributeDef : bmDef.getAttributeDefs()) { @@ -80,6 +78,18 @@ public synchronized void refreshCache() throws AtlanException { } } + /** {@inheritDoc} */ + @Override + public void lookupByName(String name) { + // Nothing to do here, can only be looked up by internal ID + } + + /** {@inheritDoc} */ + @Override + public void lookupById(String id) { + // Since we can only look up in one direction, we should only allow bulk refresh + } + /** * Retrieve all the (active) custom metadata attributes. The map will be keyed by custom metadata set * name, and the value will be a listing of all the (active) attributes within that set (with all the details @@ -118,11 +128,11 @@ public Map> getAllCustomAttributes(boolean includeDel */ public Map> getAllCustomAttributes(boolean includeDeleted, boolean forceRefresh) throws AtlanException { - if (cacheById.isEmpty() || forceRefresh) { + if (mapIdToObject.isEmpty() || forceRefresh) { refreshCache(); } Map> map = new HashMap<>(); - for (Map.Entry entry : cacheById.entrySet()) { + for (Map.Entry entry : mapIdToObject.entrySet()) { String typeId = entry.getKey(); String typeName = getNameForId(typeId); CustomMetadataDef typeDef = entry.getValue(); @@ -296,7 +306,7 @@ public CustomMetadataDef getCustomMetadataDef(String setName) throws AtlanExcept */ public CustomMetadataDef getCustomMetadataDef(String setName, boolean allowRefresh) throws AtlanException { String setId = getIdForName(setName, allowRefresh); - return cacheById.get(setId); + return mapIdToObject.get(setId); } /** diff --git a/sdk/src/main/java/com/atlan/cache/GroupCache.java b/sdk/src/main/java/com/atlan/cache/GroupCache.java index 8bccc74508..964b70850d 100644 --- a/sdk/src/main/java/com/atlan/cache/GroupCache.java +++ b/sdk/src/main/java/com/atlan/cache/GroupCache.java @@ -8,6 +8,7 @@ import com.atlan.exception.InvalidRequestException; import com.atlan.exception.NotFoundException; import com.atlan.model.admin.AtlanGroup; +import com.atlan.model.admin.GroupResponse; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; @@ -16,7 +17,7 @@ * Lazily-loaded cache for translating Atlan-internal groups into their various IDs. */ @Slf4j -public class GroupCache extends AbstractMassCache { +public class GroupCache extends AbstractMassCache { private Map mapAliasToId = new ConcurrentHashMap<>(); @@ -29,15 +30,26 @@ public GroupCache(GroupsEndpoint groupsEndpoint) { /** {@inheritDoc} */ @Override public synchronized void refreshCache() throws AtlanException { + super.refreshCache(); log.debug("Refreshing cache of groups..."); List groups = groupsEndpoint.list(); mapAliasToId = new ConcurrentHashMap<>(); for (AtlanGroup group : groups) { String groupId = group.getId(); String groupName = group.getName(); - String groupAlias = group.getAlias(); - cache(groupId, groupName); - mapAliasToId.put(groupAlias, groupId); + cache(groupId, groupName, group); + } + } + + /** {@inheritDoc} */ + @Override + protected void cache(String id, String name, AtlanGroup object) { + super.cache(id, name, object); + if (object != null) { + String alias = object.getAlias(); + if (alias != null) { + mapAliasToId.put(alias, id); + } } } @@ -69,7 +81,11 @@ public String getIdForAlias(String alias, boolean allowRefresh) throws AtlanExce String groupId = mapAliasToId.get(alias); if (groupId == null && allowRefresh) { // If not found, refresh the cache and look again (could be stale) - refreshCache(); + if (bulkRefresh.get()) { + refreshCache(); + } else { + lookupByAlias(alias); + } groupId = mapAliasToId.get(alias); } if (groupId == null) { @@ -108,4 +124,41 @@ public String getNameForAlias(String alias, boolean allowRefresh) throws AtlanEx String guid = getIdForAlias(alias, allowRefresh); return getNameForId(guid, false); } + + /** {@inheritDoc} */ + @Override + public void lookupByName(String name) throws AtlanException { + GroupResponse response = groupsEndpoint.list("{\"name\":\"" + name + "\"}"); + cacheResponse(response); + } + + /** {@inheritDoc} */ + @Override + public void lookupById(String id) throws AtlanException { + GroupResponse response = groupsEndpoint.list("{\"id\":\"" + id + "\"}"); + cacheResponse(response); + } + + /** + * Logic to look up a single object for the cache. + * + * @param alias name of the group as it appears in the UI + * @throws AtlanException on any error communicating with Atlan + */ + public void lookupByAlias(String alias) throws AtlanException { + GroupResponse response = groupsEndpoint.list("{\"alias\":\"" + alias + "\"}"); + cacheResponse(response); + } + + private void cacheResponse(GroupResponse response) { + if (response != null + && response.getRecords() != null + && !response.getRecords().isEmpty()) { + List groups = response.getRecords(); + for (AtlanGroup group : groups) { + String groupId = group.getId(); + cache(groupId, group.getName(), group); + } + } + } } diff --git a/sdk/src/main/java/com/atlan/cache/RoleCache.java b/sdk/src/main/java/com/atlan/cache/RoleCache.java index 29065a207c..32a249f354 100644 --- a/sdk/src/main/java/com/atlan/cache/RoleCache.java +++ b/sdk/src/main/java/com/atlan/cache/RoleCache.java @@ -13,7 +13,7 @@ * Lazily-loaded cache for translating Atlan-internal roles into their various IDs. */ @Slf4j -public class RoleCache extends AbstractMassCache { +public class RoleCache extends AbstractMassCache { private final RolesEndpoint rolesEndpoint; @@ -24,10 +24,29 @@ public RoleCache(RolesEndpoint rolesEndpoint) { /** {@inheritDoc} */ @Override public synchronized void refreshCache() throws AtlanException { + super.refreshCache(); log.debug("Refreshing cache of roles..."); // Note: we will only retrieve and cache the workspace-level roles, which all // start with '$' RoleResponse response = rolesEndpoint.list("{\"name\":{\"$ilike\":\"$%\"}}"); + cacheResponse(response); + } + + /** {@inheritDoc} */ + @Override + public void lookupById(String id) throws AtlanException { + RoleResponse response = rolesEndpoint.list("{\"id\":\"" + id + "\"}"); + cacheResponse(response); + } + + /** {@inheritDoc} */ + @Override + public void lookupByName(String name) throws AtlanException { + RoleResponse response = rolesEndpoint.list("{\"name\":\"" + name + "\"}"); + cacheResponse(response); + } + + private void cacheResponse(RoleResponse response) { List roles; if (response != null) { roles = response.getRecords(); @@ -35,9 +54,7 @@ public synchronized void refreshCache() throws AtlanException { roles = Collections.emptyList(); } for (AtlanRole role : roles) { - String roleId = role.getId(); - String roleName = role.getName(); - cache(roleId, roleName); + cache(role.getId(), role.getName(), role); } } } diff --git a/sdk/src/main/java/com/atlan/cache/SourceTagCache.java b/sdk/src/main/java/com/atlan/cache/SourceTagCache.java index 4a2de70892..bf2f19f8ea 100644 --- a/sdk/src/main/java/com/atlan/cache/SourceTagCache.java +++ b/sdk/src/main/java/com/atlan/cache/SourceTagCache.java @@ -10,6 +10,7 @@ import com.atlan.model.fields.AtlanField; import com.atlan.util.StringUtils; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -23,12 +24,35 @@ @Slf4j public class SourceTagCache extends AbstractAssetCache { - private static final List tagAttributes = List.of(Asset.NAME); + private static final List tagAttributes = List.of(Asset.NAME, ITag.MAPPED_ATLAN_TAG_NAME); + + private final Map> atlanTagIdToGuids = new ConcurrentHashMap<>(); public SourceTagCache(AtlanClient client) { super(client); } + /** {@inheritDoc} */ + @Override + protected String cache(Asset asset) { + String guid = super.cache(asset); + if (guid != null && asset instanceof ITag tag) { + String tagName = tag.getMappedAtlanTagName(); + if (tagName != null) { + try { + String tagId = client.getAtlanTagCache().getIdForName(tagName); + if (!atlanTagIdToGuids.containsKey(tagId)) { + atlanTagIdToGuids.put(tagId, new HashSet<>()); + } + atlanTagIdToGuids.get(tagId).add(guid); + } catch (AtlanException e) { + log.error("Unable to translate tag name to ID: {}", tagName, e); + } + } + } + return guid; + } + /** {@inheritDoc} */ @Override public void lookupByGuid(String guid) throws AtlanException { @@ -87,6 +111,78 @@ public void lookupByName(ObjectName name) throws AtlanException { } } + /** + * Logic to refresh the cache for a single object from Atlan. + * + * @param internalAtlanTagId internal hashed-string ID for the mapped Atlan tag + * @throws AtlanException on any underlying API issues + */ + public void lookupByMappedAtlanTag(String internalAtlanTagId) throws AtlanException { + if (internalAtlanTagId != null && !internalAtlanTagId.isEmpty()) { + List candidates = client + .assets + .select() + .where(Asset.SUPER_TYPE_NAMES.eq(ITag.TYPE_NAME)) + .where(ITag.MAPPED_ATLAN_TAG_NAME.eq(internalAtlanTagId)) + .includesOnResults(tagAttributes) + .stream() + .toList(); + if (!candidates.isEmpty()) { + for (Asset candidate : candidates) { + cache(candidate); + } + } + } + } + + /** + * Retrieve tags from the cache by their mapped Atlan tag, looking it up and + * adding it to the cache if it is not found there. + * + * @param internalAtlanTagId internal hashed-string ID for the mapped Atlan tag + * @return all mapped tags (if found) + * @throws AtlanException on any API communication problem if the cache needs to be refreshed + * @throws NotFoundException if the object cannot be found (does not exist) in Atlan + * @throws InvalidRequestException if no internal hashed-string Atlan ID was provided + */ + public List getByMappedAtlanTag(String internalAtlanTagId) throws AtlanException { + return getByMappedAtlanTag(internalAtlanTagId, true); + } + + /** + * Retrieve tags from the cache by their mapped Atlan tag. + * + * @param internalAtlanTagId internal hashed-string ID for the mapped Atlan tag + * @param allowRefresh whether to allow a refresh of the cache (true) or not (false) + * @return all mapped tags (if found) + * @throws AtlanException on any API communication problem if the cache needs to be refreshed + * @throws NotFoundException if the object cannot be found (does not exist) in Atlan + * @throws InvalidRequestException if no internal hashed-string Atlan ID was provided + */ + public List getByMappedAtlanTag(String internalAtlanTagId, boolean allowRefresh) throws AtlanException { + if (internalAtlanTagId != null && !internalAtlanTagId.isEmpty()) { + Set found = atlanTagIdToGuids.get(internalAtlanTagId); + if (found == null && allowRefresh) { + // If not found, refresh the cache and look again (could be stale) + lookupByMappedAtlanTag(internalAtlanTagId); + found = atlanTagIdToGuids.get(internalAtlanTagId); + } + if (found == null) { + throw new NotFoundException(ErrorCode.ASSET_NOT_FOUND_BY_NAME, "tag", internalAtlanTagId); + } + List list = new ArrayList<>(); + for (String guid : found) { + ITag tag = (ITag) getByGuid(guid, false); + if (tag != null) { + list.add(tag); + } + } + return list; + } else { + throw new InvalidRequestException(ErrorCode.MISSING_ID); + } + } + /** {@inheritDoc} */ @Override public ObjectName getName(Asset asset) { diff --git a/sdk/src/main/java/com/atlan/cache/UserCache.java b/sdk/src/main/java/com/atlan/cache/UserCache.java index 1d706cf2cc..2cde5df93e 100644 --- a/sdk/src/main/java/com/atlan/cache/UserCache.java +++ b/sdk/src/main/java/com/atlan/cache/UserCache.java @@ -17,7 +17,7 @@ * Lazily-loaded cache for translating Atlan-internal users into their various IDs. */ @Slf4j -public class UserCache extends AbstractMassCache { +public class UserCache extends AbstractMassCache { private Map mapEmailToId = new ConcurrentHashMap<>(); @@ -25,13 +25,16 @@ public class UserCache extends AbstractMassCache { private final ApiTokensEndpoint apiTokensEndpoint; public UserCache(UsersEndpoint usersEndpoint, ApiTokensEndpoint apiTokensEndpoint) { + super(); this.usersEndpoint = usersEndpoint; this.apiTokensEndpoint = apiTokensEndpoint; + this.bulkRefresh.set(false); // Default to a lazily-loaded cache for users } /** {@inheritDoc} */ @Override public synchronized void refreshCache() throws AtlanException { + super.refreshCache(); log.debug("Refreshing cache of users..."); List users = usersEndpoint.list(); mapEmailToId = new ConcurrentHashMap<>(); @@ -40,8 +43,19 @@ public synchronized void refreshCache() throws AtlanException { String userName = user.getUsername(); String email = user.getEmail(); if (userId != null && userName != null && email != null) { - cache(userId, userName); - mapEmailToId.put(email, userId); + cache(userId, userName, user); + } + } + } + + /** {@inheritDoc} */ + @Override + protected void cache(String id, String name, AtlanUser object) { + super.cache(id, name, object); + if (object != null) { + String email = object.getEmail(); + if (email != null) { + mapEmailToId.put(email, id); } } } @@ -68,7 +82,7 @@ public String getIdForName(String username, boolean allowRefresh) throws AtlanEx if (token == null) { throw new NotFoundException(ErrorCode.API_TOKEN_NOT_FOUND_BY_NAME, username); } else { - cache(token.getId(), username); + cache(token.getId(), username, null); return token.getId(); } } @@ -105,7 +119,11 @@ public String getIdForEmail(String email, boolean allowRefresh) throws AtlanExce String userId = mapEmailToId.get(email); if (userId == null && allowRefresh) { // If not found, refresh the cache and look again (could be stale) - refreshCache(); + if (bulkRefresh.get()) { + refreshCache(); + } else { + lookupByEmail(email); + } userId = mapEmailToId.get(email); } if (userId == null) { @@ -137,11 +155,57 @@ public String getNameForId(String id, boolean allowRefresh) throws AtlanExceptio ApiToken token = apiTokensEndpoint.getByGuid(id); if (token != null) { String username = token.getApiTokenUsername(); - cache(id, username); + cache(id, username, null); return username; } // Otherwise, attempt to retrieve it and allow the cache to be refreshed when doing so return super.getNameForId(id, allowRefresh); } } + + /** {@inheritDoc} */ + @Override + public void lookupByName(String username) throws AtlanException { + if (username.startsWith(ApiToken.API_USERNAME_PREFIX)) { + ApiToken token = apiTokensEndpoint.getById(username); + if (token == null) { + throw new NotFoundException(ErrorCode.API_TOKEN_NOT_FOUND_BY_NAME, username); + } else { + cache(token.getId(), username, null); + } + } else { + AtlanUser user = usersEndpoint.getByUsername(username); + cache(user.getId(), username, user); + } + } + + /** {@inheritDoc} */ + @Override + public void lookupById(String id) throws AtlanException { + try { + AtlanUser user = usersEndpoint.getByGuid(id); + cache(id, user.getUsername(), user); + } catch (NotFoundException e) { + // Otherwise, check if it is an API token + ApiToken token = apiTokensEndpoint.getByGuid(id); + if (token != null) { + cache(id, token.getApiTokenUsername(), null); + } + } + } + + /** + * Logic to look up a single object for the cache. + * + * @param email unique email address for the user + * @throws AtlanException on any error communicating with Atlan + */ + public void lookupByEmail(String email) throws AtlanException { + List users = usersEndpoint.getByEmail(email); + if (users != null && !users.isEmpty()) { + for (AtlanUser user : users) { + cache(user.getId(), user.getUsername(), user); + } + } + } } diff --git a/sdk/src/main/java/com/atlan/model/admin/AtlanGroup.java b/sdk/src/main/java/com/atlan/model/admin/AtlanGroup.java index 6323d53019..68fb4ac8a4 100644 --- a/sdk/src/main/java/com/atlan/model/admin/AtlanGroup.java +++ b/sdk/src/main/java/com/atlan/model/admin/AtlanGroup.java @@ -9,6 +9,7 @@ import com.atlan.exception.InvalidRequestException; import com.atlan.model.core.AtlanObject; import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.SortedSet; @@ -43,13 +44,14 @@ public class AtlanGroup extends AtlanObject { /** TBC */ String path; - /** Personas the group is associated with. */ - @JsonIgnore // TODO - SortedSet personas; + /** Personas the group is associated with (limited details). */ + SortedSet personas; - /** Purposes the group is associated with. */ - @JsonIgnore // TODO - SortedSet purposes; + /** Purposes the group is associated with (limited details). */ + SortedSet purposes; + + /** TBC */ + SortedSet roles; /** Number of users in the group. */ Long userCount; @@ -291,4 +293,63 @@ public static final class GroupAttributes extends AtlanObject { /** Slack channels for this group. */ List channels; } + + @Getter + @Jacksonized + @SuperBuilder(toBuilder = true) + @EqualsAndHashCode(callSuper = true) + @ToString(callSuper = true) + public static final class Persona extends AtlanObject implements Comparable { + private static final long serialVersionUID = 2L; + + private static final Comparator stringComparator = Comparator.nullsFirst(String::compareTo); + private static final Comparator personaComparator = + Comparator.comparing(AtlanGroup.Persona::getId, stringComparator); + + /** UUID of the persona. */ + String id; + + /** Name of the persona. */ + String name; + + /** Business name of the persona. */ + String displayName; + + /** Unique internal name of the persona. */ + String qualifiedName; + + /** {@inheritDoc} */ + @Override + public int compareTo(AtlanGroup.Persona o) { + return personaComparator.compare(this, o); + } + } + + @Getter + @Jacksonized + @SuperBuilder(toBuilder = true) + @EqualsAndHashCode(callSuper = true) + @ToString(callSuper = true) + public static final class Purpose extends AtlanObject implements Comparable { + private static final long serialVersionUID = 2L; + + private static final Comparator stringComparator = Comparator.nullsFirst(String::compareTo); + private static final Comparator purposeComparator = + Comparator.comparing(AtlanGroup.Purpose::getGuid, stringComparator); + + /** UUID of the purpose. */ + String guid; + + /** Name of the purpose. */ + String name; + + /** Unique internal name of the purpose. */ + String qualifiedName; + + /** {@inheritDoc} */ + @Override + public int compareTo(AtlanGroup.Purpose o) { + return purposeComparator.compare(this, o); + } + } } diff --git a/sdk/src/main/java/com/atlan/model/admin/AtlanUser.java b/sdk/src/main/java/com/atlan/model/admin/AtlanUser.java index 769e9e81a7..971e108c1d 100644 --- a/sdk/src/main/java/com/atlan/model/admin/AtlanUser.java +++ b/sdk/src/main/java/com/atlan/model/admin/AtlanUser.java @@ -403,9 +403,7 @@ public static final class Persona extends AtlanObject implements Comparable fields; + + /** Singular field on which to index the filter map. */ + protected final String filerKey; + + /** + * Default constructor + * @param field name of the field to filter by (singular) + */ + public DiscoveryFilterField(String field) { + this.filerKey = field; + this.fields = List.of(field); + } + + /** + * Default constructor + * @param fields names of the fields to filter by (multiple) + */ + public DiscoveryFilterField(List fields) { + this.filerKey = fields.get(0); + this.fields = fields; + } + + /** + * Returns a filter that will match all assets whose provided field has any value at all (non-null). + * + * @return a filter that will only match assets that have some (non-null) value for the field + */ + public DiscoveryFilter hasAnyValue() { + return build("isNotNull", ""); + } + + /** + * Returns a filter that will match all assets whose provided field has no value at all (is null). + * + * @return a filter that will only match assets that have no value at all for the field (null) + */ + public DiscoveryFilter hasNoValue() { + return build("isNull", ""); + } + + /** + * Utility method to build up a lineage filter from provided conditions. + * + * @param op operator to compare the field and value + * @param value to compare the field's value with + * @return the lineage filter with the provided conditions + */ + protected DiscoveryFilter build(String op, Object value) { + return DiscoveryFilter._internal() + .filterKey(filerKey) + .operand(fields.size() > 1 ? fields : fields.get(0)) + .operator(op) + .value(value) + .build(); + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/EnumFilterField.java b/sdk/src/main/java/com/atlan/model/discovery/EnumFilterField.java new file mode 100644 index 0000000000..959b68637c --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/EnumFilterField.java @@ -0,0 +1,30 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import java.util.Collection; + +/** + * Represents any field in Atlan that can be used for discovery by a predefined set of values. + */ +public class EnumFilterField extends DiscoveryFilterField { + + /** + * Default constructor + * @param field name of the field to filter by (singular) + */ + public EnumFilterField(String field) { + super(field); + } + + /** + * Returns a query that will match all assets whose provided field has a value that exactly equals + * one of the provided enumerated values. + * + * @param values the values to check the field's value is exactly equal to + * @return a query that will only match assets whose value for the field is exactly equal to one of the provided values + */ + public DiscoveryFilter in(Collection values) { + return build("equals", values); + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/ExactMatchFilterField.java b/sdk/src/main/java/com/atlan/model/discovery/ExactMatchFilterField.java new file mode 100644 index 0000000000..e19b7fc89e --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/ExactMatchFilterField.java @@ -0,0 +1,38 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import java.util.List; + +/** + * Represents any field in Atlan that can be used for discovery by either exactly matching (or not matching) a value. + */ +public class ExactMatchFilterField extends StrictEqualityFilterField { + + /** + * Default constructor + * @param field name of the field to filter by (singular) + */ + public ExactMatchFilterField(String field) { + super(field); + } + + /** + * Default constructor + * @param fields names of the fields to filter by (multiple) + */ + public ExactMatchFilterField(List fields) { + super(fields); + } + + /** + * Returns a query that will match all assets whose provided field has a value that does not exactly equal + * the provided value. + * + * @param value the value to check the field's value is NOT exactly equal to + * @return a query that will only match assets whose value for the field is not exactly equal to the provided value + */ + public DiscoveryFilter neq(String value) { + return build("notEquals", value); + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/LinkableQuery.java b/sdk/src/main/java/com/atlan/model/discovery/LinkableQuery.java new file mode 100644 index 0000000000..b3bca0c27b --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/LinkableQuery.java @@ -0,0 +1,450 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import com.atlan.AtlanClient; +import com.atlan.exception.AtlanException; +import com.atlan.model.admin.AtlanGroup; +import com.atlan.model.admin.AtlanUser; +import com.atlan.model.assets.Connection; +import com.atlan.model.assets.GlossaryTerm; +import com.atlan.model.enums.CertificateStatus; +import com.atlan.serde.Removable; +import com.atlan.serde.Serde; +import com.atlan.util.StringUtils; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Singular; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; + +/** + * Class to compose compound queries combining various conditions, that can be used + * to generate links (URLs) for the discovery page in Atlan. + */ +@SuperBuilder(builderMethodName = "_internal") +@EqualsAndHashCode +@Slf4j +public class LinkableQuery { + + private AtlanClient client; + + /** + * Begin building a linkable query. + * + * @param client connectivity to the Atlan tenant for which to build the query. + * @return a builder upon which the query can be composed + */ + public static LinkableQueryBuilder builder(AtlanClient client) { + return _internal().client(client); + } + + /** + * Only assets with one of these certificate statuses will be included. + * To include assets with no certificate, use {@code null} as a value in the list. + */ + @Singular + private List certificateStatuses; + + /** + * Asset hierarchy (connector type, connection qualifiedName) to limit assets. + */ + private AssetHierarchy hierarchy; + + /** Owners by which to limit assets. */ + private Owners owners; + + /** + * Criteria based on properties that exist across all asset types, that must be present on every asset. + */ + @Singular + private List properties; + + // TODO: Specialized filters (dbt, table/view, etc) + + /** + * Criteria based on matching one or more of the provided tags (and optionally a value), that must + * be present on every asset. + */ + @Singular + private List tags; + + /** Terms by which to limit assets. */ + private Terms terms; + + /** List of types by which to limit the assets. */ + @Singular + private List typeNames; + + /** Convert the linkable query into a string. */ + @Override + public String toString() { + FilterParams fp = new FilterParams(this); + try { + return Serde.allInclusiveMapper.writeValueAsString(fp); + } catch (JsonProcessingException e) { + log.error("Unable to convert parameters to a linkable query.", e); + return ""; + } + } + + /** + * Convert this linkable query into an actual link (URL). + * @return the URL (without tenant domain / hostname) for accessing this limited set of assets + */ + public String getUrl() { + String params = toString(); + // Note: needs to be double-url-encoded + String encoded = StringUtils.encodeContent(StringUtils.encodeContent(params)); + return "/assets?searchAndFilterCriteria=" + encoded; + } + + /** + * Convert this linkable query into an actual link (URL). + * Note: this will not work if the code is running in the tenant itself (e.g. via a custom package). + * @return the full URL (including tenant domain) for accessing this limited set of assets in the tenant + */ + public String getFullUrl() { + return client.getBaseUrl() + getUrl(); + } + + @Getter + @SuperBuilder + @EqualsAndHashCode + static final class AssetHierarchy { + /** Name of the connector type to limit assets by. */ + String connectorName; + + /** Unique name of the connection to limit assets by. */ + String connectionQualifiedName; + + /** + * Name of the attribute within the lowest level of the asset hierarchy to limit assets by. + * For example, if you want to limit assets by schema, use {@code schemaQualifiedName}, but if + * you only want to limit assets by database use {@code databaseQualifiedName}. + */ + String attributeName; + + /** Qualified name prefix that matches the provided {@code attributeName} by which to limit assets. */ + String attributeValue; + } + + @Getter + @SuperBuilder + @EqualsAndHashCode + @SuppressWarnings("cast") + static final class Owners { + /** List of UUIDs of owners to limit assets by. */ + @Singular + List ownerIds; + + /** Listing of details for the owners that are specified, keyed by the username. */ + @Singular + Map selectedOwners; + + /** List of usernames of owners to limit assets by. */ + @Singular + List ownerUsers; + + /** Listing of details for the owners that are specified, keyed by the group alias. */ + @Singular + Map selectedGroups; + + /** List of group aliases of owners to limit assets by. */ + @Singular + List ownerGroups; + + /** If true, include assets with no owners, otherwise only include assets with selected owners. */ + Boolean empty; + } + + @Getter + @SuperBuilder + @EqualsAndHashCode + static final class Terms { + /** Whether to include assets with no terms assigned (true) or not (false). */ + Boolean empty; + + /** Comparison operator to use for matching the terms specified. */ + String operator; + + /** Details of the terms to use for matching. */ + @Singular + List terms; + } + + @Getter + @SuperBuilder + @EqualsAndHashCode + static final class OwnerDetails { + /** First name of the user. */ + String firstName; + + /** UUID of the user. */ + String id; + + /** Username of the user. */ + String username; + + /** Surname of the user. */ + String lastName; + + /** Whether the user is active (true) or deactivated (false). */ + Boolean enabled; + + /** Email address of the user. */ + String email; + + public static OwnerDetails from(AtlanUser user) { + if (user == null) return null; + return builder() + .firstName(user.getFirstName()) + .id(user.getId()) + .username(user.getUsername()) + .lastName(user.getLastName()) + .enabled(user.getEnabled()) + .email(user.getEmail()) + .build(); + } + } + + @Getter + @SuperBuilder + @EqualsAndHashCode + @SuppressWarnings("cast") + static final class TermDetails { + /** UUID of the term. */ + String guid; + + /** Unique name of the term. */ + String qualifiedName; + + /** Type of the term. */ + final String typeName = GlossaryTerm.TYPE_NAME; + + /** Attributes of the term. */ + Map attributes; + } + + @Getter + private static final class FilterParams { + private final Map filters = new LinkedHashMap<>(); + private final Map postFilters = new LinkedHashMap<>(); + + FilterParams(LinkableQuery query) { + if (query.certificateStatuses != null) { + List list = new ArrayList<>(); + for (CertificateStatus status : query.certificateStatuses) { + if (status == null) { + list.add(Removable.NULL); + } else { + list.add(status.getValue()); + } + } + filters.put("certificateStatus", list); + } + if (query.hierarchy != null) { + filters.put("hierarchy", query.hierarchy); + } + if (query.properties != null) { + Map propertyMap = new LinkedHashMap<>(); + for (DiscoveryFilter filter : query.properties) { + propertyMap.put(filter.filterKey, List.of(filter)); + } + filters.put("properties", propertyMap); + } + if (query.tags != null) { + filters.put("__traitNames", Map.of("classifications", query.tags)); + } + if (query.typeNames != null) { + List types = new ArrayList<>(); + for (String typeName : query.typeNames) { + types.add(new TypeName(typeName)); + } + postFilters.put("typeName", types); + } + } + } + + @Getter + private static final class TypeName { + String id; + String label; + + TypeName(String typeName) { + this.id = typeName; + this.label = StringUtils.getTitleCase(typeName); + } + } + + public abstract static class LinkableQueryBuilder> { + + /** + * Limit assets to a given connection. + * @param connection for which to limit assets + * @return the query builder, limited to assets from the specific connection + */ + public B forConnection(Connection connection) { + String qn = connection.getQualifiedName(); + return hierarchy(AssetHierarchy.builder() + .connectionQualifiedName(connection.getQualifiedName()) + .connectorName( + Connection.getConnectorTypeFromQualifiedName(qn).getValue()) + .attributeName("") + .attributeValue("") + .build()); + } + + /** + * Limit assets to a specified subset of those in a connection. + * @param qualifiedNamePrefix full qualifiedName prefix that all assets in the subset should start with + * @param denormalizedAttributeName name of the denormalized attribute where the prefix can be found on all assets (for example, {@code schemaQualifiedName}) + * @return the query builder, limited to a subset of assets in a connection + */ + public B forPrefix(String qualifiedNamePrefix, String denormalizedAttributeName) { + String connectionQN = StringUtils.getConnectionQualifiedName(qualifiedNamePrefix); + if (connectionQN != null) { + return hierarchy(AssetHierarchy.builder() + .connectionQualifiedName(connectionQN) + .connectorName(Connection.getConnectorTypeFromQualifiedName(connectionQN) + .getValue()) + .attributeName(denormalizedAttributeName) + .attributeValue(qualifiedNamePrefix) + .build()); + } else { + return hierarchy(AssetHierarchy.builder().build()); + } + } + + /** + * Limit assets to those with a particular Atlan tag assigned. + * @param tagName human-readable name of the Atlan tag + * @return the query builder, limited to assets with the Atlan tag assigned + */ + public B withTag(String tagName) { + return tag(TagFilter.of(client, tagName)); + } + + /** + * Limit assets to those with a particular source tag assigned, with a particular value. + * @param tagName human-readable name of the Atlan tag mapped to a source tag + * @param value of the source tag + * @return the query builder, limited to assets with the Atlan tag assigned with a particular value + */ + public B withTagValue(String tagName, String value) { + return tag(TagFilter.of(client, tagName, value)); + } + + /** + * Limit assets to those without any owners defined (individuals or groups). + * @return the query builder, limited to assets without any owners assigned + */ + public B withoutOwners() { + return owners(Owners.builder().empty(true).build()); + } + + /** + * Limit assets to those with any of the specified owners. + * @param usernames (optional) list of usernames to match as owners + * @param groups (optional) list of internal group names to match as owners + * @return the query builder, limited to assets with any of the specified owners assigned + * @throws AtlanException if there are problems confirming any of the provided owners + */ + public B withOwners(List usernames, List groups) throws AtlanException { + if ((usernames == null || usernames.isEmpty()) && (groups == null || groups.isEmpty())) { + return withoutOwners(); + } + Owners.OwnersBuilder builder = Owners.builder(); + if (usernames != null) { + for (String username : usernames) { + AtlanUser user = client.getUserCache().getByName(username, true); + builder.ownerUser(username).ownerId(user.getId()).selectedOwner(username, OwnerDetails.from(user)); + } + } + if (groups != null) { + for (String alias : groups) { + AtlanGroup group = client.getGroupCache().getByName(alias, true); + builder.ownerGroup(alias).selectedGroup(alias, group); + } + } + return owners(builder.build()); + } + + /** + * Limit assets to those with any of the specified terms assigned. + * @param terms minimal details about the terms, which must include at least GUID, qualifiedName, and name + * @return the query builder, limited to assets with any of the specified terms assigned + */ + public B withAnyOf(List terms) { + return withTerms("equals", terms); + } + + /** + * Limit assets to those with all the specified terms assigned. + * @param terms minimal details about the terms, which must include at least GUID, qualifiedName, and name + * @return the query builder, limited to assets with all the specified terms assigned + */ + public B withAll(List terms) { + return withTerms("AND", terms); + } + + /** + * Limit assets to those with none of the specified terms assigned. + * @param terms minimal details about the terms, which must include at least GUID, qualifiedName, and name + * @return the query builder, limited to assets with none of the specified terms assigned + */ + public B withNoneOf(List terms) { + return withTerms("NAND", terms); + } + + /** + * Limit assets to those with no terms assigned. + * @return the query builder, limited to assets without any terms assigned + */ + public B withoutTerms() { + return terms(Terms.builder().operator("isNull").build()); + } + + /** + * Limit assets to those with any terms assigned. + * @return the query builder, limited to assets with any terms assigned + */ + public B withAnyTerm() { + return terms(Terms.builder().operator("isNotNull").build()); + } + + private B withTerms(String operator, List terms) { + Terms.TermsBuilder builder = Terms.builder().operator(operator); + for (GlossaryTerm term : terms) { + builder.term(TermDetails.builder() + .guid(term.getGuid()) + .qualifiedName(term.getQualifiedName()) + .attributes(Map.of("name", term.getName())) + .build()); + } + return terms(builder.build()); + } + + /** + * Convert this linkable query into an actual link (URL). + * @return the URL (without tenant domain / hostname) for accessing this limited set of assets + */ + public String toUrl() { + return build().getUrl(); + } + + /** + * Convert this linkable query into an actual link (URL). + * Note: this will not work if the code is running in the tenant itself (e.g. via a custom package). + * @return the full URL (including tenant domain) for accessing this limited set of assets in the tenant + */ + public String toFullUrl() { + return build().getFullUrl(); + } + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/NumericFilterField.java b/sdk/src/main/java/com/atlan/model/discovery/NumericFilterField.java new file mode 100644 index 0000000000..12af9df558 --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/NumericFilterField.java @@ -0,0 +1,97 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import lombok.Getter; + +/** + * Represents any field in Atlan that can be used for discovery by numerical comparison. + */ +@Getter +public class NumericFilterField extends DiscoveryFilterField { + + /** + * Default constructor + * @param field name of the field to filter by (singular) + */ + public NumericFilterField(String field) { + super(field); + } + + /** + * Returns a query that will match all assets whose provided field has a value that exactly equals + * the provided value. + * + * @param value the value to check the field's value is exactly equal to + * @return a query that will only match assets whose value for the field is exactly equal to the provided value + */ + public DiscoveryFilter eq(T value) { + return build("equals", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that does not exactly equal + * the provided value. + * + * @param value the value to check the field's value is NOT exactly equal to + * @return a query that will only match assets whose value for the field is not exactly equal to the provided value + */ + public DiscoveryFilter neq(String value) { + return build("notEquals", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that starts with + * the provided value. + * + * @param value the value to check the field's value starts with + * @return a query that will only match assets whose value for the field starts with the provided value + */ + public DiscoveryFilter startsWith(String value) { + return build("startsWith", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that ends with + * the provided value. + * + * @param value the value to check the field's value ends with + * @return a query that will only match assets whose value for the field ends with the provided value + */ + public DiscoveryFilter endsWith(String value) { + return build("endsWith", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that contains + * the provided value. + * + * @param value the value to check the field's value contains + * @return a query that will only match assets whose value for the field contains the provided value + */ + public DiscoveryFilter contains(String value) { + return build("contains", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that does not contain + * the provided value. + * + * @param value the value to check the field's value does NOT contain + * @return a query that will only match assets whose value for the field does not contain the provided value + */ + public DiscoveryFilter doesNotContain(String value) { + return build("notContains", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that matches the provided + * regular expression pattern. + * + * @param value the regular expression to check the field's value matches + * @return a query that will only match assets whose value for the field matches the provided regular expression + */ + public DiscoveryFilter regex(String value) { + return build("pattern", value); + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/StrictEqualityFilterField.java b/sdk/src/main/java/com/atlan/model/discovery/StrictEqualityFilterField.java new file mode 100644 index 0000000000..2a01e2e7d7 --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/StrictEqualityFilterField.java @@ -0,0 +1,38 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import java.util.List; + +/** + * Represents any field in Atlan that can be used for discovery only by exactly matching a value (positively). + */ +public class StrictEqualityFilterField extends DiscoveryFilterField { + + /** + * Default constructor + * @param field name of the field to filter by (singular) + */ + public StrictEqualityFilterField(String field) { + super(field); + } + + /** + * Default constructor + * @param fields names of the fields to filter by (multiple) + */ + public StrictEqualityFilterField(List fields) { + super(fields); + } + + /** + * Returns a query that will match all assets whose provided field has a value that exactly equals + * the provided value. + * + * @param value the value to check the field's value is exactly equal to + * @return a query that will only match assets whose value for the field is exactly equal to the provided value + */ + public DiscoveryFilter eq(String value) { + return build("equals", value); + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/StringFilterField.java b/sdk/src/main/java/com/atlan/model/discovery/StringFilterField.java new file mode 100644 index 0000000000..4fefb5e926 --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/StringFilterField.java @@ -0,0 +1,84 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import java.util.List; +import lombok.Getter; + +/** + * Represents any field in Atlan that can be used for discovery by string comparisons. + */ +@Getter +public class StringFilterField extends ExactMatchFilterField { + + /** + * Default constructor + * @param field name of the field to filter by (singular) + */ + public StringFilterField(String field) { + super(field); + } + + /** + * Default constructor + * @param fields names of the fields to filter by (multiple) + */ + public StringFilterField(List fields) { + super(fields); + } + + /** + * Returns a query that will match all assets whose provided field has a value that starts with + * the provided value. + * + * @param value the value to check the field's value starts with + * @return a query that will only match assets whose value for the field starts with the provided value + */ + public DiscoveryFilter startsWith(String value) { + return build("startsWith", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that ends with + * the provided value. + * + * @param value the value to check the field's value ends with + * @return a query that will only match assets whose value for the field ends with the provided value + */ + public DiscoveryFilter endsWith(String value) { + return build("endsWith", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that contains + * the provided value. + * + * @param value the value to check the field's value contains + * @return a query that will only match assets whose value for the field contains the provided value + */ + public DiscoveryFilter contains(String value) { + return build("contains", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that does not contain + * the provided value. + * + * @param value the value to check the field's value does NOT contain + * @return a query that will only match assets whose value for the field does not contain the provided value + */ + public DiscoveryFilter doesNotContain(String value) { + return build("notContains", value); + } + + /** + * Returns a query that will match all assets whose provided field has a value that matches the provided + * regular expression pattern. + * + * @param value the regular expression to check the field's value matches + * @return a query that will only match assets whose value for the field matches the provided regular expression + */ + public DiscoveryFilter regex(String value) { + return build("pattern", value); + } +} diff --git a/sdk/src/main/java/com/atlan/model/discovery/TagFilter.java b/sdk/src/main/java/com/atlan/model/discovery/TagFilter.java new file mode 100644 index 0000000000..dace858537 --- /dev/null +++ b/sdk/src/main/java/com/atlan/model/discovery/TagFilter.java @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: Apache-2.0 + Copyright 2023 Atlan Pte. Ltd. */ +package com.atlan.model.discovery; + +import com.atlan.AtlanClient; +import com.atlan.exception.AtlanException; +import com.atlan.model.assets.ITag; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Singular; +import lombok.experimental.SuperBuilder; +import lombok.extern.slf4j.Slf4j; + +/** + * Class to compose a single filter criterion, by tag, for use in a linkable query. + */ +@Getter +@SuperBuilder(toBuilder = true, builderMethodName = "_internal") +@EqualsAndHashCode +@Slf4j +public class TagFilter { + + /** Atlan-internal tag name for the tag. */ + String name; + + /** Human-readable name for the tag. */ + String displayName; + + /** Value to compare the operand against. */ + @Singular + List tagValues; + + @Getter + @SuperBuilder(toBuilder = true) + @EqualsAndHashCode + public static final class TagValue { + /** Value of the tag to match. */ + String consolidatedValue; + + /** Unique name(s) of the source tags to match the value against. */ + @Singular + List tagQFNames; + } + + /** + * Create a new TagFilter to limit assets to those with the human-readable Atlan tag specified. + * + * @param client connectivity to the Atlan tenant + * @param tagName human-readable name of the Atlan tag + * @return a filter to limit assets to those with the tag + */ + public static TagFilter of(AtlanClient client, String tagName) { + try { + String clsId = client.getAtlanTagCache().getIdForName(tagName); + return TagFilter._internal().displayName(tagName).name(clsId).build(); + } catch (AtlanException e) { + log.error("Unable to translate tag -- skipping: {}", tagName, e); + } + return null; + } + + /** + * Create a new TagFilter to limit assets to those with the human-readable Atlan tag specified, + * which in turn is mapped to a source tag, and thus can be further narrowed by the value of the + * source tag. + * + * @param client connectivity to the Atlan tenant + * @param tagName human-readable name of the Atlan tag + * @param value of the tag to further narrow by + * @return a filter to limit assets to those with the tag + */ + public static TagFilter of(AtlanClient client, String tagName, String value) { + TagFilter starter = of(client, tagName); + if (starter != null && value != null && !value.isEmpty()) { + try { + List sourceTags = client.getSourceTagCache().getByMappedAtlanTag(starter.getName()); + List qualifiedNames = + sourceTags.stream().map(ITag::getQualifiedName).toList(); + return starter.toBuilder() + .tagValue(TagValue.builder() + .tagQFNames(qualifiedNames) + .consolidatedValue(value) + .build()) + .build(); + } catch (AtlanException e) { + log.error("Unable to find any source tags mapped to tag -- skipping: {}", tagName, e); + } + } + return starter; + } +} diff --git a/sdk/src/main/java/com/atlan/util/StringUtils.java b/sdk/src/main/java/com/atlan/util/StringUtils.java index 9612f2c85f..63f53dcfdf 100644 --- a/sdk/src/main/java/com/atlan/util/StringUtils.java +++ b/sdk/src/main/java/com/atlan/util/StringUtils.java @@ -299,4 +299,22 @@ private static String getSnakeCase(String text) { .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") .replaceAll("([a-z])([A-Z])", "$1_$2"); } + + /** + * Convert the provided string to Title Case. + * @param text to convert + * @return the original text, in Title Case + */ + public static String getTitleCase(String text) { + StringBuilder titleCase = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char currentChar = text.charAt(i); + // Insert a space before uppercase letters except for the first character + if (i > 0 && Character.isUpperCase(currentChar)) { + titleCase.append(" "); + } + titleCase.append(i == 0 ? Character.toUpperCase(currentChar) : currentChar); + } + return titleCase.toString(); + } }