diff --git a/api/src/main/java/org/jfrog/artifactory/client/model/AqlItem.java b/api/src/main/java/org/jfrog/artifactory/client/model/AqlItem.java index 00482d9f..cf356509 100644 --- a/api/src/main/java/org/jfrog/artifactory/client/model/AqlItem.java +++ b/api/src/main/java/org/jfrog/artifactory/client/model/AqlItem.java @@ -19,8 +19,18 @@ public class AqlItem { private AqlItemType type; @JsonProperty("actual_md5") private String actualMd5; + @JsonProperty("original_md5") + private String originalMd5; @JsonProperty("actual_sha1") private String actualSha1; + @JsonProperty("sha256") + private String sha256; + @JsonProperty("created_by") + private String createdBy; + @JsonProperty("modified_by") + private String modifiedBy; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX") + private Date updated; private Long size; private List properties; @@ -52,10 +62,30 @@ public String getActualMd5() { return actualMd5; } + public String getOriginalMd5() { + return originalMd5; + } + public String getActualSha1() { return actualSha1; } + public String getSha256() { + return sha256; + } + + public String getCreatedBy() { + return createdBy; + } + + public String getModifiedBy() { + return modifiedBy; + } + + public Date getUpdated() { + return updated; + } + public Long getSize() { return size; } @@ -77,5 +107,48 @@ public String getkey() { public String getValue() { return value; } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("Property ["); + if (key != null) { sb.append("key=").append(key); } + if (value != null) { + if (sb.length() > "Property [".length()) { sb.append(", "); } + sb.append("value=").append(value); + } + return sb.append("]").toString(); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("AqlItem ["); + appendIfNotNull(sb, "repo", repo); + appendIfNotNull(sb, "path", path); + appendIfNotNull(sb, "name", name); + appendIfNotNull(sb, "created", created); + appendIfNotNull(sb, "modified", modified); + appendIfNotNull(sb, "type", type); + appendIfNotNull(sb, "actualMd5", actualMd5); + appendIfNotNull(sb, "originalMd5", originalMd5); + appendIfNotNull(sb, "actualSha1", actualSha1); + appendIfNotNull(sb, "sha256", sha256); + appendIfNotNull(sb, "createdBy", createdBy); + appendIfNotNull(sb, "modifiedBy", modifiedBy); + appendIfNotNull(sb, "updated", updated); + appendIfNotNull(sb, "size", size); + appendIfNotNull(sb, "properties", properties); + return sb.append("]").toString(); + } + + private void appendIfNotNull(StringBuilder sb, String fieldName, Object value) { + if (value == null) { + return; + } + // add separator only when there is already at least one field present + if (sb.length() > "AqlItem [".length()) { + sb.append(", "); + } + sb.append(fieldName).append("=").append(value); } } diff --git a/services/src/main/java/org/jfrog/artifactory/client/aql/FileSpecBuilder.java b/services/src/main/java/org/jfrog/artifactory/client/aql/FileSpecBuilder.java new file mode 100644 index 00000000..96067852 --- /dev/null +++ b/services/src/main/java/org/jfrog/artifactory/client/aql/FileSpecBuilder.java @@ -0,0 +1,294 @@ +package org.jfrog.artifactory.client.aql; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jfrog.filespecs.FileSpec; +import org.jfrog.filespecs.entities.Aql; +import org.jfrog.filespecs.entities.FilesGroup; + +import java.util.Arrays; +import java.util.Collection; + +/** + * Fluent builder that constructs a {@link FileSpec} directly from AQL predicate + * expressions for use with {@link org.jfrog.artifactory.client.Searches#artifactsByFileSpec}. + * + *

Each builder instance represents a single {@code FilesGroup} (one entry in the + * {@code "files"} array). Call {@link #buildFileSpec()} to wrap it in a new + * {@link FileSpec}, or {@link #addToFileSpec(FileSpec)} to append it to an existing one + * (multi-group / multi-query specs). + * + *

Predicate methods ({@link #item}, {@link #match}, {@link #eq}, …) populate the + * JSON body passed to {@code items.find(…)}. Suffix methods ({@link #limit}, + * {@link #offset}, {@link #sortAsc}, {@link #sortDesc}) are stored in the matching + * {@link FilesGroup} fields and assembled by + * {@code AqlConverter.convertFilesGroupToAql} at search time — they are not + * baked into the find-body string. + * + *

Use {@link #include(String...)} to control which fields are returned by Artifactory. + * When set, the library's default {@code .include(…)} is replaced with the caller-supplied + * list. Custom property fields (prefixed with {@code @}) are supported. + * + *

Example — produces the equivalent of: + *

{@code
+ * {
+ *   "files": [{
+ *     "aql": {
+ *       "items.find": {
+ *         "type": "file",
+ *         "repo": {"$match": "pnc-devel-*"},
+ *         "property.key": {"$eq": "pnc.build-BQBPZZFPTRYAA"}
+ *       }
+ *     },
+ *     "include": ["name","repo","path","size","actual_sha1","actual_md5","sha256","@jf.origin.remote.path"],
+ *     "limit": 50000
+ *   }]
+ * }
+ * }
+ * + *
{@code
+ * FileSpec spec = new FileSpecBuilder()
+ *     .item("type", "file")
+ *     .match("repo", "pnc-devel-*")
+ *     .eq("property.key", "pnc.build-BQBPZZFPTRYAA")
+ *     .include("name", "repo", "path", "size", "actual_sha1", "actual_md5", "sha256",
+ *              "@jf.origin.remote.path")
+ *     .limit(50000)
+ *     .buildFileSpec();
+ * }
+ * + * @see org.jfrog.artifactory.client.Searches#artifactsByFileSpec(FileSpec) + * @see AqlQueryBuilder for building raw AQL strings for the direct POST path + */ +public class FileSpecBuilder { + + // ── find-body ───────────────────────────────────────────────────────────── + private final AqlRootElement root = new AqlRootElement(); + + // ── FilesGroup suffix fields ─────────────────────────────────────────────── + private String[] sortBy; + private String sortOrder; + private Integer limit; + private Integer offset; + + // ── explicit include fields (null = use library default) ────────────────── + private String[] includeFields; + + // ── find-body predicate methods ─────────────────────────────────────────── + + /** + * Adds a literal equality field: {@code "key": value}. + * Use {@link #eq(String, String)} when you need the explicit {@code {"$eq":"…"}} form + * (e.g. for {@code property.key} comparisons). + */ + public FileSpecBuilder item(String key, Object value) { + root.putAll(AqlItem.aqlItem(key, value).value()); + return this; + } + + /** {@code "key": {"$match": "pattern"}} — wildcard glob match. */ + public FileSpecBuilder match(String key, String pattern) { + root.putAll(AqlItem.match(key, pattern).value()); + return this; + } + + /** {@code "key": {"$nmatch": "pattern"}} — negated wildcard glob match. */ + public FileSpecBuilder notMatch(String key, String pattern) { + root.putAll(AqlItem.notMatch(key, pattern).value()); + return this; + } + + /** + * {@code "key": {"$eq": "value"}} — explicit AQL equality operator. + * Distinct from the bare-literal form emitted by {@link #item(String, Object)}; + * required for {@code property.key} / {@code property.value} predicates. + */ + public FileSpecBuilder eq(String key, String value) { + root.putAll(AqlItem.aqlItem(key, AqlItem.aqlItem("$eq", value)).value()); + return this; + } + + /** {@code "key": {"$ne": "value"}} — AQL not-equal operator. */ + public FileSpecBuilder ne(String key, String value) { + root.putAll(AqlItem.aqlItem(key, AqlItem.aqlItem("$ne", value)).value()); + return this; + } + + /** + * {@code "$and": [{…}, …]} — wraps multiple {@link AqlItem} conditions in a + * logical AND. Use the {@link AqlItem} factory methods to build the items. + */ + public FileSpecBuilder and(AqlItem... items) { + if (items.length > 0) { + root.putAll(AqlItem.and((Object[]) items).value()); + } + return this; + } + + /** Convenience overload accepting a {@link Collection}. */ + public FileSpecBuilder and(Collection items) { + return and(items.toArray(new AqlItem[0])); + } + + /** + * {@code "$or": [{…}, …]} — wraps multiple {@link AqlItem} conditions in a + * logical OR. + */ + public FileSpecBuilder or(AqlItem... items) { + if (items.length > 0) { + root.putAll(AqlItem.or((Object[]) items).value()); + } + return this; + } + + /** Convenience overload accepting a {@link Collection}. */ + public FileSpecBuilder or(Collection items) { + return or(items.toArray(new AqlItem[0])); + } + + // ── include method ──────────────────────────────────────────────────────── + + /** + * Overrides the default {@code .include(…)} clause that Artifactory appends to every + * AQL query. By default the library includes a fixed set of item fields; calling this + * method replaces that set with exactly the fields you specify. + * + *

Standard item fields: {@code name}, {@code repo}, {@code path}, {@code size}, + * {@code actual_sha1}, {@code actual_md5}, {@code sha256}, {@code type}, + * {@code modified}, {@code created}. + * Custom property fields use the {@code @} prefix, e.g. {@code "@jf.origin.remote.path"}. + * + *

When this method is called, {@link #buildFileSpec()} and {@link #addToFileSpec(FileSpec)} + * return an {@link IncludeAwareFileSpec} whose {@code toAql()} bypasses the library's + * hardcoded include and injects the caller-supplied fields instead. + */ + public FileSpecBuilder include(String... fields) { + this.includeFields = Arrays.copyOf(fields, fields.length); + return this; + } + + // ── suffix / pagination methods ─────────────────────────────────────────── + + /** + * Sort results ascending by the given fields. + * Stored in {@link FilesGroup#setSortBy(String[])} and + * {@link FilesGroup#setSortOrder(String)}; assembled by {@code AqlBuildingUtils} + * at search time. + */ + public FileSpecBuilder sortAsc(String... fields) { + this.sortBy = fields; + this.sortOrder = "asc"; + return this; + } + + /** Sort results descending by the given fields. */ + public FileSpecBuilder sortDesc(String... fields) { + this.sortBy = fields; + this.sortOrder = "desc"; + return this; + } + + /** Maximum number of items to return. Stored in {@link FilesGroup#setLimit(String)}. */ + public FileSpecBuilder limit(int limit) { + this.limit = limit; + return this; + } + + /** Number of items to skip. Stored in {@link FilesGroup#setOffset(String)}. */ + public FileSpecBuilder offset(int offset) { + this.offset = offset; + return this; + } + + // ── build methods ───────────────────────────────────────────────────────── + + /** + * Builds the {@link FilesGroup} represented by this builder. + * The group's spec-type is always {@link FilesGroup.SpecType#AQL}. + */ + public FilesGroup buildGroup() { + Aql aql = new Aql(); + aql.setFind(serializeRoot()); + + FilesGroup group = new FilesGroup().setAql(aql); + if (sortBy != null) { group.setSortBy(Arrays.copyOf(sortBy, sortBy.length)); } + if (sortOrder != null) { group.setSortOrder(sortOrder); } + if (limit != null) { group.setLimit(String.valueOf(limit)); } + if (offset != null) { group.setOffset(String.valueOf(offset)); } + return group; + } + + /** + * Wraps the built {@link FilesGroup} in a new single-group {@link FileSpec}. + * + *

If {@link #include(String...)} was called, returns an {@link IncludeAwareFileSpec} + * whose {@code toAql()} replaces the library's default {@code .include(…)} with the + * caller-supplied field list. Otherwise returns a plain {@link FileSpec}. + */ + public FileSpec buildFileSpec() { + FilesGroup group = buildGroup(); + if (includeFields != null) { + IncludeAwareFileSpec spec = new IncludeAwareFileSpec(); + spec.addGroup(group, includeFields); + return spec; + } + FileSpec spec = new FileSpec(); + spec.addFilesGroup(group); + return spec; + } + + /** + * Appends the built {@link FilesGroup} to an existing {@link FileSpec} and + * returns that same spec. Use this to accumulate multiple groups (one POST + * per group is issued by + * {@link org.jfrog.artifactory.client.impl.SearchesImpl#artifactsByFileSpec}). + * + *

If {@link #include(String...)} was called and {@code spec} is an + * {@link IncludeAwareFileSpec}, the include override is registered for this group. + * If {@code spec} is a plain {@link FileSpec} and an include override is set, it is + * promoted to an {@link IncludeAwareFileSpec} first. + * + *

{@code
+     * FileSpec spec = new FileSpec();
+     * for (String buildId : buildIds) {
+     *     new FileSpecBuilder()
+     *         .item("type", "file")
+     *         .match("repo", "pnc-devel-*")
+     *         .eq("property.key", buildId)
+     *         .include("name", "repo", "path", "size", "actual_sha1")
+     *         .limit(50000)
+     *         .addToFileSpec(spec);
+     * }
+     * List results = artifactory.searches().artifactsByFileSpec(spec);
+     * }
+ */ + public FileSpec addToFileSpec(FileSpec spec) { + FilesGroup group = buildGroup(); + if (includeFields != null) { + if (spec instanceof IncludeAwareFileSpec) { + ((IncludeAwareFileSpec) spec).addGroup(group, includeFields); + } else { + // Promote the existing plain FileSpec into an IncludeAwareFileSpec so that + // previously-added groups (no override) still use the library default. + IncludeAwareFileSpec promoted = new IncludeAwareFileSpec(spec); + promoted.addGroup(group, includeFields); + // Callers hold the original reference; we can't reassign it here. + // Return the promoted instance so the caller can update their reference. + return promoted; + } + } else { + spec.addFilesGroup(group); + } + return spec; + } + + // ── internals ───────────────────────────────────────────────────────────── + + private String serializeRoot() { + try { + return new ObjectMapper().writeValueAsString(root); + } catch (JsonProcessingException e) { + throw new AqlBuilderException("Error serializing AQL find-body to JSON: ", e); + } + } +} diff --git a/services/src/main/java/org/jfrog/artifactory/client/aql/IncludeAwareFileSpec.java b/services/src/main/java/org/jfrog/artifactory/client/aql/IncludeAwareFileSpec.java new file mode 100644 index 00000000..b73f5af8 --- /dev/null +++ b/services/src/main/java/org/jfrog/artifactory/client/aql/IncludeAwareFileSpec.java @@ -0,0 +1,143 @@ +package org.jfrog.artifactory.client.aql; + +import org.jfrog.filespecs.FileSpec; +import org.jfrog.filespecs.aql.AqlConverter; +import org.jfrog.filespecs.entities.FilesGroup; +import org.jfrog.filespecs.entities.InvalidFileSpecException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.StringJoiner; + +/** + * A {@link FileSpec} subclass that allows individual {@link FilesGroup} entries to carry + * an explicit {@code .include(…)} field list, overriding the fixed set that + * {@code AqlBuildingUtils} always appends. + * + *

Groups registered via {@link #addGroup(FilesGroup, String[])} have their AQL string + * built locally (bypassing {@code AqlConverter}) so that the caller-supplied include list + * is emitted at the correct position: + *

+ *   items.find({…}).include("name","repo",…).sort(…).offset(…).limit(…)
+ * 
+ * + *

Groups added through the inherited {@link FileSpec#addFilesGroup(FilesGroup)} path + * (no override) are converted by the standard {@code AqlConverter}, preserving existing + * behaviour. + * + *

Instances are created by {@link FileSpecBuilder#buildFileSpec()} and + * {@link FileSpecBuilder#addToFileSpec(FileSpec)} when {@link FileSpecBuilder#include} + * has been called. + */ +class IncludeAwareFileSpec extends FileSpec { + + /** + * Maps each group that carries an explicit include override to its field list. + * Identity semantics are used so that two equal-valued {@link FilesGroup} objects + * can coexist without colliding. + */ + private final Map includeOverrides = new IdentityHashMap<>(); + + /** Creates an empty spec. */ + IncludeAwareFileSpec() { + super(); + } + + /** + * Creates an {@link IncludeAwareFileSpec} pre-populated with all groups from an + * existing {@link FileSpec}. Those groups carry no include override and will + * continue to use the library default. + */ + IncludeAwareFileSpec(FileSpec existing) { + super(); + if (existing.getFiles() != null) { + for (FilesGroup g : existing.getFiles()) { + addFilesGroup(g); + } + } + } + + /** + * Registers {@code group} with an explicit include override and adds it to the + * group list. + */ + void addGroup(FilesGroup group, String[] fields) { + addFilesGroup(group); + includeOverrides.put(group, Arrays.copyOf(fields, fields.length)); + } + + /** + * Converts each group to its AQL string. + * + *

    + *
  • Groups with an include override are converted locally so the caller-supplied + * fields replace the library's hardcoded {@code .include(…)}.
  • + *
  • All other groups fall through to {@code AqlConverter}, preserving the + * library's default behaviour.
  • + *
+ */ + @Override + public List toAql() throws InvalidFileSpecException { + List aqls = new ArrayList<>(); + for (FilesGroup group : getFiles()) { + String[] override = includeOverrides.get(group); + if (override != null) { + aqls.add(buildAql(group, override)); + } else { + aqls.add(AqlConverter.convertFilesGroupToAql(group)); + } + } + return aqls; + } + + // ── internals ───────────────────────────────────────────────────────────── + + /** + * Assembles the AQL string for a group that carries an explicit include override. + * + *

The order of suffixes follows the AQL specification: + * {@code .include(…).sort(…).offset(…).limit(…)}. + */ + private static String buildAql(FilesGroup group, String[] includeFields) { + StringBuilder sb = new StringBuilder(); + sb.append("items.find(").append(group.getAql()).append(")"); + sb.append(buildInclude(includeFields)); + sb.append(buildSort(group)); + if (isNotBlank(group.getOffset())) { + sb.append(".offset(").append(group.getOffset()).append(")"); + } + if (isNotBlank(group.getLimit())) { + sb.append(".limit(").append(group.getLimit()).append(")"); + } + return sb.toString(); + } + + private static String buildInclude(String[] fields) { + StringJoiner joiner = new StringJoiner(","); + for (String f : fields) { + joiner.add("\"" + f + "\""); + } + return ".include(" + joiner + ")"; + } + + private static String buildSort(FilesGroup group) { + String[] sortBy = group.getSortBy(); + if (sortBy == null || sortBy.length == 0) { + return ""; + } + String order = (group.getSortOrder() != null && !group.getSortOrder().isEmpty()) + ? group.getSortOrder() : "asc"; + StringJoiner joiner = new StringJoiner(","); + for (String f : sortBy) { + joiner.add("\"" + f + "\""); + } + return ".sort({\"$" + order + "\":[" + joiner + "]})"; + } + + private static boolean isNotBlank(String s) { + return s != null && !s.trim().isEmpty(); + } +} diff --git a/services/src/test/java/org/jfrog/artifactory/client/aql/FileSpecBuilderTest.java b/services/src/test/java/org/jfrog/artifactory/client/aql/FileSpecBuilderTest.java new file mode 100644 index 00000000..27ed0c37 --- /dev/null +++ b/services/src/test/java/org/jfrog/artifactory/client/aql/FileSpecBuilderTest.java @@ -0,0 +1,389 @@ +package org.jfrog.artifactory.client.aql; + +import org.jfrog.filespecs.FileSpec; +import org.jfrog.filespecs.entities.FilesGroup; +import org.jfrog.filespecs.entities.InvalidFileSpecException; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.jfrog.artifactory.client.aql.AqlItem.aqlItem; +import static org.jfrog.artifactory.client.aql.AqlItem.match; +import static org.jfrog.artifactory.client.aql.AqlItem.or; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +public class FileSpecBuilderTest { + + // ── find-body round-trips ────────────────────────────────────────────────── + + @Test + public void emptyBuilderProducesEmptyFindBody() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder().buildFileSpec()); + assertEquals(aql, "items.find({})" + defaultInclude()); + } + + @Test + public void itemLiteralEquality() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .buildFileSpec()); + assertTrue(aql.startsWith("items.find({\"type\":\"file\"})"), aql); + } + + @Test + public void matchWildcard() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .match("repo", "pnc-devel-*") + .buildFileSpec()); + assertTrue(aql.contains("\"repo\":{\"$match\":\"pnc-devel-*\"}"), aql); + } + + @Test + public void notMatch() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .notMatch("repo", "libs-*") + .buildFileSpec()); + assertTrue(aql.contains("\"repo\":{\"$nmatch\":\"libs-*\"}"), aql); + } + + @Test + public void eqOperator() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .eq("property.key", "pnc.build-BQBPZZFPTRYAA") + .buildFileSpec()); + assertTrue(aql.contains("\"property.key\":{\"$eq\":\"pnc.build-BQBPZZFPTRYAA\"}"), aql); + } + + @Test + public void neOperator() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .ne("property.key", "excluded-key") + .buildFileSpec()); + assertTrue(aql.contains("\"property.key\":{\"$ne\":\"excluded-key\"}"), aql); + } + + @Test + public void motivatingExample() throws InvalidFileSpecException { + // Equivalent to the JSON FileSpec in the design doc + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .match("repo", "pnc-devel-*") + .eq("property.key", "pnc.build-BQBPZZFPTRYAA") + .limit(50000) + .buildFileSpec()); + + assertTrue(aql.contains("\"type\":\"file\""), aql); + assertTrue(aql.contains("\"repo\":{\"$match\":\"pnc-devel-*\"}"), aql); + assertTrue(aql.contains("\"property.key\":{\"$eq\":\"pnc.build-BQBPZZFPTRYAA\"}"), aql); + assertTrue(aql.endsWith(".limit(50000)"), aql); + } + + // ── composite predicates ─────────────────────────────────────────────────── + + @Test + public void andPredicate() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .and(aqlItem("repo", "myrepo1"), aqlItem("repo", "myrepo2")) + .buildFileSpec()); + assertTrue(aql.contains("\"$and\":[{\"repo\":\"myrepo1\"},{\"repo\":\"myrepo2\"}]"), aql); + } + + @Test + public void andPredicateCollection() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .and(Arrays.asList(aqlItem("repo", "myrepo1"), aqlItem("repo", "myrepo2"))) + .buildFileSpec()); + assertTrue(aql.contains("\"$and\":[{\"repo\":\"myrepo1\"},{\"repo\":\"myrepo2\"}]"), aql); + } + + @Test + public void orPredicate() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .or(aqlItem("repo", "myrepo1"), aqlItem("repo", "myrepo2")) + .buildFileSpec()); + assertTrue(aql.contains("\"$or\":[{\"repo\":\"myrepo1\"},{\"repo\":\"myrepo2\"}]"), aql); + } + + @Test + public void orPredicateCollection() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .or(Arrays.asList(aqlItem("repo", "r1"), aqlItem("repo", "r2"))) + .buildFileSpec()); + assertTrue(aql.contains("\"$or\":[{\"repo\":\"r1\"},{\"repo\":\"r2\"}]"), aql); + } + + @Test + public void nestedAndOr() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .and( + or(aqlItem("repo", "libs-release"), aqlItem("repo", "libs-snapshot")), + match("name", "*.jar") + ) + .buildFileSpec()); + assertTrue(aql.contains("\"type\":\"file\""), aql); + assertTrue(aql.contains("\"$and\":"), aql); + assertTrue(aql.contains("\"$or\":"), aql); + assertTrue(aql.contains("\"name\":{\"$match\":\"*.jar\"}"), aql); + } + + // ── suffix / pagination fields ───────────────────────────────────────────── + + @Test + public void limit() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder().limit(100).buildFileSpec()); + assertTrue(aql.endsWith(".limit(100)"), aql); + } + + @Test + public void offset() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder().offset(20).buildFileSpec()); + assertTrue(aql.contains(".offset(20)"), aql); + } + + @Test + public void sortAsc() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder().sortAsc("name").buildFileSpec()); + assertTrue(aql.contains(".sort({\"$asc\":[\"name\"]})"), aql); + } + + @Test + public void sortDesc() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder().sortDesc("name", "repo").buildFileSpec()); + assertTrue(aql.contains(".sort({\"$desc\":[\"name\",\"repo\"]})"), aql); + } + + @Test + public void limitAndOffsetOrdering() throws InvalidFileSpecException { + // AqlConverter emits: .sort(…).offset(…).limit(…) + String aql = aqlFromSpec(new FileSpecBuilder() + .sortAsc("name") + .offset(10) + .limit(50) + .buildFileSpec()); + int sortIdx = aql.indexOf(".sort("); + int offsetIdx = aql.indexOf(".offset("); + int limitIdx = aql.indexOf(".limit("); + assertTrue(sortIdx < offsetIdx && offsetIdx < limitIdx, + "Expected sort < offset < limit in: " + aql); + } + + // ── FileSpec structure ───────────────────────────────────────────────────── + + @Test + public void buildFileSpecContainsOneGroup() { + FileSpec spec = new FileSpecBuilder().item("type", "file").buildFileSpec(); + assertNotNull(spec.getFiles()); + assertEquals(spec.getFiles().size(), 1); + } + + @Test + public void groupSpecTypeIsAql() { + FilesGroup group = new FileSpecBuilder().item("type", "file").buildGroup(); + assertEquals(group.getSpecType(), FilesGroup.SpecType.AQL); + } + + @Test + public void addToFileSpecAccumulatesGroups() throws InvalidFileSpecException { + FileSpec spec = new FileSpec(); + new FileSpecBuilder().match("repo", "libs-release").addToFileSpec(spec); + new FileSpecBuilder().match("repo", "libs-snapshot").addToFileSpec(spec); + + assertEquals(spec.getFiles().size(), 2); + + List aqls = spec.toAql(); + assertEquals(aqls.size(), 2); + assertTrue(aqls.get(0).contains("\"repo\":{\"$match\":\"libs-release\"}"), aqls.get(0)); + assertTrue(aqls.get(1).contains("\"repo\":{\"$match\":\"libs-snapshot\"}"), aqls.get(1)); + } + + @Test + public void multiGroupMotivatingExample() throws InvalidFileSpecException { + String[] buildIds = {"pnc.build-AAA", "pnc.build-BBB"}; + FileSpec spec = new FileSpec(); + for (String buildId : buildIds) { + new FileSpecBuilder() + .item("type", "file") + .match("repo", "pnc-devel-*") + .eq("property.key", buildId) + .limit(50000) + .addToFileSpec(spec); + } + + List aqls = spec.toAql(); + assertEquals(aqls.size(), 2); + assertTrue(aqls.get(0).contains("pnc.build-AAA"), aqls.get(0)); + assertTrue(aqls.get(1).contains("pnc.build-BBB"), aqls.get(1)); + assertTrue(aqls.get(0).endsWith(".limit(50000)"), aqls.get(0)); + } + + // ── include field tests ──────────────────────────────────────────────────── + + @Test + public void includeReplacesDefaultInclude() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .include("name", "repo", "path") + .buildFileSpec()); + assertTrue(aql.contains(".include(\"name\",\"repo\",\"path\")"), aql); + // Must NOT also contain the library's default include (which adds "actual_md5" etc.) + // — only one .include() clause should be present. + assertEquals(countOccurrences(aql, ".include("), 1, "Expected exactly one .include() in: " + aql); + } + + @Test + public void includeWithCustomPropertyField() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .include("name", "repo", "path", "size", "actual_sha1", "actual_md5", + "sha256", "@jf.origin.remote.path") + .buildFileSpec()); + assertTrue(aql.contains("\"@jf.origin.remote.path\""), aql); + assertTrue(aql.contains("\"sha256\""), aql); + assertEquals(countOccurrences(aql, ".include("), 1, aql); + } + + @Test + public void includeWithLimitPreservesLimitSuffix() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .match("repo", "pnc-devel-*") + .eq("property.key", "pnc.build-BQBPZZFPTRYAA") + .include("name", "repo", "path", "size", "actual_sha1", "actual_md5", + "sha256", "@jf.origin.remote.path") + .limit(50000) + .buildFileSpec()); + // include comes before limit + int includeIdx = aql.indexOf(".include("); + int limitIdx = aql.indexOf(".limit("); + assertTrue(includeIdx < limitIdx, "Expected .include() before .limit() in: " + aql); + assertTrue(aql.endsWith(".limit(50000)"), aql); + assertEquals(countOccurrences(aql, ".include("), 1, aql); + } + + @Test + public void includeWithSortAndOffsetOrdering() throws InvalidFileSpecException { + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .include("name", "repo") + .sortAsc("name") + .offset(10) + .limit(50) + .buildFileSpec()); + // AQL order: .include(…).sort(…).offset(…).limit(…) + int includeIdx = aql.indexOf(".include("); + int sortIdx = aql.indexOf(".sort("); + int offsetIdx = aql.indexOf(".offset("); + int limitIdx = aql.indexOf(".limit("); + assertTrue(includeIdx < sortIdx, "include before sort in: " + aql); + assertTrue(sortIdx < offsetIdx, "sort before offset in: " + aql); + assertTrue(offsetIdx < limitIdx, "offset before limit in: " + aql); + assertEquals(countOccurrences(aql, ".include("), 1, aql); + } + + @Test + public void noIncludeCallUsesLibraryDefault() throws InvalidFileSpecException { + // Sanity: omitting .include() still produces the library's default .include() + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .buildFileSpec()); + assertTrue(aql.contains(".include("), aql); + // Library default always contains "actual_md5" + assertTrue(aql.contains("\"actual_md5\""), aql); + } + + @Test + public void buildFileSpecReturnsIncludeAwareFileSpecWhenIncludeSet() { + FileSpec spec = new FileSpecBuilder() + .item("type", "file") + .include("name", "repo") + .buildFileSpec(); + assertTrue(spec instanceof IncludeAwareFileSpec, + "Expected IncludeAwareFileSpec when include() is called"); + } + + @Test + public void buildFileSpecReturnsPlainFileSpecWhenNoInclude() { + FileSpec spec = new FileSpecBuilder() + .item("type", "file") + .buildFileSpec(); + assertFalse(spec instanceof IncludeAwareFileSpec, + "Expected plain FileSpec when include() is not called"); + } + + @Test + public void addToFileSpecWithIncludeAccumulatesGroups() throws InvalidFileSpecException { + FileSpec spec = new FileSpec(); + spec = new FileSpecBuilder() + .match("repo", "libs-release") + .include("name", "repo", "path") + .addToFileSpec(spec); + new FileSpecBuilder() + .match("repo", "libs-snapshot") + .include("name", "repo", "sha256") + .addToFileSpec(spec); + + assertEquals(spec.getFiles().size(), 2); + List aqls = spec.toAql(); + assertEquals(aqls.size(), 2); + assertTrue(aqls.get(0).contains("libs-release"), aqls.get(0)); + assertTrue(aqls.get(0).contains("\"path\""), aqls.get(0)); + assertEquals(countOccurrences(aqls.get(0), ".include("), 1, aqls.get(0)); + assertTrue(aqls.get(1).contains("libs-snapshot"), aqls.get(1)); + assertTrue(aqls.get(1).contains("\"sha256\""), aqls.get(1)); + assertEquals(countOccurrences(aqls.get(1), ".include("), 1, aqls.get(1)); + } + + @Test + public void motivatingExampleWithInclude() throws InvalidFileSpecException { + // The exact example from the user request / javadoc + String aql = aqlFromSpec(new FileSpecBuilder() + .item("type", "file") + .match("repo", "pnc-devel-*") + .eq("property.key", "pnc.build-BQBPZZFPTRYAA") + .include("name", "repo", "path", "size", "actual_sha1", "actual_md5", + "sha256", "@jf.origin.remote.path") + .limit(50000) + .buildFileSpec()); + + assertTrue(aql.contains("\"type\":\"file\""), aql); + assertTrue(aql.contains("\"repo\":{\"$match\":\"pnc-devel-*\"}"), aql); + assertTrue(aql.contains("\"property.key\":{\"$eq\":\"pnc.build-BQBPZZFPTRYAA\"}"), aql); + assertTrue(aql.contains(".include(\"name\",\"repo\",\"path\",\"size\"," + + "\"actual_sha1\",\"actual_md5\",\"sha256\",\"@jf.origin.remote.path\")"), aql); + assertTrue(aql.endsWith(".limit(50000)"), aql); + assertEquals(countOccurrences(aql, ".include("), 1, aql); + } + + // ── helpers ──────────────────────────────────────────────────────────────── + + /** Converts a single-group FileSpec to the AQL string that would be POSTed. */ + private static String aqlFromSpec(FileSpec spec) throws InvalidFileSpecException { + List aqls = spec.toAql(); + assertEquals(aqls.size(), 1); + return aqls.get(0); + } + + /** + * The default .include(…) that AqlBuildingUtils always appends when there is no + * sort/suffix (sortBy is empty and suffix is blank → property is included). + */ + private static String defaultInclude() { + return ".include(\"name\",\"repo\",\"path\",\"actual_md5\",\"actual_sha1\"," + + "\"size\",\"type\",\"modified\",\"created\",\"property\")"; + } + + private static int countOccurrences(String text, String sub) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(sub, idx)) != -1) { + count++; + idx += sub.length(); + } + return count; + } +}