diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 3dffae9..8ec9ce1 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 5a84dc8..e1d300c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -27,7 +27,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 561def3..cd03836 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -13,9 +13,9 @@ jobs: chroma-version: [0.4.24, 0.5.0, 0.5.5, 0.5.15 ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up JDK 8 - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: java-version: '8' distribution: 'adopt' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c2a669..2362aa7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,9 +9,9 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Set up JDK 8 - uses: actions/setup-java@v3 + uses: actions/setup-java@v5 with: java-version: '8' distribution: 'adopt' diff --git a/.github/workflows/v2-api-nightly.yml b/.github/workflows/v2-api-nightly.yml new file mode 100644 index 0000000..8d39639 --- /dev/null +++ b/.github/workflows/v2-api-nightly.yml @@ -0,0 +1,225 @@ +name: V2 API Nightly Tests + +on: + schedule: + # Run at 2 AM UTC every day + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + test_experimental: + description: 'Test experimental ChromaDB features' + required: false + type: boolean + default: false + +env: + MAVEN_OPTS: -Xmx4096m -Xms1024m + +jobs: + comprehensive-v2-tests: + name: Comprehensive V2 API Tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + chroma-version: + - '1.0.0' # Minimum supported version + - '1.0.1' + - '1.0.2' + - '1.0.3' + - '1.0.4' + - '1.1.0' + - 'latest' + java-version: [8, 11, 17, 21] + exclude: + # Java 8 only with older ChromaDB versions (no exclusions needed for 1.x) + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + cache: 'maven' + + - name: Start ChromaDB container + run: | + docker run -d \ + --name chroma-${{ matrix.chroma-version }} \ + -p 8000:8000 \ + -e ALLOW_RESET=TRUE \ + -e IS_PERSISTENT=FALSE \ + chromadb/chroma:${{ matrix.chroma-version }} + + # Wait for ChromaDB to be ready + echo "Waiting for ChromaDB to start..." + for i in {1..60}; do + if curl -f http://localhost:8000/api/v1 > /dev/null 2>&1; then + echo "ChromaDB is ready!" + break + fi + echo "Waiting... ($i/60)" + sleep 2 + done + + - name: Check ChromaDB health + run: | + curl -v http://localhost:8000/api/v1 + docker logs chroma-${{ matrix.chroma-version }} | tail -20 + + - name: Run V2 API tests + run: | + mvn clean test \ + -Dtest="tech.amikos.chromadb.v2.**" \ + -DfailIfNoTests=false \ + -Dchroma.url=http://localhost:8000 + env: + CHROMA_VERSION: ${{ matrix.chroma-version }} + CHROMA_URL: http://localhost:8000 + + - name: Generate detailed test report + if: always() + run: | + mvn surefire-report:report-only + mvn site -DgenerateReports=false + + - name: Collect container logs + if: failure() + run: | + docker logs chroma-${{ matrix.chroma-version }} > chroma-logs-${{ matrix.chroma-version }}-java-${{ matrix.java-version }}.txt + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: nightly-v2-chroma-${{ matrix.chroma-version }}-java-${{ matrix.java-version }} + path: | + target/surefire-reports/ + target/site/ + chroma-logs-*.txt + + - name: Stop ChromaDB container + if: always() + run: docker stop chroma-${{ matrix.chroma-version }} && docker rm chroma-${{ matrix.chroma-version }} + + stress-tests: + name: V2 API Stress Tests + runs-on: ubuntu-latest + + services: + chroma: + image: chromadb/chroma:latest + ports: + - 8000:8000 + env: + ALLOW_RESET: 'TRUE' + IS_PERSISTENT: 'TRUE' + PERSIST_DIRECTORY: '/chroma/data' + options: >- + --health-cmd "wget -q --spider http://localhost:8000/api/v1 || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + --mount type=tmpfs,destination=/chroma/data + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Run stress tests + run: | + mvn test -Dtest=V2StressTest -DfailIfNoTests=false + env: + CHROMA_URL: http://localhost:8000 + + - name: Upload stress test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: stress-test-results-v2 + path: | + target/surefire-reports/TEST-*V2StressTest.xml + target/site/ + + report-summary: + name: Generate Nightly Report + needs: [comprehensive-v2-tests, stress-tests] + runs-on: ubuntu-latest + if: always() + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: test-artifacts + + - name: Generate summary report + run: | + echo "# V2 API Nightly Test Report" > nightly-report.md + echo "Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> nightly-report.md + echo "" >> nightly-report.md + + echo "## Test Coverage Matrix" >> nightly-report.md + echo "| ChromaDB | Java 8 | Java 11 | Java 17 | Java 21 |" >> nightly-report.md + echo "|----------|--------|---------|---------|---------|" >> nightly-report.md + + # Process test results + for dir in test-artifacts/nightly-v2-*; do + if [ -d "$dir" ]; then + basename "$dir" | grep -o "chroma-[^-]*" | cut -d- -f2 >> versions.txt + fi + done + + sort -u versions.txt > unique-versions.txt || true + + while IFS= read -r version; do + if [ -n "$version" ]; then + row="| $version |" + for java in 8 11 17 21; do + if [ -d "test-artifacts/nightly-v2-chroma-${version}-java-${java}" ]; then + if ls test-artifacts/nightly-v2-chroma-${version}-java-${java}/TEST-*.xml 2>/dev/null | head -1 | xargs grep -q 'failures="0".*errors="0"' 2>/dev/null; then + row="$row ✅ |" + else + row="$row ❌ |" + fi + else + row="$row - |" + fi + done + echo "$row" >> nightly-report.md + fi + done < unique-versions.txt || true + + echo "" >> nightly-report.md + echo "## Stress Test Results" >> nightly-report.md + if [ -f "test-artifacts/stress-test-results-v2/TEST-*V2StressTest.xml" ]; then + echo "✅ Stress tests completed successfully" >> nightly-report.md + else + echo "⚠️ Stress test results not found" >> nightly-report.md + fi + + cat nightly-report.md >> $GITHUB_STEP_SUMMARY + + - name: Create issue for failures + if: failure() + uses: actions/github-script@v7 + with: + script: | + const date = new Date().toISOString().split('T')[0]; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[V2 API] Nightly test failures - ${date}`, + body: `Nightly V2 API tests have failed. Please check the [workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for details.`, + labels: ['bug', 'v2-api', 'nightly-test'] + }); \ No newline at end of file diff --git a/.github/workflows/v2-api-pr-validation.yml b/.github/workflows/v2-api-pr-validation.yml new file mode 100644 index 0000000..98248a6 --- /dev/null +++ b/.github/workflows/v2-api-pr-validation.yml @@ -0,0 +1,267 @@ +name: V2 API PR Validation + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/main/java/tech/amikos/chromadb/v2/**' + - 'src/test/java/tech/amikos/chromadb/v2/**' + +jobs: + quick-validation: + name: Quick V2 API Validation + runs-on: ubuntu-latest + + services: + chroma: + image: chromadb/chroma:1.1.0 + ports: + - 8000:8000 + env: + ALLOW_RESET: 'TRUE' + IS_PERSISTENT: 'FALSE' + options: >- + --health-cmd "wget -q --spider http://localhost:8000/api/v1 || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Java 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + cache: 'maven' + + - name: Check code changes + id: changes + run: | + echo "Changed files in V2 API:" + git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E "v2/|V2" || true + + # Check if only test files changed + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E "src/main/java.*v2" > /dev/null; then + echo "src_changed=true" >> $GITHUB_OUTPUT + else + echo "src_changed=false" >> $GITHUB_OUTPUT + fi + + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E "src/test/java.*v2" > /dev/null; then + echo "test_changed=true" >> $GITHUB_OUTPUT + else + echo "test_changed=false" >> $GITHUB_OUTPUT + fi + + - name: Compile V2 API code + run: | + mvn compile -pl . -am + + - name: Run checkstyle on V2 API + run: | + mvn checkstyle:check -Dcheckstyle.includes="**/v2/**/*.java" || true + + - name: Run V2 API unit tests + if: steps.changes.outputs.src_changed == 'true' || steps.changes.outputs.test_changed == 'true' + run: | + mvn test \ + -Dtest="tech.amikos.chromadb.v2.**Test" \ + -DfailIfNoTests=false + env: + CHROMA_URL: http://localhost:8000 + + - name: Check test coverage + if: steps.changes.outputs.src_changed == 'true' + run: | + mvn jacoco:prepare-agent test jacoco:report \ + -Dtest="tech.amikos.chromadb.v2.**Test" \ + -DfailIfNoTests=false + + # Extract coverage percentage (simplified) + if [ -f target/site/jacoco/index.html ]; then + echo "Code coverage report generated" + # You can add coverage threshold checks here + fi + + - name: Run basic integration test + run: | + cat > BasicV2IntegrationTest.java << 'EOF' + import tech.amikos.chromadb.v2.auth.AuthProvider; + import tech.amikos.chromadb.v2.client.Collection; + import tech.amikos.chromadb.v2.client.ServerClient; + + public class BasicV2IntegrationTest { + public static void main(String[] args) throws Exception { + ServerClient client = ServerClient.builder() + .baseUrl("http://localhost:8000") + .auth(AuthProvider.none()) + .build(); + + try { + // Test heartbeat + String heartbeat = client.heartbeat(); + System.out.println("✅ Heartbeat successful: " + heartbeat); + + // Test collection creation + Collection collection = client.createCollection("pr_validation_test"); + System.out.println("✅ Collection created: " + collection.getName()); + + // Test add operation + collection.add() + .ids(java.util.Arrays.asList("test1")) + .embeddings(java.util.Arrays.asList( + java.util.Arrays.asList(0.1f, 0.2f, 0.3f) + )) + .execute(); + System.out.println("✅ Document added successfully"); + + // Test count + int count = collection.count(); + if (count == 1) { + System.out.println("✅ Count verified: " + count); + } else { + throw new RuntimeException("Count mismatch: expected 1, got " + count); + } + + // Cleanup + client.deleteCollection("pr_validation_test"); + System.out.println("✅ Collection deleted successfully"); + + System.out.println("\n✅ All basic V2 API operations passed!"); + System.exit(0); + } catch (Exception e) { + System.err.println("❌ Test failed: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } + } + } + EOF + + javac -cp "target/classes:target/test-classes:$(mvn dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q)" BasicV2IntegrationTest.java + java -cp ".:target/classes:target/test-classes:$(mvn dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q)" BasicV2IntegrationTest + + - name: Comment PR with results + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + let comment = '## V2 API PR Validation Results\n\n'; + + // Add emoji based on job status + const status = '${{ job.status }}'; + if (status === 'success') { + comment += '✅ **All checks passed!**\n\n'; + } else if (status === 'failure') { + comment += '❌ **Some checks failed**\n\n'; + } else { + comment += '⚠️ **Validation canceled**\n\n'; + } + + // Add change summary + comment += '### Changes Detected\n'; + comment += '- Source files changed: ${{ steps.changes.outputs.src_changed }}\n'; + comment += '- Test files changed: ${{ steps.changes.outputs.test_changed }}\n\n'; + + // Add test results if available + const testResults = `${{ steps.test-results.outputs.summary }}`; + if (testResults) { + comment += '### Test Results\n'; + comment += testResults + '\n\n'; + } + + comment += '---\n'; + comment += `🔗 [View full workflow run](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})\n`; + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('V2 API PR Validation Results') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + code-quality: + name: V2 API Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + cache: 'maven' + + - name: Run SpotBugs on V2 API + continue-on-error: true + run: | + mvn compile spotbugs:check -Dspotbugs.includeFilterFile=v2-api-include.xml || true + + - name: Run PMD on V2 API + continue-on-error: true + run: | + mvn pmd:check -Dpmd.includes="**/v2/**/*.java" || true + + - name: Check for security issues + continue-on-error: true + run: | + mvn dependency-check:check -DfailBuildOnCVSS=8 || true + + - name: Generate quality report + if: always() + run: | + echo "## Code Quality Report" >> quality-report.md + echo "" >> quality-report.md + + if [ -f target/spotbugsXml.xml ]; then + echo "### SpotBugs" >> quality-report.md + echo "Report generated at target/spotbugsXml.xml" >> quality-report.md + fi + + if [ -f target/pmd.xml ]; then + echo "### PMD" >> quality-report.md + echo "Report generated at target/pmd.xml" >> quality-report.md + fi + + cat quality-report.md >> $GITHUB_STEP_SUMMARY + + - name: Upload quality reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: quality-reports-v2 + path: | + target/spotbugsXml.xml + target/pmd.xml + target/dependency-check-report.html \ No newline at end of file diff --git a/.github/workflows/v2-api-release.yml b/.github/workflows/v2-api-release.yml new file mode 100644 index 0000000..029eccb --- /dev/null +++ b/.github/workflows/v2-api-release.yml @@ -0,0 +1,271 @@ +name: V2 API Release Validation + +on: + push: + tags: + - 'v2.*' + - 'v*.*.0-v2' + workflow_dispatch: + inputs: + release_version: + description: 'Release version (e.g., 2.0.0)' + required: true + dry_run: + description: 'Perform dry run without publishing' + required: false + type: boolean + default: true + +jobs: + validate-release: + name: Validate V2 API Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Java 8 + uses: actions/setup-java@v4 + with: + java-version: '8' + distribution: 'adopt' + cache: 'maven' + + - name: Determine version + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.release_version }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + # Extract version components + if [[ $VERSION =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)(-.*)?$ ]]; then + echo "major=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + echo "minor=${BASH_REMATCH[2]}" >> $GITHUB_OUTPUT + echo "patch=${BASH_REMATCH[3]}" >> $GITHUB_OUTPUT + echo "suffix=${BASH_REMATCH[4]}" >> $GITHUB_OUTPUT + fi + + - name: Update version in pom.xml + run: | + mvn versions:set -DnewVersion=${{ steps.version.outputs.version }} + mvn versions:commit + + - name: Run full test suite + run: | + mvn clean test -Dtest="tech.amikos.chromadb.v2.**" + + - name: Check backward compatibility + run: | + echo "Checking backward compatibility..." + + # Download previous version for comparison + PREV_MAJOR=${{ steps.version.outputs.major }} + PREV_MINOR=$((steps.version.outputs.minor - 1)) + PREV_VERSION="v${PREV_MAJOR}.${PREV_MINOR}.0" + + if git rev-parse "$PREV_VERSION" >/dev/null 2>&1; then + git checkout "$PREV_VERSION" -- src/main/java/tech/amikos/chromadb/v2/client/Client.java || true + mv src/main/java/tech/amikos/chromadb/v2/client/Client.java /tmp/Client.java.prev || true + git checkout HEAD -- src/main/java/tech/amikos/chromadb/v2/client/Client.java + + # Simple diff to check for breaking changes + if [ -f /tmp/Client.java.prev ]; then + echo "### API Changes from ${PREV_VERSION}:" >> $GITHUB_STEP_SUMMARY + diff -u /tmp/Client.java.prev src/main/java/tech/amikos/chromadb/v2/client/Client.java || true >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Generate Javadoc + run: | + mvn javadoc:javadoc -Dadditionalparam=-Xdoclint:none + + - name: Build release artifacts + run: | + mvn clean package -DskipTests + mvn source:jar + mvn javadoc:jar + + - name: Create release notes + id: release-notes + run: | + cat > release-notes.md << EOF + # V2 API Release ${{ steps.version.outputs.version }} + + ## What's New + - ChromaDB V2 API support + - Improved performance and reliability + - Enhanced testing coverage + + ## Compatibility + - Java: 8, 11, 17, 21 + - ChromaDB: 1.0.0+ + + ## Installation + + ### Maven + \`\`\`xml + + io.github.amikos-tech + chromadb-java-client + ${{ steps.version.outputs.version }} + + \`\`\` + + ### Gradle + \`\`\`groovy + implementation 'io.github.amikos-tech:chromadb-java-client:${{ steps.version.outputs.version }}' + \`\`\` + + ## Changes + $(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD | grep -E "v2|V2" | head -20) + + ## Documentation + - [V2 API Documentation](https://github.com/amikos-tech/chromadb-java-client/blob/main/V2_API_EXAMPLE.md) + - [Migration Guide](https://github.com/amikos-tech/chromadb-java-client/blob/main/V2_MIGRATION.md) + + ## Checksums + \`\`\` + $(sha256sum target/*.jar) + \`\`\` + EOF + + cat release-notes.md >> $GITHUB_STEP_SUMMARY + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-artifacts-v2 + path: | + target/*.jar + release-notes.md + + compatibility-matrix: + name: V2 Release Compatibility Test + needs: validate-release + runs-on: ubuntu-latest + strategy: + matrix: + chroma-version: ['1.0.0', '1.0.4', '1.1.0', 'latest'] + java-version: [8, 11, 17, 21] + + services: + chroma: + image: chromadb/chroma:${{ matrix.chroma-version }} + ports: + - 8000:8000 + env: + ALLOW_RESET: 'TRUE' + options: >- + --health-cmd "wget -q --spider http://localhost:8000/api/v1 || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + cache: 'maven' + + - name: Run compatibility tests + run: | + mvn test -Dtest="tech.amikos.chromadb.v2.**Test" -DfailIfNoTests=false + env: + CHROMA_URL: http://localhost:8000 + + - name: Report results + if: always() + run: | + echo "ChromaDB: ${{ matrix.chroma-version }}, Java: ${{ matrix.java-version }}" >> compatibility-results.txt + if [ $? -eq 0 ]; then + echo "✅ PASSED" >> compatibility-results.txt + else + echo "❌ FAILED" >> compatibility-results.txt + fi + + - name: Upload compatibility results + if: always() + uses: actions/upload-artifact@v4 + with: + name: compatibility-chroma-${{ matrix.chroma-version }}-java-${{ matrix.java-version }} + path: compatibility-results.txt + + publish-release: + name: Publish V2 API Release + needs: [validate-release, compatibility-matrix] + runs-on: ubuntu-latest + if: github.event.inputs.dry_run != 'true' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java 11 + uses: actions/setup-java@v4 + with: + java-version: '11' + distribution: 'temurin' + server-id: ossrh + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE + + - name: Download release artifacts + uses: actions/download-artifact@v4 + with: + name: release-artifacts-v2 + path: ./release + + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: V2 API Release ${{ github.ref }} + body_path: ./release/release-notes.md + draft: false + prerelease: ${{ contains(github.ref, '-') }} + + - name: Upload JAR to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./release/chromadb-java-client-${{ steps.version.outputs.version }}.jar + asset_name: chromadb-java-client-${{ steps.version.outputs.version }}.jar + asset_content_type: application/java-archive + + - name: Deploy to Maven Central + if: github.event.inputs.dry_run != 'true' + env: + MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + mvn clean deploy -P release -DskipTests + + - name: Update documentation + run: | + # Update README with new version + sed -i "s/.*<\/version>/${{ steps.version.outputs.version }}<\/version>/g" README.md + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + git add README.md + git commit -m "docs: update version to ${{ steps.version.outputs.version }}" || true + git push origin HEAD:main || true \ No newline at end of file diff --git a/.github/workflows/v2-api-tests.yml b/.github/workflows/v2-api-tests.yml new file mode 100644 index 0000000..753c3ff --- /dev/null +++ b/.github/workflows/v2-api-tests.yml @@ -0,0 +1,298 @@ +name: V2 API Tests + +on: + push: + branches: + - main + - develop + - 'feature/chroma-v2-api-*' + paths: + - 'src/main/java/tech/amikos/chromadb/v2/**' + - 'src/test/java/tech/amikos/chromadb/v2/**' + - '.github/workflows/v2-api-tests.yml' + - 'pom.xml' + pull_request: + branches: + - main + - develop + paths: + - 'src/main/java/tech/amikos/chromadb/v2/**' + - 'src/test/java/tech/amikos/chromadb/v2/**' + - '.github/workflows/v2-api-tests.yml' + - 'pom.xml' + workflow_dispatch: + inputs: + chroma_versions: + description: 'Comma-separated list of ChromaDB versions to test (e.g., 1.1.0)' + required: false + default: '' + +env: + MAVEN_OPTS: -Xmx4096m -Xms1024m + +jobs: + determine-versions: + name: Determine ChromaDB Versions + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Set ChromaDB versions matrix + id: set-matrix + run: | + if [ -n "${{ github.event.inputs.chroma_versions }}" ]; then + # Use custom versions from workflow dispatch + IFS=',' read -ra VERSIONS <<< "${{ github.event.inputs.chroma_versions }}" + JSON_ARRAY=$(printf '"%s",' "${VERSIONS[@]}" | sed 's/,$//') + echo "matrix=[${JSON_ARRAY}]" >> $GITHUB_OUTPUT + else + # Default versions for v2 API testing + echo 'matrix=["1.0.0", "1.1.0", "latest"]' >> $GITHUB_OUTPUT + fi + + v2-api-tests: + name: V2 API Tests (ChromaDB ${{ matrix.chroma-version }}) + needs: determine-versions + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + chroma-version: ${{ fromJson(needs.determine-versions.outputs.matrix) }} + java-version: [8, 17] + + services: + chroma: + image: chromadb/chroma:${{ matrix.chroma-version }} + ports: + - 8000:8000 + env: # TODO these are not accurate and won't work + ALLOW_RESET: 'TRUE' + IS_PERSISTENT: 'FALSE' + CHROMA_SERVER_AUTH_PROVIDER: 'chromadb.auth.token_authn.TokenAuthenticationServerProvider' + CHROMA_SERVER_AUTH_CREDENTIALS_PROVIDER: 'chromadb.auth.token_authn.TokenConfigServerCredentialsProvider' + CHROMA_SERVER_AUTH_TOKEN_TRANSPORT_HEADER: 'X_CHROMA_TOKEN' + CHROMA_SERVER_AUTH_CREDENTIALS: 'test-token' + options: >- + --health-cmd "wget -q --spider http://localhost:8000/api/v1 || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + cache: 'maven' + + - name: Cache Maven dependencies + uses: actions/cache@v3 + with: + path: | + ~/.m2/repository + ~/.m2/wrapper + key: ${{ runner.os }}-maven-v2-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven-v2- + ${{ runner.os }}-maven- + + - name: Wait for ChromaDB to be ready + run: | + echo "Waiting for ChromaDB to be ready..." + for i in {1..30}; do + if curl -f http://localhost:8000/api/v1 > /dev/null 2>&1; then + echo "ChromaDB is ready!" + break + fi + echo "Waiting... ($i/30)" + sleep 2 + done + + # Verify ChromaDB is responding + curl -v http://localhost:8000/api/v1 || true + + - name: Get ChromaDB Version Info + run: | + echo "Testing against ChromaDB version: ${{ matrix.chroma-version }}" + curl -s http://localhost:8000/api/v1/version || echo "Version endpoint not available" + + - name: Compile project + run: mvn clean compile -DskipTests + + - name: Compile tests + run: mvn test-compile + + - name: Run V2 API Unit Tests + run: | + mvn test \ + -Dtest="tech.amikos.chromadb.v2.**Test" \ + -DfailIfNoTests=false \ + -Dchroma.url=http://localhost:8000 \ + -Dchroma.token=test-token + env: + CHROMA_VERSION: ${{ matrix.chroma-version }} + CHROMA_URL: http://localhost:8000 + CHROMA_TOKEN: test-token + + - name: Run V2 API Integration Tests + if: success() || failure() + run: | + mvn test \ + -Dtest="tech.amikos.chromadb.v2.**IT" \ + -DfailIfNoTests=false \ + -Dchroma.url=http://localhost:8000 \ + -Dchroma.token=test-token + env: + CHROMA_VERSION: ${{ matrix.chroma-version }} + CHROMA_URL: http://localhost:8000 + CHROMA_TOKEN: test-token + + - name: Generate test report + if: always() + run: | + mvn surefire-report:report-only + mvn site -DgenerateReports=false + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-v2-chroma-${{ matrix.chroma-version }}-java-${{ matrix.java-version }} + path: | + target/surefire-reports/ + target/site/surefire-report.html + + - name: Upload coverage reports + if: success() + uses: actions/upload-artifact@v4 + with: + name: coverage-v2-chroma-${{ matrix.chroma-version }}-java-${{ matrix.java-version }} + path: target/site/jacoco/ + + - name: Test Report Summary + if: always() + run: | + echo "## Test Results Summary" >> $GITHUB_STEP_SUMMARY + echo "- ChromaDB Version: ${{ matrix.chroma-version }}" >> $GITHUB_STEP_SUMMARY + echo "- Java Version: ${{ matrix.java-version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f target/surefire-reports/TEST-tech.amikos.chromadb.v2.ServerClientTest.xml ]; then + echo "### V2 API Test Results" >> $GITHUB_STEP_SUMMARY + # Parse XML to get test counts (simplified - you might want to use xmllint or similar) + grep -o 'tests="[^"]*"' target/surefire-reports/TEST-*.xml | head -1 >> $GITHUB_STEP_SUMMARY || true + grep -o 'failures="[^"]*"' target/surefire-reports/TEST-*.xml | head -1 >> $GITHUB_STEP_SUMMARY || true + grep -o 'errors="[^"]*"' target/surefire-reports/TEST-*.xml | head -1 >> $GITHUB_STEP_SUMMARY || true + fi + + v2-api-compatibility-matrix: + name: V2 API Compatibility Report + needs: v2-api-tests + runs-on: ubuntu-latest + if: always() + + steps: + - name: Download all test results + uses: actions/download-artifact@v4 + with: + path: test-artifacts + + - name: Generate compatibility matrix + run: | + echo "# V2 API Compatibility Matrix" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| ChromaDB Version | Java 8 | Java 17 |" >> $GITHUB_STEP_SUMMARY + echo "|------------------|--------|---------|" >> $GITHUB_STEP_SUMMARY + + # Check test results for each combination + for chroma_version in "1.0.0" "1.1.0" "latest"; do + java8_status="❓" + java17_status="❓" + + if [ -d "test-artifacts/test-results-v2-chroma-${chroma_version}-java-8" ]; then + if grep -q 'failures="0"' test-artifacts/test-results-v2-chroma-${chroma_version}-java-8/TEST-*.xml 2>/dev/null && \ + grep -q 'errors="0"' test-artifacts/test-results-v2-chroma-${chroma_version}-java-8/TEST-*.xml 2>/dev/null; then + java8_status="✅" + else + java8_status="❌" + fi + fi + + if [ -d "test-artifacts/test-results-v2-chroma-${chroma_version}-java-17" ]; then + if grep -q 'failures="0"' test-artifacts/test-results-v2-chroma-${chroma_version}-java-17/TEST-*.xml 2>/dev/null && \ + grep -q 'errors="0"' test-artifacts/test-results-v2-chroma-${chroma_version}-java-17/TEST-*.xml 2>/dev/null; then + java17_status="✅" + else + java17_status="❌" + fi + fi + + echo "| ${chroma_version} | ${java8_status} | ${java17_status} |" >> $GITHUB_STEP_SUMMARY + done + + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ = All tests passed | ❌ = Tests failed | ❓ = No results" >> $GITHUB_STEP_SUMMARY + + - name: Comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const summary = fs.readFileSync(process.env.GITHUB_STEP_SUMMARY, 'utf8'); + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: summary + }); + + v2-api-performance-tests: + name: V2 API Performance Tests + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + + services: + chroma: + image: chromadb/chroma:latest + ports: + - 8000:8000 + env: + ALLOW_RESET: 'TRUE' + IS_PERSISTENT: 'FALSE' + options: >- + --health-cmd "wget -q --spider http://localhost:8000/api/v1 || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Run performance tests + run: | + mvn test \ + -Dtest="tech.amikos.chromadb.v2.**PerformanceTest" \ + -DfailIfNoTests=false \ + -Dchroma.url=http://localhost:8000 + env: + CHROMA_URL: http://localhost:8000 + + - name: Upload performance results + uses: actions/upload-artifact@v4 + with: + name: performance-results-v2 + path: target/performance-reports/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index aa70b23..3893731 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,10 @@ build/ .vscode/ ### Mac OS ### -.DS_Store*.class +.DS_Store + +### Java ### +*.class *.log *.ctxt .mtj.tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index 36f7865..315fd2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `HF_API_KEY` - Required for HuggingFace embedding tests - `CHROMA_VERSION` - Specifies ChromaDB version for integration tests -## Architecture Overview +## API Design Principles (V2) -### Core Client Structure -The client follows a standard Swagger/OpenAPI generated client pattern with custom enhancements: +### Radical Simplicity +The V2 API follows principles of radical simplicity based on successful Java libraries like OkHttp, Retrofit, and Jedis: + +1. **Single Configuration Pattern**: Use ONLY fluent builders, NEVER Consumer patterns +2. **Flat Package Structure**: All public API classes in `tech.amikos.chromadb.v2` package (no sub-packages) +3. **One Way to Do Things**: Each task has exactly one idiomatic approach +4. **Minimal Public API Surface**: ~20-25 classes total (following OkHttp's model) +5. **Concrete Over Abstract**: Prefer concrete classes over interfaces where possible + +### Architecture Overview + +#### V1 Client (Legacy - Maintained for Compatibility) +The V1 client (`tech.amikos.chromadb`) follows a standard Swagger/OpenAPI generated client pattern: 1. **Generated API Layer** (`target/generated-sources/swagger/`) - Auto-generated from OpenAPI specifications @@ -48,6 +59,31 @@ The client follows a standard Swagger/OpenAPI generated client pattern with cust - Each implements `EmbeddingFunction` interface - Default embedding uses ONNX Runtime for local inference +#### V2 Client (Recommended - Radical Simplicity) +The V2 client (`tech.amikos.chromadb.v2`) implements radical simplicity: + +1. **Single Flat Package** - All classes in `tech.amikos.chromadb.v2` + - No sub-packages for auth, model, client, etc. + - Everything discoverable in one location + +2. **Core Classes** (~20 total) + - `ChromaClient` - Single client class with builder + - `Collection` - Concrete collection class (not interface) + - `Metadata` - Strongly-typed metadata with builder + - Query builders: `QueryBuilder`, `AddBuilder`, etc. + - Model classes: `Where`, `WhereDocument`, `Include` + - Auth: `AuthProvider` interface with implementations + - Exceptions: Strongly-typed exception hierarchy + +3. **Builder-Only Pattern** + ```java + // Only way to query - no Consumer alternative + collection.query() + .where(Where.eq("type", "article")) + .nResults(10) + .execute(); + ``` + ### Key Design Patterns 1. **Authentication Handling** diff --git a/V2_API.md b/V2_API.md new file mode 100644 index 0000000..8ebd550 --- /dev/null +++ b/V2_API.md @@ -0,0 +1,377 @@ +# ChromaDB V2 API Documentation + +## Overview + +The V2 API is an experimental implementation of the ChromaDB v2 client for Java, designed with principles of radical simplicity based on successful Java libraries like OkHttp, Retrofit, and Jedis. + +**⚠️ Important:** The v2 API does not yet exist in ChromaDB. This implementation is based on anticipated v2 API design and is provided for experimental/preview purposes only. + +## Design Principles + +### Radical Simplicity +- **Dual API Approach**: Convenience methods for common cases (80%), builders for complex operations (20%) +- **Chroma-Aligned**: API mirrors official Python/TypeScript SDKs for familiarity +- **Flat Package Structure**: All public API classes in `tech.amikos.chromadb.v2` package (no sub-packages) +- **Simple Things Simple**: Common operations in 1-2 lines, no builders required +- **Minimal Public API Surface**: ~20-25 classes total (following OkHttp's model) +- **Concrete Over Abstract**: Prefer concrete classes over interfaces where possible + +## Architecture + +``` +Client (interface) + ├── BaseClient (abstract) + │ ├── ServerClient (self-hosted) + │ └── CloudClient (cloud - future) + │ + └── Collection (smart entity with operations) + ├── query() + ├── get() + ├── add() + ├── update() + ├── upsert() + ├── delete() + └── count() +``` + +### Core Classes (~20 total) +- `ServerClient` / `CloudClient` - Client implementations +- `Collection` - Concrete collection class (not interface) +- `Metadata` - Strongly-typed metadata with builder +- Query builders: `QueryBuilder`, `AddBuilder`, etc. +- Model classes: `Where`, `WhereDocument`, `Include` +- Auth: `AuthProvider` interface with implementations +- Exceptions: Strongly-typed exception hierarchy + +## Quick Start + +### 1. Create a Client + +```java +import tech.amikos.chromadb.v2.ChromaClient; +import tech.amikos.chromadb.v2.AuthProvider; + +ChromaClient client = ChromaClient.builder() + .serverUrl("http://localhost:8000") + .auth(AuthProvider.none()) + .tenant("default_tenant") + .database("default_database") + .build(); +``` + +### 2. Create a Collection + +```java +// Simple creation +Collection collection = client.createCollection("my-collection"); + +// With metadata +Collection collection = client.createCollection("my-collection", + Map.of("description", "My collection")); +``` + +## Simple API (Convenience Methods) + +For most use cases, use the simple, Chroma-aligned convenience methods: + +### 3. Add Records + +```java +// Simple add - mirrors Python/TypeScript Chroma API +collection.add( + List.of("id1", "id2", "id3"), + List.of( + List.of(0.1f, 0.2f, 0.3f), + List.of(0.4f, 0.5f, 0.6f), + List.of(0.7f, 0.8f, 0.9f) + ), + List.of("Document 1", "Document 2", "Document 3"), + List.of( + Map.of("author", "John"), + Map.of("author", "Jane"), + Map.of("author", "Bob") + ) +); +``` + +### 4. Query Collection + +```java +// Simple query by embeddings +QueryResponse results = collection.query( + List.of(List.of(0.1f, 0.2f, 0.3f)), + 10 // number of results +); + +// Query with filtering +results = collection.query( + List.of(List.of(0.1f, 0.2f, 0.3f)), + 10, + Where.eq("author", "John") +); + +// Query by text (auto-embedded) +results = collection.queryByText( + List.of("quantum computing"), + 5 +); +``` + +### 5. Get Records + +```java +// Simple get by IDs +GetResponse records = collection.get(List.of("id1", "id2")); + +// Get with includes +records = collection.get( + List.of("id1", "id2"), + Include.DOCUMENTS, Include.METADATAS +); +``` + +### 6. Update/Upsert Records + +```java +// Simple upsert +collection.upsert( + List.of("id4"), + List.of(List.of(0.2f, 0.3f, 0.4f)), + List.of("New document") +); +``` + +### 7. Delete Records + +```java +// Delete by IDs +collection.delete(List.of("id1", "id2")); + +// Delete by filter +collection.delete(Where.eq("status", "archived")); +``` + +## Advanced API (Builder Pattern) + +For complex operations with multiple options, use the builder pattern: + +### Complex Query + +```java +QueryResponse results = collection.query() + .queryEmbeddings(List.of(List.of(0.1f, 0.2f, 0.3f))) + .nResults(10) + .where(Where.and( + Where.eq("status", "published"), + Where.gte("score", 8.0) + )) + .whereDocument(WhereDocument.contains("technology")) + .include(Include.EMBEDDINGS, Include.METADATAS, Include.DISTANCES) + .execute(); +``` + +### Complex Get with Pagination + +```java +GetResponse records = collection.get() + .where(Where.eq("category", "tech")) + .limit(100) + .offset(0) + .include(Include.DOCUMENTS, Include.METADATAS) + .execute(); +``` + +### Complex Add + +```java +collection.add() + .ids(List.of("id1", "id2")) + .embeddings(embeddings) + .documents(documents) + .metadatas(metadatas) + .uris(uris) + .execute(); +``` + +## Advanced Features + +### Authentication + +```java +// Basic authentication +ServerClient client = ServerClient.builder() + .baseUrl("http://localhost:8000") + .auth(AuthProvider.basic("username", "password")) + .build(); + +// Bearer token +client = ServerClient.builder() + .baseUrl("http://localhost:8000") + .auth(AuthProvider.bearerToken("your-api-token")) + .build(); + +// X-Chroma-Token header +client = ServerClient.builder() + .baseUrl("http://localhost:8000") + .auth(AuthProvider.chromaToken("chroma-token")) + .build(); +``` + +### Embedding Functions + +```java +// Default embedding (uses all-MiniLM-L6-v2) +EmbeddingFunction defaultEF = EmbeddingFunction.getDefault(); + +// OpenAI embeddings +EmbeddingFunction openAI = EmbeddingFunction.openAI("your-api-key"); + +// Custom embedding function +EmbeddingFunction custom = new EmbeddingFunction() { + @Override + public List> embed(List texts) { + // Your embedding logic + } +}; + +// Use with collection +Collection collection = client.createCollection(builder -> builder + .name("documents") + .embeddingFunction(openAI) +); +``` + +### Metadata Filtering (Where DSL) + +```java +// Complex filter conditions +Where filter = Where.builder() + .and( + Where.eq("status", "published"), + Where.gte("score", 8.0), + Where.or( + Where.eq("category", "tech"), + Where.eq("category", "science") + ) + ) + .build(); + +// Use in queries +QueryResponse results = collection.query(builder -> builder + .queryTexts(Arrays.asList("search text")) + .where(filter) + .nResults(10) +); +``` + +### Document Filtering + +```java +// Filter by document content +WhereDocument docFilter = WhereDocument.contains("machine learning"); + +QueryResponse results = collection.query(builder -> builder + .queryTexts(Arrays.asList("AI research")) + .whereDocument(docFilter) + .nResults(5) +); +``` + +## Implementation Status + +### What's Implemented ✅ +- Basic client structure (`ServerClient`, `CloudClient`) +- Authentication providers (Basic, Token, ChromaToken) +- Model classes for v2 operations +- Collection operations interface +- Query builder pattern +- Fluent API for all operations +- Type-safe metadata and filtering + +### Known Issues ⚠️ +1. **API Endpoints:** Currently modified to use `/api/v1` endpoints as a temporary workaround +2. **Tenant/Database Support:** v2 expects multi-tenancy which v1 doesn't fully support +3. **Response Models:** Field names and structure differ between v1 and v2 +4. **Embedding Functions:** Integration needs refinement for v2 API + +### Coming Soon 🚀 +- CloudClient implementation +- Advanced query capabilities +- Batch operations optimization +- Streaming results +- Async/reactive operations + +## API Design: Dual Approach + +The V2 API offers **two complementary approaches**: + +### 1. Convenience Methods (Simple API) +- **For**: 80% of use cases +- **Style**: Direct method calls with parameters +- **Benefit**: Minimal boilerplate, Chroma-aligned +- **Example**: `collection.add(ids, embeddings, documents)` + +### 2. Builder Pattern (Advanced API) +- **For**: 20% of complex use cases +- **Style**: Fluent builders with `.execute()` +- **Benefit**: Maximum flexibility, all options available +- **Example**: `collection.query().queryEmbeddings(...).where(...).execute()` + +### When to Use Which? + +| Use Case | Recommended Approach | Example | +|----------|---------------------|---------| +| Simple add with all data | Convenience | `collection.add(ids, embeddings, documents, metadatas)` | +| Add with URIs or complex options | Builder | `collection.add().ids(...).uris(...).execute()` | +| Basic query | Convenience | `collection.query(embeddings, 10)` | +| Query with whereDocument or complex filters | Builder | `collection.query().queryEmbeddings(...).whereDocument(...).execute()` | +| Get by IDs | Convenience | `collection.get(List.of("id1", "id2"))` | +| Get with pagination | Builder | `collection.get().limit(100).offset(0).execute()` | +| Delete by IDs | Convenience | `collection.delete(ids)` | +| Delete by complex filter | Builder | `collection.delete().where(...).whereDocument(...).execute()` | + +### Design Philosophy + +> **"Simple things should be simple, complex things should be possible."** + +The dual API approach ensures: +- New users can get started quickly with minimal code +- Power users have full control when needed +- API feels familiar to Chroma users from Python/TypeScript +- Java best practices (type safety, clarity) are maintained + +## Migration from V1 + +The V2 API is designed to coexist with V1. Key differences: + +| V1 | V2 | +|----|-----| +| `Client` class | `ChromaClient` | +| Swagger-generated models | Hand-crafted POJOs | +| Builder-only patterns | Dual approach (convenience + builders) | +| Multiple ways to configure | Flat, simple API surface | +| Nested packages | Flat package structure | + +## Testing + +The V2 API includes comprehensive test coverage: + +```bash +# Run all V2 tests +mvn test -Dtest="tech.amikos.chromadb.v2.**" + +# Run with specific ChromaDB version +export CHROMA_VERSION=1.1.0 && mvn test + +# Run stress tests +mvn test -Dtest=V2StressTest +``` + +## Support + +This is an experimental API. For production use, please use the stable V1 API. + +For issues or questions: +- GitHub Issues: [chromadb-java-client/issues](https://github.com/amikos-tech/chromadb-java-client/issues) +- Documentation: This file +- Examples: See test files in `src/test/java/tech/amikos/chromadb/v2/` \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/AddRecordsRequest.java b/src/main/java/tech/amikos/chromadb/v2/AddRecordsRequest.java new file mode 100644 index 0000000..f98368e --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/AddRecordsRequest.java @@ -0,0 +1,113 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class AddRecordsRequest { + @SerializedName("ids") + private final List ids; + + @SerializedName("embeddings") + private final Object embeddings; + + @SerializedName("documents") + private final List documents; + + @SerializedName("metadatas") + private final List> metadatas; + + @SerializedName("uris") + private final List uris; + + private AddRecordsRequest(Builder builder) { + this.ids = builder.ids; + this.embeddings = builder.embeddings; + this.documents = builder.documents; + this.metadatas = builder.metadatas; + this.uris = builder.uris; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List ids = new ArrayList<>(); + private Object embeddings; + private List documents; + private List> metadatas; + private List uris; + + public Builder ids(List ids) { + this.ids = ids; + return this; + } + + public Builder id(String id) { + this.ids.add(id); + return this; + } + + public Builder embeddings(List> embeddings) { + this.embeddings = embeddings; + return this; + } + + public Builder embeddingsAsBase64(List embeddings) { + this.embeddings = embeddings; + return this; + } + + public Builder documents(List documents) { + this.documents = documents; + return this; + } + + public Builder document(String document) { + if (this.documents == null) { + this.documents = new ArrayList<>(); + } + this.documents.add(document); + return this; + } + + public Builder metadatas(List> metadatas) { + this.metadatas = metadatas; + return this; + } + + public Builder metadata(Map metadata) { + if (this.metadatas == null) { + this.metadatas = new ArrayList<>(); + } + this.metadatas.add(metadata); + return this; + } + + public Builder uris(List uris) { + this.uris = uris; + return this; + } + + public Builder uri(String uri) { + if (this.uris == null) { + this.uris = new ArrayList<>(); + } + this.uris.add(uri); + return this; + } + + public AddRecordsRequest build() { + if (ids == null || ids.isEmpty()) { + throw new IllegalArgumentException("ids are required"); + } + if (embeddings == null) { + throw new IllegalArgumentException("embeddings are required"); + } + return new AddRecordsRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/AuthProvider.java b/src/main/java/tech/amikos/chromadb/v2/AuthProvider.java new file mode 100644 index 0000000..5984565 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/AuthProvider.java @@ -0,0 +1,23 @@ +package tech.amikos.chromadb.v2; + +import okhttp3.Request; + +public interface AuthProvider { + Request.Builder authenticate(Request.Builder requestBuilder); + + static AuthProvider none() { + return NoAuthProvider.INSTANCE; + } + + static AuthProvider token(String token) { + return new TokenAuthProvider(token); + } + + static AuthProvider basic(String username, String password) { + return new BasicAuthProvider(username, password); + } + + static AuthProvider chromaToken(String token) { + return new ChromaTokenAuthProvider(token); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/BasicAuthProvider.java b/src/main/java/tech/amikos/chromadb/v2/BasicAuthProvider.java new file mode 100644 index 0000000..0aa20a3 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/BasicAuthProvider.java @@ -0,0 +1,17 @@ +package tech.amikos.chromadb.v2; + +import okhttp3.Credentials; +import okhttp3.Request; + +class BasicAuthProvider implements AuthProvider { + private final String credentials; + + BasicAuthProvider(String username, String password) { + this.credentials = Credentials.basic(username, password); + } + + @Override + public Request.Builder authenticate(Request.Builder requestBuilder) { + return requestBuilder.header("Authorization", credentials); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaBadRequestException.java b/src/main/java/tech/amikos/chromadb/v2/ChromaBadRequestException.java new file mode 100644 index 0000000..4900e99 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaBadRequestException.java @@ -0,0 +1,7 @@ +package tech.amikos.chromadb.v2; + +public class ChromaBadRequestException extends ChromaV2Exception { + public ChromaBadRequestException(String message) { + super(400, "BAD_REQUEST", message); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaClient.java b/src/main/java/tech/amikos/chromadb/v2/ChromaClient.java new file mode 100644 index 0000000..edf6bcb --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaClient.java @@ -0,0 +1,265 @@ +package tech.amikos.chromadb.v2; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Simplified ChromaDB client with single approach for configuration. + * Follows radical simplicity principles: flat package, single client, builder-only patterns. + */ +public class ChromaClient { + private final HttpClient httpClient; + private final String defaultTenant; + private final String defaultDatabase; + + private ChromaClient(Builder builder) { + this.httpClient = HttpClient.builder() + .baseUrl(builder.baseUrl) + .auth(builder.authProvider) + .connectTimeout(builder.connectTimeout) + .readTimeout(builder.readTimeout) + .writeTimeout(builder.writeTimeout) + .build(); + this.defaultTenant = builder.defaultTenant; + this.defaultDatabase = builder.defaultDatabase; + } + + public static Builder builder() { + return new Builder(); + } + + // Heartbeat and version + @SuppressWarnings("unchecked") + public String heartbeat() { + Map response = httpClient.get("/api/v2/heartbeat", Map.class); + return response.get("nanosecond heartbeat").toString(); + } + + public String version() { + String response = httpClient.get("/api/v2/version", String.class); + return response.replace("\"", ""); + } + + public void reset() { + httpClient.post("/api/v2/reset", null, Void.class); + } + + // Tenant operations + public Tenant createTenant(String name) { + Map request = new HashMap<>(); + request.put("name", name); + return httpClient.post("/api/v2/tenants", request, Tenant.class); + } + + public Tenant getTenant(String name) { + return httpClient.get("/api/v2/tenants/" + name, Tenant.class); + } + + // Database operations + public Database createDatabase(String name) { + return createDatabase(defaultTenant, name); + } + + public Database createDatabase(String tenant, String name) { + Map request = new HashMap<>(); + request.put("name", name); + return httpClient.post("/api/v2/tenants/" + tenant + "/databases", request, Database.class); + } + + public Database getDatabase(String name) { + return getDatabase(defaultTenant, name); + } + + public Database getDatabase(String tenant, String name) { + return httpClient.get("/api/v2/tenants/" + tenant + "/databases/" + name, Database.class); + } + + public List listDatabases() { + return listDatabases(defaultTenant); + } + + @SuppressWarnings("unchecked") + public List listDatabases(String tenant) { + return httpClient.get("/api/v2/tenants/" + tenant + "/databases", List.class); + } + + public void deleteDatabase(String name) { + deleteDatabase(defaultTenant, name); + } + + public void deleteDatabase(String tenant, String name) { + httpClient.delete("/api/v2/tenants/" + tenant + "/databases/" + name, Void.class); + } + + // Collection operations - simplified overloads + public Collection createCollection(String name) { + return createCollection(defaultTenant, defaultDatabase, name, null); + } + + public Collection createCollection(String name, Map metadata) { + CreateCollectionRequest request = new CreateCollectionRequest.Builder(name) + .metadata(metadata) + .build(); + return createCollectionWithRequest(defaultTenant, defaultDatabase, request); + } + + public Collection getCollection(String nameOrId) { + return getCollection(defaultTenant, defaultDatabase, nameOrId); + } + + @SuppressWarnings("unchecked") + public Collection getCollection(String tenant, String database, String nameOrId) { + // Try as ID first + try { + UUID.fromString(nameOrId); + CollectionModel model = httpClient.get( + "/api/v2/tenants/" + tenant + "/databases/" + database + "/collections/" + nameOrId, + CollectionModel.class + ); + return new Collection(httpClient, model); + } catch (IllegalArgumentException e) { + // Not a UUID, try as name + List collections = httpClient.get( + "/api/v2/tenants/" + tenant + "/databases/" + database + "/collections?name=" + nameOrId, + List.class + ); + if (collections.isEmpty()) { + throw new ChromaNotFoundException("Collection not found: " + nameOrId); + } + return new Collection(httpClient, collections.get(0)); + } + } + + public Collection getOrCreateCollection(String name) { + return getOrCreateCollection(name, null); + } + + public Collection getOrCreateCollection(String name, Map metadata) { + try { + return getCollection(name); + } catch (ChromaNotFoundException e) { + return createCollection(name, metadata); + } + } + + public List listCollections() { + return listCollections(defaultTenant, defaultDatabase); + } + + @SuppressWarnings("unchecked") + public List listCollections(String tenant, String database) { + List models = httpClient.get( + "/api/v2/tenants/" + tenant + "/databases/" + database + "/collections", + List.class + ); + return models.stream() + .map(model -> new Collection(httpClient, model)) + .collect(Collectors.toList()); + } + + public void deleteCollection(String nameOrId) { + deleteCollection(defaultTenant, defaultDatabase, nameOrId); + } + + public void deleteCollection(String tenant, String database, String nameOrId) { + Collection collection = getCollection(tenant, database, nameOrId); + httpClient.delete("/api/v2/tenants/" + tenant + "/databases/" + database + + "/collections/" + collection.getId(), Void.class); + } + + public int countCollections() { + return countCollections(defaultTenant, defaultDatabase); + } + + public int countCollections(String tenant, String database) { + Integer count = httpClient.get( + "/api/v2/tenants/" + tenant + "/databases/" + database + "/collections_count", + Integer.class + ); + return count; + } + + // Private helpers + private Collection createCollectionWithRequest(String tenant, String database, CreateCollectionRequest request) { + CollectionModel model = httpClient.post( + "/api/v2/tenants/" + tenant + "/databases/" + database + "/collections", + request, + CollectionModel.class + ); + return new Collection(httpClient, model); + } + + private Collection createCollection(String tenant, String database, String name, Map metadata) { + CreateCollectionRequest request = new CreateCollectionRequest.Builder(name) + .metadata(metadata) + .build(); + return createCollectionWithRequest(tenant, database, request); + } + + public static class Builder { + private String baseUrl = "http://localhost:8000"; + private AuthProvider authProvider = NoAuthProvider.INSTANCE; + private String defaultTenant = "default_tenant"; + private String defaultDatabase = "default_database"; + private int connectTimeout = 60; + private int readTimeout = 60; + private int writeTimeout = 60; + + // Server mode configuration + public Builder serverUrl(String url) { + this.baseUrl = url; + return this; + } + + // Cloud mode configuration (syntactic sugar) + public Builder cloudUrl(String url) { + this.baseUrl = url; + return this; + } + + public Builder apiKey(String apiKey) { + this.authProvider = new TokenAuthProvider(apiKey); + return this; + } + + public Builder auth(AuthProvider authProvider) { + this.authProvider = authProvider; + return this; + } + + public Builder tenant(String tenant) { + this.defaultTenant = tenant; + return this; + } + + public Builder database(String database) { + this.defaultDatabase = database; + return this; + } + + public Builder connectTimeout(int seconds) { + this.connectTimeout = seconds; + return this; + } + + public Builder readTimeout(int seconds) { + this.readTimeout = seconds; + return this; + } + + public Builder writeTimeout(int seconds) { + this.writeTimeout = seconds; + return this; + } + + public ChromaClient build() { + if (baseUrl == null) { + throw new IllegalStateException("Base URL is required"); + } + return new ChromaClient(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaNotFoundException.java b/src/main/java/tech/amikos/chromadb/v2/ChromaNotFoundException.java new file mode 100644 index 0000000..e9ca021 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaNotFoundException.java @@ -0,0 +1,7 @@ +package tech.amikos.chromadb.v2; + +public class ChromaNotFoundException extends ChromaV2Exception { + public ChromaNotFoundException(String message) { + super(404, "NOT_FOUND", message); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaServerException.java b/src/main/java/tech/amikos/chromadb/v2/ChromaServerException.java new file mode 100644 index 0000000..2de7408 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaServerException.java @@ -0,0 +1,11 @@ +package tech.amikos.chromadb.v2; + +public class ChromaServerException extends ChromaV2Exception { + public ChromaServerException(String message) { + super(500, "SERVER_ERROR", message); + } + + public ChromaServerException(int statusCode, String message) { + super(statusCode, "SERVER_ERROR", message); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaTokenAuthProvider.java b/src/main/java/tech/amikos/chromadb/v2/ChromaTokenAuthProvider.java new file mode 100644 index 0000000..0be2d08 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaTokenAuthProvider.java @@ -0,0 +1,16 @@ +package tech.amikos.chromadb.v2; + +import okhttp3.Request; + +class ChromaTokenAuthProvider implements AuthProvider { + private final String token; + + ChromaTokenAuthProvider(String token) { + this.token = token; + } + + @Override + public Request.Builder authenticate(Request.Builder requestBuilder) { + return requestBuilder.header("X-Chroma-Token", token); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaUnauthorizedException.java b/src/main/java/tech/amikos/chromadb/v2/ChromaUnauthorizedException.java new file mode 100644 index 0000000..4e788e0 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaUnauthorizedException.java @@ -0,0 +1,7 @@ +package tech.amikos.chromadb.v2; + +public class ChromaUnauthorizedException extends ChromaV2Exception { + public ChromaUnauthorizedException(String message) { + super(401, "UNAUTHORIZED", message); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/ChromaV2Exception.java b/src/main/java/tech/amikos/chromadb/v2/ChromaV2Exception.java new file mode 100644 index 0000000..839fe00 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/ChromaV2Exception.java @@ -0,0 +1,32 @@ +package tech.amikos.chromadb.v2; + +public class ChromaV2Exception extends RuntimeException { + private final int statusCode; + private final String errorType; + + public ChromaV2Exception(String message) { + super(message); + this.statusCode = -1; + this.errorType = "UNKNOWN"; + } + + public ChromaV2Exception(String message, Throwable cause) { + super(message, cause); + this.statusCode = -1; + this.errorType = "UNKNOWN"; + } + + public ChromaV2Exception(int statusCode, String errorType, String message) { + super(message); + this.statusCode = statusCode; + this.errorType = errorType; + } + + public int getStatusCode() { + return statusCode; + } + + public String getErrorType() { + return errorType; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/Collection.java b/src/main/java/tech/amikos/chromadb/v2/Collection.java new file mode 100644 index 0000000..dd97df8 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/Collection.java @@ -0,0 +1,367 @@ +package tech.amikos.chromadb.v2; + +import tech.amikos.chromadb.v2.HttpClient; +import tech.amikos.chromadb.v2.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Server implementation of Collection for self-hosted Chroma instances. + */ +/** + * Concrete Collection class representing a ChromaDB collection. + * Uses builder pattern for all complex operations, following radical simplicity principles. + */ +public class Collection { + private final CollectionModel model; + private final HttpClient httpClient; + + public Collection(HttpClient httpClient, CollectionModel model) { + this.model = model; + this.httpClient = httpClient; + } + + public UUID getId() { + return model.getId(); + } + + public String getName() { + return model.getName(); + } + + public String getTenant() { + return model.getTenant(); + } + + public String getDatabase() { + return model.getDatabase(); + } + + public Map getMetadata() { + return model.getMetadata(); + } + + public Integer getDimension() { + return model.getDimension(); + } + + public CollectionConfiguration getConfiguration() { + return model.getConfiguration(); + } + + public String getResourceName() { + return model.getResourceName(); + } + + private String basePath() { + return String.format("/api/v2/tenants/%s/databases/%s/collections/%s", + getTenant(), getDatabase(), getId()); + } + + public int count() { + return httpClient.get(basePath() + "/count", Integer.class); + } + + // Convenience methods for common use cases (Chroma-aligned API) + + public void add(List ids, List> embeddings) { + new AddBuilder().ids(ids).embeddings(embeddings).execute(); + } + + public void add(List ids, List> embeddings, List documents) { + new AddBuilder().ids(ids).embeddings(embeddings).documents(documents).execute(); + } + + public void add(List ids, List> embeddings, List documents, List> metadatas) { + new AddBuilder().ids(ids).embeddings(embeddings).documents(documents).metadatas(metadatas).execute(); + } + + public QueryResponse query(List> queryEmbeddings, int nResults) { + return new QueryBuilder().queryEmbeddings(queryEmbeddings).nResults(nResults).execute(); + } + + public QueryResponse query(List> queryEmbeddings, int nResults, Where where) { + return new QueryBuilder().queryEmbeddings(queryEmbeddings).nResults(nResults).where(where).execute(); + } + + public QueryResponse query(List> queryEmbeddings, int nResults, Where where, Include... include) { + return new QueryBuilder().queryEmbeddings(queryEmbeddings).nResults(nResults).where(where).include(include).execute(); + } + + public QueryResponse queryByText(List queryTexts, int nResults) { + return new QueryBuilder().queryTexts(queryTexts).nResults(nResults).execute(); + } + + public QueryResponse queryByText(List queryTexts, int nResults, Where where) { + return new QueryBuilder().queryTexts(queryTexts).nResults(nResults).where(where).execute(); + } + + public QueryResponse queryByText(List queryTexts, int nResults, Where where, Include... include) { + return new QueryBuilder().queryTexts(queryTexts).nResults(nResults).where(where).include(include).execute(); + } + + public GetResponse get(List ids) { + return new GetBuilder().ids(ids).execute(); + } + + public GetResponse get(List ids, Include... include) { + return new GetBuilder().ids(ids).include(include).execute(); + } + + public void update(List ids, List> embeddings, List documents, List> metadatas) { + new UpdateBuilder().ids(ids).embeddings(embeddings).documents(documents).metadatas(metadatas).execute(); + } + + public void upsert(List ids, List> embeddings) { + new UpsertBuilder().ids(ids).embeddings(embeddings).execute(); + } + + public void upsert(List ids, List> embeddings, List documents) { + new UpsertBuilder().ids(ids).embeddings(embeddings).documents(documents).execute(); + } + + public void upsert(List ids, List> embeddings, List documents, List> metadatas) { + new UpsertBuilder().ids(ids).embeddings(embeddings).documents(documents).metadatas(metadatas).execute(); + } + + public void delete(List ids) { + new DeleteBuilder().ids(ids).execute(); + } + + public void delete(Where where) { + new DeleteBuilder().where(where).execute(); + } + + // Builder methods for complex operations + + public QueryBuilder query() { + return new QueryBuilder(); + } + + public GetBuilder get() { + return new GetBuilder(); + } + + public AddBuilder add() { + return new AddBuilder(); + } + + public UpdateBuilder update() { + return new UpdateBuilder(); + } + + public UpsertBuilder upsert() { + return new UpsertBuilder(); + } + + public DeleteBuilder delete() { + return new DeleteBuilder(); + } + + + public class QueryBuilder { + private final QueryRequest.Builder builder = QueryRequest.builder(); + + public QueryBuilder queryEmbeddings(List> embeddings) { + builder.queryEmbeddings(embeddings); + return this; + } + + public QueryBuilder queryTexts(List texts) { + builder.queryTexts(texts); + return this; + } + + public QueryBuilder nResults(int nResults) { + builder.nResults(nResults); + return this; + } + + public QueryBuilder where(Where where) { + builder.where(where); + return this; + } + + public QueryBuilder whereDocument(WhereDocument whereDocument) { + builder.whereDocument(whereDocument); + return this; + } + + public QueryBuilder include(Include... include) { + builder.include(include); + return this; + } + + public QueryResponse execute() { + QueryRequest request = builder.build(); + return httpClient.post(basePath() + "/query", request, QueryResponse.class); + } + } + + public class GetBuilder { + private final GetRequest.Builder builder = GetRequest.builder(); + + public GetBuilder ids(List ids) { + builder.ids(ids); + return this; + } + + public GetBuilder where(Where where) { + builder.where(where); + return this; + } + + public GetBuilder whereDocument(WhereDocument whereDocument) { + builder.whereDocument(whereDocument); + return this; + } + + public GetBuilder include(Include... include) { + builder.include(include); + return this; + } + + public GetBuilder limit(int limit) { + builder.limit(limit); + return this; + } + + public GetBuilder offset(int offset) { + builder.offset(offset); + return this; + } + + public GetResponse execute() { + GetRequest request = builder.build(); + return httpClient.post(basePath() + "/get", request, GetResponse.class); + } + } + + public class AddBuilder { + private final AddRecordsRequest.Builder builder = AddRecordsRequest.builder(); + + public AddBuilder ids(List ids) { + builder.ids(ids); + return this; + } + + public AddBuilder embeddings(List> embeddings) { + builder.embeddings(embeddings); + return this; + } + + public AddBuilder documents(List documents) { + builder.documents(documents); + return this; + } + + public AddBuilder metadatas(List> metadatas) { + builder.metadatas(metadatas); + return this; + } + + public AddBuilder uris(List uris) { + builder.uris(uris); + return this; + } + + public void execute() { + AddRecordsRequest request = builder.build(); + httpClient.post(basePath() + "/add", request, Void.class); + } + } + + public class UpdateBuilder { + private final UpdateRecordsRequest.Builder builder = UpdateRecordsRequest.builder(); + + public UpdateBuilder ids(List ids) { + builder.ids(ids); + return this; + } + + public UpdateBuilder embeddings(List> embeddings) { + builder.embeddings(embeddings); + return this; + } + + public UpdateBuilder documents(List documents) { + builder.documents(documents); + return this; + } + + public UpdateBuilder metadatas(List> metadatas) { + builder.metadatas(metadatas); + return this; + } + + public UpdateBuilder uris(List uris) { + builder.uris(uris); + return this; + } + + public void execute() { + UpdateRecordsRequest request = builder.build(); + httpClient.post(basePath() + "/update", request, Void.class); + } + } + + public class UpsertBuilder { + private final AddRecordsRequest.Builder builder = AddRecordsRequest.builder(); + + public UpsertBuilder ids(List ids) { + builder.ids(ids); + return this; + } + + public UpsertBuilder embeddings(List> embeddings) { + builder.embeddings(embeddings); + return this; + } + + public UpsertBuilder documents(List documents) { + builder.documents(documents); + return this; + } + + public UpsertBuilder metadatas(List> metadatas) { + builder.metadatas(metadatas); + return this; + } + + public UpsertBuilder uris(List uris) { + builder.uris(uris); + return this; + } + + public void execute() { + AddRecordsRequest request = builder.build(); + httpClient.post(basePath() + "/upsert", request, Void.class); + } + } + + public class DeleteBuilder { + private final DeleteRecordsRequest.Builder builder = DeleteRecordsRequest.builder(); + + public DeleteBuilder ids(List ids) { + builder.ids(ids); + return this; + } + + public DeleteBuilder where(Where where) { + builder.where(where); + return this; + } + + public DeleteBuilder whereDocument(WhereDocument whereDocument) { + builder.whereDocument(whereDocument); + return this; + } + + public void execute() { + DeleteRecordsRequest request = builder.build(); + httpClient.post(basePath() + "/delete", request, Void.class); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/CollectionConfiguration.java b/src/main/java/tech/amikos/chromadb/v2/CollectionConfiguration.java new file mode 100644 index 0000000..cc6e56e --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/CollectionConfiguration.java @@ -0,0 +1,61 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +public class CollectionConfiguration { + @SerializedName("embedding_function") + private final EmbeddingFunctionConfig embeddingFunction; + + @SerializedName("hnsw") + private final HnswConfiguration hnsw; + + @SerializedName("spann") + private final SpannConfiguration spann; + + private CollectionConfiguration(Builder builder) { + this.embeddingFunction = builder.embeddingFunction; + this.hnsw = builder.hnsw; + this.spann = builder.spann; + } + + public EmbeddingFunctionConfig getEmbeddingFunction() { + return embeddingFunction; + } + + public HnswConfiguration getHnsw() { + return hnsw; + } + + public SpannConfiguration getSpann() { + return spann; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private EmbeddingFunctionConfig embeddingFunction; + private HnswConfiguration hnsw; + private SpannConfiguration spann; + + public Builder embeddingFunction(EmbeddingFunctionConfig embeddingFunction) { + this.embeddingFunction = embeddingFunction; + return this; + } + + public Builder hnsw(HnswConfiguration hnsw) { + this.hnsw = hnsw; + return this; + } + + public Builder spann(SpannConfiguration spann) { + this.spann = spann; + return this; + } + + public CollectionConfiguration build() { + return new CollectionConfiguration(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/CollectionInterface.java.bak b/src/main/java/tech/amikos/chromadb/v2/CollectionInterface.java.bak new file mode 100644 index 0000000..5133432 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/CollectionInterface.java.bak @@ -0,0 +1,99 @@ +package tech.amikos.chromadb.v2; + +import tech.amikos.chromadb.v2.*; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Collection interface representing a vector collection in ChromaDB. + * Different implementations can provide server-specific or cloud-specific behavior. + */ +public interface Collection { + + // Metadata accessors + UUID getId(); + String getName(); + String getTenant(); + String getDatabase(); + Map getMetadata(); + Integer getDimension(); + CollectionConfiguration getConfiguration(); + String getResourceName(); + + // Count operation + int count(); + + // Query operations + QueryBuilder query(); + + // Get operations + GetBuilder get(); + + // Add operations + AddBuilder add(); + + // Update operations + UpdateBuilder update(); + + // Upsert operations + UpsertBuilder upsert(); + + // Delete operations + DeleteBuilder delete(); + + // Fluent builder interfaces + interface QueryBuilder { + QueryBuilder queryEmbeddings(List> embeddings); + QueryBuilder nResults(int nResults); + QueryBuilder where(Where where); + QueryBuilder whereDocument(WhereDocument whereDocument); + QueryBuilder include(Include... include); + QueryResponse execute(); + } + + interface GetBuilder { + GetBuilder ids(List ids); + GetBuilder where(Where where); + GetBuilder whereDocument(WhereDocument whereDocument); + GetBuilder include(Include... include); + GetBuilder limit(int limit); + GetBuilder offset(int offset); + GetResponse execute(); + } + + interface AddBuilder { + AddBuilder ids(List ids); + AddBuilder embeddings(List> embeddings); + AddBuilder documents(List documents); + AddBuilder metadatas(List> metadatas); + AddBuilder uris(List uris); + void execute(); + } + + interface UpdateBuilder { + UpdateBuilder ids(List ids); + UpdateBuilder embeddings(List> embeddings); + UpdateBuilder documents(List documents); + UpdateBuilder metadatas(List> metadatas); + UpdateBuilder uris(List uris); + void execute(); + } + + interface UpsertBuilder { + UpsertBuilder ids(List ids); + UpsertBuilder embeddings(List> embeddings); + UpsertBuilder documents(List documents); + UpsertBuilder metadatas(List> metadatas); + UpsertBuilder uris(List uris); + void execute(); + } + + interface DeleteBuilder { + DeleteBuilder ids(List ids); + DeleteBuilder where(Where where); + DeleteBuilder whereDocument(WhereDocument whereDocument); + void execute(); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/CollectionModel.java b/src/main/java/tech/amikos/chromadb/v2/CollectionModel.java new file mode 100644 index 0000000..ddaf4fc --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/CollectionModel.java @@ -0,0 +1,93 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; +import java.util.UUID; + +/** + * POJO representing collection metadata from the API. + * This is the data model returned by the API, not the operational interface. + */ +public class CollectionModel { + @SerializedName("id") + private final UUID id; + + @SerializedName("name") + private final String name; + + @SerializedName("tenant") + private final String tenant; + + @SerializedName("database") + private final String database; + + @SerializedName("metadata") + private final Map metadata; + + @SerializedName("dimension") + private final Integer dimension; + + @SerializedName("configuration_json") + private final CollectionConfiguration configuration; + + @SerializedName("log_position") + private final Long logPosition; + + @SerializedName("version") + private final Integer version; + + public CollectionModel(UUID id, String name, String tenant, String database, + Map metadata, Integer dimension, + CollectionConfiguration configuration, Long logPosition, Integer version) { + this.id = id; + this.name = name; + this.tenant = tenant; + this.database = database; + this.metadata = metadata; + this.dimension = dimension; + this.configuration = configuration; + this.logPosition = logPosition; + this.version = version; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public String getTenant() { + return tenant; + } + + public String getDatabase() { + return database; + } + + public Map getMetadata() { + return metadata; + } + + public Integer getDimension() { + return dimension; + } + + public CollectionConfiguration getConfiguration() { + return configuration; + } + + public Long getLogPosition() { + return logPosition; + } + + public Integer getVersion() { + return version; + } + + public String getResourceName() { + return String.format("chroma://%s/%s/collections/%s", tenant, database, id); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/CreateCollectionRequest.java b/src/main/java/tech/amikos/chromadb/v2/CreateCollectionRequest.java new file mode 100644 index 0000000..3700d1c --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/CreateCollectionRequest.java @@ -0,0 +1,60 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + +public class CreateCollectionRequest { + @SerializedName("name") + private final String name; + + @SerializedName("metadata") + private final Map metadata; + + @SerializedName("configuration") + private final CollectionConfiguration configuration; + + @SerializedName("get_or_create") + private final Boolean getOrCreate; + + private CreateCollectionRequest(Builder builder) { + this.name = builder.name; + this.metadata = builder.metadata; + this.configuration = builder.configuration; + this.getOrCreate = builder.getOrCreate; + } + + public static Builder builder(String name) { + return new Builder(name); + } + + public static class Builder { + private final String name; + private Map metadata; + private CollectionConfiguration configuration; + private Boolean getOrCreate = false; + + public Builder(String name) { + this.name = name; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder configuration(CollectionConfiguration configuration) { + this.configuration = configuration; + return this; + } + + public Builder getOrCreate(boolean getOrCreate) { + this.getOrCreate = getOrCreate; + return this; + } + + public CreateCollectionRequest build() { + return new CreateCollectionRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/Database.java b/src/main/java/tech/amikos/chromadb/v2/Database.java new file mode 100644 index 0000000..48dfb22 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/Database.java @@ -0,0 +1,34 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.UUID; + +public class Database { + @SerializedName("id") + private final UUID id; + + @SerializedName("name") + private final String name; + + @SerializedName("tenant") + private final String tenant; + + public Database(UUID id, String name, String tenant) { + this.id = id; + this.name = name; + this.tenant = tenant; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public String getTenant() { + return tenant; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/DeleteRecordsRequest.java b/src/main/java/tech/amikos/chromadb/v2/DeleteRecordsRequest.java new file mode 100644 index 0000000..2a4cab7 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/DeleteRecordsRequest.java @@ -0,0 +1,52 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; +import java.util.Map; + +public class DeleteRecordsRequest { + @SerializedName("ids") + private final List ids; + + @SerializedName("where") + private final Map where; + + @SerializedName("where_document") + private final Map whereDocument; + + private DeleteRecordsRequest(Builder builder) { + this.ids = builder.ids; + this.where = builder.where; + this.whereDocument = builder.whereDocument; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List ids; + private Map where; + private Map whereDocument; + + public Builder ids(List ids) { + this.ids = ids; + return this; + } + + public Builder where(Where where) { + this.where = where != null ? where.toMap() : null; + return this; + } + + public Builder whereDocument(WhereDocument whereDocument) { + this.whereDocument = whereDocument != null ? whereDocument.toMap() : null; + return this; + } + + public DeleteRecordsRequest build() { + return new DeleteRecordsRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/EmbeddingFunctionConfig.java b/src/main/java/tech/amikos/chromadb/v2/EmbeddingFunctionConfig.java new file mode 100644 index 0000000..fa7fbde --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/EmbeddingFunctionConfig.java @@ -0,0 +1,34 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + +public class EmbeddingFunctionConfig { + @SerializedName("name") + private final String name; + + @SerializedName("config") + private final Map config; + + public EmbeddingFunctionConfig(String name, Map config) { + this.name = name; + this.config = config; + } + + public String getName() { + return name; + } + + public Map getConfig() { + return config; + } + + public static EmbeddingFunctionConfig legacy() { + return new EmbeddingFunctionConfig("legacy", null); + } + + public static EmbeddingFunctionConfig custom(String name, Map config) { + return new EmbeddingFunctionConfig(name, config); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/GetRequest.java b/src/main/java/tech/amikos/chromadb/v2/GetRequest.java new file mode 100644 index 0000000..1825d28 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/GetRequest.java @@ -0,0 +1,88 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class GetRequest { + @SerializedName("ids") + private final List ids; + + @SerializedName("where") + private final Map where; + + @SerializedName("where_document") + private final Map whereDocument; + + @SerializedName("include") + private final List include; + + @SerializedName("limit") + private final Integer limit; + + @SerializedName("offset") + private final Integer offset; + + private GetRequest(Builder builder) { + this.ids = builder.ids; + this.where = builder.where; + this.whereDocument = builder.whereDocument; + this.include = builder.include; + this.limit = builder.limit; + this.offset = builder.offset; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List ids; + private Map where; + private Map whereDocument; + private List include; + private Integer limit; + private Integer offset; + + public Builder ids(List ids) { + this.ids = ids; + return this; + } + + public Builder where(Where where) { + this.where = where != null ? where.toMap() : null; + return this; + } + + public Builder whereDocument(WhereDocument whereDocument) { + this.whereDocument = whereDocument != null ? whereDocument.toMap() : null; + return this; + } + + public Builder include(Include... include) { + this.include = Arrays.asList(include); + return this; + } + + public Builder include(List include) { + this.include = include; + return this; + } + + public Builder limit(int limit) { + this.limit = limit; + return this; + } + + public Builder offset(int offset) { + this.offset = offset; + return this; + } + + public GetRequest build() { + return new GetRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/GetResponse.java b/src/main/java/tech/amikos/chromadb/v2/GetResponse.java new file mode 100644 index 0000000..664b281 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/GetResponse.java @@ -0,0 +1,50 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; +import java.util.Map; + +public class GetResponse { + @SerializedName("ids") + private List ids; + + @SerializedName("embeddings") + private List> embeddings; + + @SerializedName("documents") + private List documents; + + @SerializedName("metadatas") + private List> metadatas; + + @SerializedName("uris") + private List uris; + + @SerializedName("include") + private List include; + + public List getIds() { + return ids; + } + + public List> getEmbeddings() { + return embeddings; + } + + public List getDocuments() { + return documents; + } + + public List> getMetadatas() { + return metadatas; + } + + public List getUris() { + return uris; + } + + public List getInclude() { + return include; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/HnswConfiguration.java b/src/main/java/tech/amikos/chromadb/v2/HnswConfiguration.java new file mode 100644 index 0000000..d083898 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/HnswConfiguration.java @@ -0,0 +1,99 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +public class HnswConfiguration { + @SerializedName("space") + private final String space; + + @SerializedName("ef_construction") + private final Integer efConstruction; + + @SerializedName("ef_search") + private final Integer efSearch; + + @SerializedName("num_threads") + private final Integer numThreads; + + @SerializedName("M") + private final Integer m; + + @SerializedName("resize_factor") + private final Double resizeFactor; + + @SerializedName("batch_size") + private final Integer batchSize; + + @SerializedName("sync_threshold") + private final Integer syncThreshold; + + private HnswConfiguration(Builder builder) { + this.space = builder.space; + this.efConstruction = builder.efConstruction; + this.efSearch = builder.efSearch; + this.numThreads = builder.numThreads; + this.m = builder.m; + this.resizeFactor = builder.resizeFactor; + this.batchSize = builder.batchSize; + this.syncThreshold = builder.syncThreshold; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String space = "l2"; + private Integer efConstruction; + private Integer efSearch; + private Integer numThreads; + private Integer m; + private Double resizeFactor; + private Integer batchSize; + private Integer syncThreshold; + + public Builder space(String space) { + this.space = space; + return this; + } + + public Builder efConstruction(Integer efConstruction) { + this.efConstruction = efConstruction; + return this; + } + + public Builder efSearch(Integer efSearch) { + this.efSearch = efSearch; + return this; + } + + public Builder numThreads(Integer numThreads) { + this.numThreads = numThreads; + return this; + } + + public Builder m(Integer m) { + this.m = m; + return this; + } + + public Builder resizeFactor(Double resizeFactor) { + this.resizeFactor = resizeFactor; + return this; + } + + public Builder batchSize(Integer batchSize) { + this.batchSize = batchSize; + return this; + } + + public Builder syncThreshold(Integer syncThreshold) { + this.syncThreshold = syncThreshold; + return this; + } + + public HnswConfiguration build() { + return new HnswConfiguration(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/HttpClient.java b/src/main/java/tech/amikos/chromadb/v2/HttpClient.java new file mode 100644 index 0000000..2918abe --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/HttpClient.java @@ -0,0 +1,186 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import okhttp3.*; +import tech.amikos.chromadb.v2.AuthProvider; +import tech.amikos.chromadb.v2.*; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +public class HttpClient { + private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); + private static final Gson GSON = new GsonBuilder().create(); + + private final OkHttpClient client; + private final String baseUrl; + private final AuthProvider authProvider; + + private HttpClient(Builder builder) { + this.baseUrl = builder.baseUrl.endsWith("/") + ? builder.baseUrl.substring(0, builder.baseUrl.length() - 1) + : builder.baseUrl; + this.authProvider = builder.authProvider; + this.client = new OkHttpClient.Builder() + .connectTimeout(builder.connectTimeout, TimeUnit.SECONDS) + .readTimeout(builder.readTimeout, TimeUnit.SECONDS) + .writeTimeout(builder.writeTimeout, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + Request.Builder requestBuilder = original.newBuilder() + .header("User-Agent", "ChromaDB-Java-Client-V2/0.2.0"); + return chain.proceed(requestBuilder.build()); + }) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + public T get(String path, Class responseType) { + String url = baseUrl + path; + Request.Builder requestBuilder = new Request.Builder().url(url).get(); + requestBuilder = authProvider.authenticate(requestBuilder); + + return executeRequest(requestBuilder.build(), responseType); + } + + public T post(String path, Object body, Class responseType) { + String url = baseUrl + path; + String json = GSON.toJson(body); + RequestBody requestBody = RequestBody.create(json, JSON); + + Request.Builder requestBuilder = new Request.Builder().url(url).post(requestBody); + requestBuilder = authProvider.authenticate(requestBuilder); + + return executeRequest(requestBuilder.build(), responseType); + } + + public T put(String path, Object body, Class responseType) { + String url = baseUrl + path; + String json = GSON.toJson(body); + RequestBody requestBody = RequestBody.create(json, JSON); + + Request.Builder requestBuilder = new Request.Builder().url(url).put(requestBody); + requestBuilder = authProvider.authenticate(requestBuilder); + + return executeRequest(requestBuilder.build(), responseType); + } + + public T delete(String path, Class responseType) { + String url = baseUrl + path; + Request.Builder requestBuilder = new Request.Builder().url(url).delete(); + requestBuilder = authProvider.authenticate(requestBuilder); + + return executeRequest(requestBuilder.build(), responseType); + } + + public T patch(String path, Object body, Class responseType) { + String url = baseUrl + path; + String json = GSON.toJson(body); + RequestBody requestBody = RequestBody.create(json, JSON); + + Request.Builder requestBuilder = new Request.Builder().url(url).patch(requestBody); + requestBuilder = authProvider.authenticate(requestBuilder); + + return executeRequest(requestBuilder.build(), responseType); + } + + private T executeRequest(Request request, Class responseType) { + try (Response response = client.newCall(request).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + + if (!response.isSuccessful()) { + handleErrorResponse(response.code(), responseBody); + } + + if (responseType == Void.class) { + return null; + } + + if (responseType == String.class) { + return responseType.cast(responseBody); + } + + return GSON.fromJson(responseBody, responseType); + } catch (IOException e) { + throw new ChromaV2Exception("Failed to execute request: " + e.getMessage(), e); + } + } + + private void handleErrorResponse(int statusCode, String responseBody) { + ErrorResponse errorResponse = null; + try { + errorResponse = GSON.fromJson(responseBody, ErrorResponse.class); + } catch (Exception ignored) { + } + + String message = errorResponse != null + ? errorResponse.getMessage() + : "HTTP " + statusCode + ": " + responseBody; + + switch (statusCode) { + case 400: + throw new ChromaBadRequestException(message); + case 401: + throw new ChromaUnauthorizedException(message); + case 404: + throw new ChromaNotFoundException(message); + case 500: + throw new ChromaServerException(message); + default: + throw new ChromaV2Exception(statusCode, "HTTP_ERROR", message); + } + } + + public static class Builder { + private String baseUrl; + private AuthProvider authProvider = AuthProvider.none(); + private int connectTimeout = 60; + private int readTimeout = 60; + private int writeTimeout = 60; + + public Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + public Builder auth(AuthProvider authProvider) { + this.authProvider = authProvider; + return this; + } + + public Builder connectTimeout(int seconds) { + this.connectTimeout = seconds; + return this; + } + + public Builder readTimeout(int seconds) { + this.readTimeout = seconds; + return this; + } + + public Builder writeTimeout(int seconds) { + this.writeTimeout = seconds; + return this; + } + + public HttpClient build() { + if (baseUrl == null || baseUrl.isEmpty()) { + throw new IllegalArgumentException("baseUrl is required"); + } + return new HttpClient(this); + } + } + + private static class ErrorResponse { + private String error; + private String message; + + public String getMessage() { + return message != null ? message : error; + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/Include.java b/src/main/java/tech/amikos/chromadb/v2/Include.java new file mode 100644 index 0000000..361f5dd --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/Include.java @@ -0,0 +1,30 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +public enum Include { + @SerializedName("embeddings") + EMBEDDINGS("embeddings"), + + @SerializedName("documents") + DOCUMENTS("documents"), + + @SerializedName("metadatas") + METADATAS("metadatas"), + + @SerializedName("distances") + DISTANCES("distances"), + + @SerializedName("uris") + URIS("uris"); + + private final String value; + + Include(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/Metadata.java b/src/main/java/tech/amikos/chromadb/v2/Metadata.java new file mode 100644 index 0000000..34aa587 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/Metadata.java @@ -0,0 +1,258 @@ +package tech.amikos.chromadb.v2; + +import java.util.*; + +/** + * Strongly-typed metadata container with builder pattern and custom serialization. + * Provides type-safe access methods while maintaining flexibility. + */ +public class Metadata { + private final Map data; + + private Metadata(Map data) { + this.data = Collections.unmodifiableMap(new HashMap<>(data)); + } + + public static Builder builder() { + return new Builder(); + } + + public static Metadata of(Map data) { + return new Metadata(data != null ? data : Collections.emptyMap()); + } + + public static Metadata empty() { + return new Metadata(Collections.emptyMap()); + } + + // Type-safe getters + public String getString(String key) { + Object value = data.get(key); + return value != null ? value.toString() : null; + } + + public String getString(String key, String defaultValue) { + String value = getString(key); + return value != null ? value : defaultValue; + } + + public Integer getInt(String key) { + Object value = data.get(key); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof String) { + try { + return Integer.parseInt((String) value); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + public Integer getInt(String key, Integer defaultValue) { + Integer value = getInt(key); + return value != null ? value : defaultValue; + } + + public Long getLong(String key) { + Object value = data.get(key); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value instanceof String) { + try { + return Long.parseLong((String) value); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + public Long getLong(String key, Long defaultValue) { + Long value = getLong(key); + return value != null ? value : defaultValue; + } + + public Double getDouble(String key) { + Object value = data.get(key); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + if (value instanceof String) { + try { + return Double.parseDouble((String) value); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + public Double getDouble(String key, Double defaultValue) { + Double value = getDouble(key); + return value != null ? value : defaultValue; + } + + public Boolean getBoolean(String key) { + Object value = data.get(key); + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof String) { + return Boolean.parseBoolean((String) value); + } + return null; + } + + public Boolean getBoolean(String key, Boolean defaultValue) { + Boolean value = getBoolean(key); + return value != null ? value : defaultValue; + } + + @SuppressWarnings("unchecked") + public List getStringList(String key) { + Object value = data.get(key); + if (value instanceof List) { + List list = (List) value; + List result = new ArrayList<>(); + for (Object item : list) { + result.add(item != null ? item.toString() : null); + } + return result; + } + return null; + } + + @SuppressWarnings("unchecked") + public T get(String key, Class type) { + Object value = data.get(key); + if (type.isInstance(value)) { + return (T) value; + } + return null; + } + + public Object get(String key) { + return data.get(key); + } + + public boolean containsKey(String key) { + return data.containsKey(key); + } + + public Set keySet() { + return data.keySet(); + } + + public int size() { + return data.size(); + } + + public boolean isEmpty() { + return data.isEmpty(); + } + + // For JSON serialization (Jackson will use this getter) + public Map toMap() { + return data; + } + + // Builder methods that return new instances + public Metadata with(String key, Object value) { + Map newData = new HashMap<>(data); + newData.put(key, value); + return new Metadata(newData); + } + + public Metadata without(String key) { + Map newData = new HashMap<>(data); + newData.remove(key); + return new Metadata(newData); + } + + public Metadata merge(Metadata other) { + Map newData = new HashMap<>(data); + if (other != null) { + newData.putAll(other.data); + } + return new Metadata(newData); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Metadata metadata = (Metadata) o; + return Objects.equals(data, metadata.data); + } + + @Override + public int hashCode() { + return Objects.hash(data); + } + + @Override + public String toString() { + return "Metadata" + data; + } + + public static class Builder { + private final Map data = new HashMap<>(); + + public Builder put(String key, Object value) { + data.put(key, value); + return this; + } + + public Builder putString(String key, String value) { + data.put(key, value); + return this; + } + + public Builder putInt(String key, int value) { + data.put(key, value); + return this; + } + + public Builder putLong(String key, long value) { + data.put(key, value); + return this; + } + + public Builder putDouble(String key, double value) { + data.put(key, value); + return this; + } + + public Builder putBoolean(String key, boolean value) { + data.put(key, value); + return this; + } + + public Builder putList(String key, List value) { + data.put(key, value); + return this; + } + + public Builder putAll(Map map) { + if (map != null) { + data.putAll(map); + } + return this; + } + + public Builder from(Metadata metadata) { + if (metadata != null) { + data.putAll(metadata.data); + } + return this; + } + + public Metadata build() { + return new Metadata(data); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/NoAuthProvider.java b/src/main/java/tech/amikos/chromadb/v2/NoAuthProvider.java new file mode 100644 index 0000000..6ef59ab --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/NoAuthProvider.java @@ -0,0 +1,14 @@ +package tech.amikos.chromadb.v2; + +import okhttp3.Request; + +public class NoAuthProvider implements AuthProvider { + public static final NoAuthProvider INSTANCE = new NoAuthProvider(); + + private NoAuthProvider() {} + + @Override + public Request.Builder authenticate(Request.Builder requestBuilder) { + return requestBuilder; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/QueryRequest.java b/src/main/java/tech/amikos/chromadb/v2/QueryRequest.java new file mode 100644 index 0000000..aeb3eb0 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/QueryRequest.java @@ -0,0 +1,100 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class QueryRequest { + @SerializedName("query_embeddings") + private final Object queryEmbeddings; + + @SerializedName("query_texts") + private final List queryTexts; + + @SerializedName("n_results") + private final Integer nResults; + + @SerializedName("where") + private final Map where; + + @SerializedName("where_document") + private final Map whereDocument; + + @SerializedName("include") + private final List include; + + private QueryRequest(Builder builder) { + this.queryEmbeddings = builder.queryEmbeddings; + this.queryTexts = builder.queryTexts; + this.nResults = builder.nResults; + this.where = builder.where; + this.whereDocument = builder.whereDocument; + this.include = builder.include; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Object queryEmbeddings; + private List queryTexts; + private Integer nResults = 10; + private Map where; + private Map whereDocument; + private List include; + + public Builder queryEmbeddings(List> embeddings) { + this.queryEmbeddings = embeddings; + return this; + } + + public Builder queryEmbeddingsAsBase64(List embeddings) { + this.queryEmbeddings = embeddings; + return this; + } + + public Builder queryTexts(List texts) { + this.queryTexts = texts; + return this; + } + + public Builder nResults(int nResults) { + this.nResults = nResults; + return this; + } + + public Builder where(Where where) { + this.where = where != null ? where.toMap() : null; + return this; + } + + public Builder whereDocument(WhereDocument whereDocument) { + this.whereDocument = whereDocument != null ? whereDocument.toMap() : null; + return this; + } + + public Builder include(Include... include) { + this.include = Arrays.asList(include); + return this; + } + + public Builder include(List include) { + this.include = include; + return this; + } + + public QueryRequest build() { + if (queryEmbeddings == null && queryTexts == null) { + throw new IllegalArgumentException("Either queryEmbeddings or queryTexts must be provided"); + } + if (queryEmbeddings != null && queryTexts != null) { + throw new IllegalArgumentException("Cannot provide both queryEmbeddings and queryTexts"); + } + return new QueryRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/QueryResponse.java b/src/main/java/tech/amikos/chromadb/v2/QueryResponse.java new file mode 100644 index 0000000..dc8049e --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/QueryResponse.java @@ -0,0 +1,50 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; +import java.util.Map; + +public class QueryResponse { + @SerializedName("ids") + private List> ids; + + @SerializedName("embeddings") + private List>> embeddings; + + @SerializedName("documents") + private List> documents; + + @SerializedName("metadatas") + private List>> metadatas; + + @SerializedName("distances") + private List> distances; + + @SerializedName("uris") + private List> uris; + + public List> getIds() { + return ids; + } + + public List>> getEmbeddings() { + return embeddings; + } + + public List> getDocuments() { + return documents; + } + + public List>> getMetadatas() { + return metadatas; + } + + public List> getDistances() { + return distances; + } + + public List> getUris() { + return uris; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/QuickStartExample.java b/src/main/java/tech/amikos/chromadb/v2/QuickStartExample.java new file mode 100644 index 0000000..7195ef0 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/QuickStartExample.java @@ -0,0 +1,155 @@ +package tech.amikos.chromadb.v2; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * QuickStart example demonstrating the V2 API with radical simplicity. + * Shows both convenience methods (for simple cases) and builders (for complex cases). + */ +public class QuickStartExample { + public static void main(String[] args) { + // Single client class with builder + ChromaClient client = ChromaClient.builder() + .serverUrl("http://localhost:8000") + .auth(AuthProvider.none()) + .build(); + + // Simple collection creation + Collection collection = client.createCollection("my-collection", + Map.of("description", "Example collection")); + + System.out.println("=== SIMPLE API (Convenience Methods) ===\n"); + + // Simple add - Chroma-aligned API (most common use case) + collection.add( + List.of("id1", "id2", "id3"), + List.of( + List.of(0.1f, 0.2f, 0.3f), + List.of(0.4f, 0.5f, 0.6f), + List.of(0.7f, 0.8f, 0.9f) + ), + List.of( + "This is a document about technology", + "This is a blog post about science", + "This is an article about technology" + ), + List.of( + Map.of("type", "article", "category", "tech"), + Map.of("type", "blog", "category", "science"), + Map.of("type", "article", "category", "tech") + ) + ); + + System.out.println("Added " + collection.count() + " records\n"); + + // Simple query by embeddings + QueryResponse results = collection.query( + List.of(List.of(0.1f, 0.2f, 0.3f)), + 2 + ); + System.out.println("Simple query results: " + results.getIds()); + + // Simple query with filtering + results = collection.query( + List.of(List.of(0.1f, 0.2f, 0.3f)), + 2, + Where.eq("type", "article") + ); + System.out.println("Filtered query results: " + results.getIds()); + + // Simple query by text (auto-embedded by collection's embedding function) + QueryResponse textResults = collection.queryByText( + List.of("technology innovation"), + 5 + ); + System.out.println("Text query results: " + textResults.getIds()); + + // Simple get by IDs + GetResponse records = collection.get(List.of("id1", "id2")); + System.out.println("Got records: " + records.getIds()); + + // Simple get with includes + records = collection.get( + List.of("id1", "id2"), + Include.DOCUMENTS, Include.METADATAS + ); + System.out.println("Got records with data: " + records.getDocuments()); + + System.out.println("\n=== ADVANCED API (Builder Pattern) ===\n"); + + // Complex query using builder - for when you need all the options + QueryResponse complexResults = collection.query() + .queryEmbeddings(List.of(List.of(0.1f, 0.2f, 0.3f))) + .nResults(2) + .where(Where.eq("type", "article")) + .whereDocument(WhereDocument.contains("technology")) + .include(Include.DOCUMENTS, Include.DISTANCES, Include.METADATAS) + .execute(); + + System.out.println("Complex query results: " + complexResults.getIds()); + + // Complex get with filtering and pagination + GetResponse filteredRecords = collection.get() + .where(Where.eq("category", "tech")) + .limit(10) + .offset(0) + .include(Include.DOCUMENTS, Include.METADATAS) + .execute(); + + System.out.println("Filtered get results: " + filteredRecords.getIds()); + + // Update using builder (complex case) + collection.update() + .ids(List.of("id1")) + .metadatas(List.of(Map.of("type", "article", "category", "tech", "featured", true))) + .execute(); + + System.out.println("Updated record metadata"); + + // Upsert with convenience method + collection.upsert( + List.of("id4"), + List.of(List.of(0.2f, 0.3f, 0.4f)), + List.of("New document about AI") + ); + System.out.println("Upserted new record"); + + System.out.println("\n=== CLEANUP ===\n"); + + // Simple delete by IDs + collection.delete(List.of("id4")); + System.out.println("Deleted id4"); + + // Delete with filtering using convenience method + collection.delete(Where.eq("type", "blog")); + System.out.println("Deleted all blog posts"); + + System.out.println("Final count: " + collection.count() + " records remaining"); + + System.out.println("\n=== OTHER FEATURES ===\n"); + + // Strongly-typed Metadata + Metadata metadata = Metadata.builder() + .putString("description", "Updated collection") + .putInt("version", 2) + .putList("tags", List.of("ai", "vectors", "search")) + .build(); + + System.out.println("Created metadata: " + metadata.toMap()); + + // Cloud mode example + ChromaClient cloudClient = ChromaClient.builder() + .cloudUrl("https://api.trychroma.com") + .apiKey("your-api-key") + .tenant("my-tenant") + .database("my-database") + .build(); + + System.out.println("\n=== KEY TAKEAWAYS ==="); + System.out.println("1. Use convenience methods for 80% of use cases (simple & Chroma-aligned)"); + System.out.println("2. Use builders when you need advanced filtering or options"); + System.out.println("3. Both approaches work together seamlessly!"); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/SpannConfiguration.java b/src/main/java/tech/amikos/chromadb/v2/SpannConfiguration.java new file mode 100644 index 0000000..c993ff5 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/SpannConfiguration.java @@ -0,0 +1,4 @@ +package tech.amikos.chromadb.v2; + +public class SpannConfiguration { +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/Tenant.java b/src/main/java/tech/amikos/chromadb/v2/Tenant.java new file mode 100644 index 0000000..3c41355 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/Tenant.java @@ -0,0 +1,16 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +public class Tenant { + @SerializedName("name") + private final String name; + + public Tenant(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/TokenAuthProvider.java b/src/main/java/tech/amikos/chromadb/v2/TokenAuthProvider.java new file mode 100644 index 0000000..aeea9d4 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/TokenAuthProvider.java @@ -0,0 +1,16 @@ +package tech.amikos.chromadb.v2; + +import okhttp3.Request; + +class TokenAuthProvider implements AuthProvider { + private final String token; + + TokenAuthProvider(String token) { + this.token = token; + } + + @Override + public Request.Builder authenticate(Request.Builder requestBuilder) { + return requestBuilder.header("Authorization", "Bearer " + token); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/UpdateCollectionRequest.java b/src/main/java/tech/amikos/chromadb/v2/UpdateCollectionRequest.java new file mode 100644 index 0000000..91df6cd --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/UpdateCollectionRequest.java @@ -0,0 +1,51 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + +public class UpdateCollectionRequest { + @SerializedName("name") + private final String name; + + @SerializedName("metadata") + private final Map metadata; + + @SerializedName("configuration") + private final CollectionConfiguration configuration; + + private UpdateCollectionRequest(Builder builder) { + this.name = builder.name; + this.metadata = builder.metadata; + this.configuration = builder.configuration; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String name; + private Map metadata; + private CollectionConfiguration configuration; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder metadata(Map metadata) { + this.metadata = metadata; + return this; + } + + public Builder configuration(CollectionConfiguration configuration) { + this.configuration = configuration; + return this; + } + + public UpdateCollectionRequest build() { + return new UpdateCollectionRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/UpdateRecordsRequest.java b/src/main/java/tech/amikos/chromadb/v2/UpdateRecordsRequest.java new file mode 100644 index 0000000..0203c32 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/UpdateRecordsRequest.java @@ -0,0 +1,76 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class UpdateRecordsRequest { + @SerializedName("ids") + private final List ids; + + @SerializedName("embeddings") + private final Object embeddings; + + @SerializedName("documents") + private final List documents; + + @SerializedName("metadatas") + private final List> metadatas; + + @SerializedName("uris") + private final List uris; + + private UpdateRecordsRequest(Builder builder) { + this.ids = builder.ids; + this.embeddings = builder.embeddings; + this.documents = builder.documents; + this.metadatas = builder.metadatas; + this.uris = builder.uris; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List ids = new ArrayList<>(); + private Object embeddings; + private List documents; + private List> metadatas; + private List uris; + + public Builder ids(List ids) { + this.ids = ids; + return this; + } + + public Builder embeddings(List> embeddings) { + this.embeddings = embeddings; + return this; + } + + public Builder documents(List documents) { + this.documents = documents; + return this; + } + + public Builder metadatas(List> metadatas) { + this.metadatas = metadatas; + return this; + } + + public Builder uris(List uris) { + this.uris = uris; + return this; + } + + public UpdateRecordsRequest build() { + if (ids == null || ids.isEmpty()) { + throw new IllegalArgumentException("ids are required"); + } + return new UpdateRecordsRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/UpdateTenantRequest.java b/src/main/java/tech/amikos/chromadb/v2/UpdateTenantRequest.java new file mode 100644 index 0000000..5342a17 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/UpdateTenantRequest.java @@ -0,0 +1,29 @@ +package tech.amikos.chromadb.v2; + +import com.google.gson.annotations.SerializedName; + +public class UpdateTenantRequest { + @SerializedName("resource_name") + private final String resourceName; + + private UpdateTenantRequest(Builder builder) { + this.resourceName = builder.resourceName; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String resourceName; + + public Builder resourceName(String resourceName) { + this.resourceName = resourceName; + return this; + } + + public UpdateTenantRequest build() { + return new UpdateTenantRequest(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/Where.java b/src/main/java/tech/amikos/chromadb/v2/Where.java new file mode 100644 index 0000000..5d9110a --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/Where.java @@ -0,0 +1,113 @@ +package tech.amikos.chromadb.v2; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Where { + private final Map conditions; + + private Where(Map conditions) { + this.conditions = conditions; + } + + public Map toMap() { + return conditions; + } + + public static Where empty() { + return new Where(new HashMap<>()); + } + + public static Where eq(String key, Object value) { + Map condition = new HashMap<>(); + condition.put(key, value); + return new Where(condition); + } + + public static Where ne(String key, Object value) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$ne", value); + condition.put(key, op); + return new Where(condition); + } + + public static Where gt(String key, Object value) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$gt", value); + condition.put(key, op); + return new Where(condition); + } + + public static Where gte(String key, Object value) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$gte", value); + condition.put(key, op); + return new Where(condition); + } + + public static Where lt(String key, Object value) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$lt", value); + condition.put(key, op); + return new Where(condition); + } + + public static Where lte(String key, Object value) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$lte", value); + condition.put(key, op); + return new Where(condition); + } + + public static Where in(String key, List values) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$in", values); + condition.put(key, op); + return new Where(condition); + } + + public static Where nin(String key, List values) { + Map condition = new HashMap<>(); + Map op = new HashMap<>(); + op.put("$nin", values); + condition.put(key, op); + return new Where(condition); + } + + public static Where and(Where... conditions) { + Map combined = new HashMap<>(); + Map andClause = new HashMap<>(); + Object[] condArray = new Object[conditions.length]; + for (int i = 0; i < conditions.length; i++) { + condArray[i] = conditions[i].toMap(); + } + andClause.put("$and", condArray); + return new Where(andClause); + } + + public static Where or(Where... conditions) { + Map combined = new HashMap<>(); + Map orClause = new HashMap<>(); + Object[] condArray = new Object[conditions.length]; + for (int i = 0; i < conditions.length; i++) { + condArray[i] = conditions[i].toMap(); + } + orClause.put("$or", condArray); + return new Where(orClause); + } + + public Where and(Where other) { + return and(this, other); + } + + public Where or(Where other) { + return or(this, other); + } +} \ No newline at end of file diff --git a/src/main/java/tech/amikos/chromadb/v2/WhereDocument.java b/src/main/java/tech/amikos/chromadb/v2/WhereDocument.java new file mode 100644 index 0000000..f59e833 --- /dev/null +++ b/src/main/java/tech/amikos/chromadb/v2/WhereDocument.java @@ -0,0 +1,48 @@ +package tech.amikos.chromadb.v2; + +import java.util.HashMap; +import java.util.Map; + +public class WhereDocument { + private final Map conditions; + + private WhereDocument(Map conditions) { + this.conditions = conditions; + } + + public Map toMap() { + return conditions; + } + + public static WhereDocument contains(String text) { + Map condition = new HashMap<>(); + condition.put("$contains", text); + return new WhereDocument(condition); + } + + public static WhereDocument notContains(String text) { + Map condition = new HashMap<>(); + condition.put("$not_contains", text); + return new WhereDocument(condition); + } + + public static WhereDocument and(WhereDocument... conditions) { + Map andClause = new HashMap<>(); + Object[] condArray = new Object[conditions.length]; + for (int i = 0; i < conditions.length; i++) { + condArray[i] = conditions[i].toMap(); + } + andClause.put("$and", condArray); + return new WhereDocument(andClause); + } + + public static WhereDocument or(WhereDocument... conditions) { + Map orClause = new HashMap<>(); + Object[] condArray = new Object[conditions.length]; + for (int i = 0; i < conditions.length; i++) { + condArray[i] = conditions[i].toMap(); + } + orClause.put("$or", condArray); + return new WhereDocument(orClause); + } +} \ No newline at end of file diff --git a/src/test/java/tech/amikos/chromadb/v2/ChromaClientTest.java b/src/test/java/tech/amikos/chromadb/v2/ChromaClientTest.java new file mode 100644 index 0000000..a8689da --- /dev/null +++ b/src/test/java/tech/amikos/chromadb/v2/ChromaClientTest.java @@ -0,0 +1,601 @@ +package tech.amikos.chromadb.v2; + +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; +import java.util.*; + +import static org.junit.Assert.*; + +public class ChromaClientTest { + private static GenericContainer chromaContainer; + private ChromaClient client; + + @BeforeClass + public static void setupContainer() { + if (chromaContainer == null || !chromaContainer.isRunning()) { + String chromaVersion = System.getenv("CHROMA_VERSION"); + if (chromaVersion == null) { + chromaVersion = "1.1.0"; // Use version that supports v2 API + } + + chromaContainer = new GenericContainer<>(DockerImageName.parse("chromadb/chroma:" + chromaVersion)) + .withExposedPorts(8000) + .withEnv("ALLOW_RESET", "TRUE") + .withEnv("IS_PERSISTENT", "FALSE") // Use ephemeral mode for tests + .waitingFor(Wait.forHttp("/api/v2/heartbeat") + .forPort(8000) + .forStatusCode(200) + .withStartupTimeout(Duration.ofSeconds(30))); + + chromaContainer.start(); + } + } + + @Before + public void setup() { + String host = chromaContainer.getHost(); + Integer port = chromaContainer.getMappedPort(8000); + String endpoint = "http://" + host + ":" + port; + + client = ChromaClient.builder() + .serverUrl(endpoint) + .auth(AuthProvider.none()) + .connectTimeout(30) + .readTimeout(30) + .writeTimeout(30) + .tenant("default_tenant") + .database("default_database") + .build(); + + // Ensure database exists for v2 API + try { + client.createDatabase("default_database"); + } catch (Exception e) { + // Database might already exist, that's okay + } + } + + @After + public void cleanup() { + if (client != null) { + try { + List collections = client.listCollections(); + for (Collection collection : collections) { + if (collection.getName().startsWith("test_")) { + client.deleteCollection(collection.getId().toString()); + } + } + } catch (Exception e) { + // Ignore cleanup errors + } + } + } + + @Test + public void testHeartbeat() { + String heartbeat = client.heartbeat(); + assertNotNull(heartbeat); + // Heartbeat returns nanoseconds as a number (converted to string) + // Should be a large number (timestamp in nanoseconds) + assertTrue(heartbeat.length() > 0); + try { + Double.parseDouble(heartbeat); + } catch (NumberFormatException e) { + fail("Heartbeat should be a valid number"); + } + } + + @Test + public void testVersion() { + String version = client.version(); + assertNotNull(version); + assertFalse(version.isEmpty()); + } + + @Test + public void testCreateCollection() { + String collectionName = "test_collection_" + UUID.randomUUID().toString().substring(0, 8); + + Collection collection = client.createCollection(collectionName, + Map.of("test", "true", "created_at", System.currentTimeMillis())); + + assertNotNull(collection); + assertEquals(collectionName, collection.getName()); + assertNotNull(collection.getId()); + assertEquals("default_tenant", collection.getTenant()); + assertEquals("default_database", collection.getDatabase()); + } + + @Test + public void testGetOrCreateCollection() { + String collectionName = "test_collection_" + UUID.randomUUID().toString().substring(0, 8); + + Collection collection1 = client.getOrCreateCollection(collectionName); + assertNotNull(collection1); + assertEquals(collectionName, collection1.getName()); + + Collection collection2 = client.getOrCreateCollection(collectionName); + assertNotNull(collection2); + assertEquals(collection1.getId(), collection2.getId()); + } + + @Test + public void testAddDocuments() { + String collectionName = "test_add_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3")) + .documents(Arrays.asList( + "This is document one", + "This is document two", + "This is document three" + )) + .metadatas(Arrays.asList( + Map.of("source", "test", "page", 1), + Map.of("source", "test", "page", 2), + Map.of("source", "test", "page", 3) + )) + .execute(); + + assertEquals(3, collection.count()); + } + + @Test + public void testAddEmbeddings() { + String collectionName = "test_embeddings_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + List> embeddings = Arrays.asList( + Arrays.asList(0.1f, 0.2f, 0.3f), + Arrays.asList(0.4f, 0.5f, 0.6f) + ); + + collection.add() + .ids(Arrays.asList("id1", "id2")) + .embeddings(embeddings) + .execute(); + + assertEquals(2, collection.count()); + } + + @Test + public void testQuery() { + String collectionName = "test_query_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3")) + .embeddings(Arrays.asList( + Arrays.asList(0.1f, 0.2f, 0.3f), + Arrays.asList(0.4f, 0.5f, 0.6f), + Arrays.asList(0.7f, 0.8f, 0.9f) + )) + .documents(Arrays.asList( + "The weather is nice today", + "I love programming in Java", + "ChromaDB is a vector database" + )) + .execute(); + + QueryResponse result = collection.query() + .queryEmbeddings(Arrays.asList(Arrays.asList(0.15f, 0.25f, 0.35f))) + .nResults(2) + .include(Include.DOCUMENTS, Include.DISTANCES) + .execute(); + + assertNotNull(result); + assertNotNull(result.getIds()); + assertEquals(1, result.getIds().size()); + assertEquals(2, result.getIds().get(0).size()); + } + + @Test + public void testQueryWithFilter() { + String collectionName = "test_query_filter_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3")) + .documents(Arrays.asList( + "Document about cats", + "Document about dogs", + "Document about birds" + )) + .metadatas(Arrays.asList( + Map.of("animal", "cat", "type", "mammal"), + Map.of("animal", "dog", "type", "mammal"), + Map.of("animal", "bird", "type", "avian") + )) + .execute(); + + Where where = Where.eq("type", "mammal"); + + QueryResponse result = collection.query() + .queryEmbeddings(Arrays.asList(Arrays.asList(0.5f, 0.5f, 0.5f))) + .nResults(10) + .where(where) + .include(Include.METADATAS, Include.DOCUMENTS) + .execute(); + + assertNotNull(result); + assertEquals(1, result.getIds().size()); + assertEquals(2, result.getIds().get(0).size()); + } + + @Test + public void testGet() { + String collectionName = "test_get_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3")) + .documents(Arrays.asList( + "First document", + "Second document", + "Third document" + )) + .execute(); + + GetResponse result = collection.get() + .ids(Arrays.asList("id1", "id3")) + .include(Include.DOCUMENTS) + .execute(); + + assertNotNull(result); + assertEquals(2, result.getIds().size()); + assertTrue(result.getIds().contains("id1")); + assertTrue(result.getIds().contains("id3")); + assertNotNull(result.getDocuments()); + assertEquals(2, result.getDocuments().size()); + } + + @Test + public void testGetWithFilter() { + String collectionName = "test_get_filter_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3")) + .metadatas(Arrays.asList( + Map.of("category", "A"), + Map.of("category", "B"), + Map.of("category", "A") + )) + .execute(); + + Where where = Where.eq("category", "A"); + + GetResponse result = collection.get() + .where(where) + .include(Include.METADATAS) + .execute(); + + assertNotNull(result); + assertEquals(2, result.getIds().size()); + assertTrue(result.getIds().contains("id1")); + assertTrue(result.getIds().contains("id3")); + } + + @Test + public void testUpdate() { + String collectionName = "test_update_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2")) + .documents(Arrays.asList( + "Original document 1", + "Original document 2" + )) + .execute(); + + collection.update() + .ids(Arrays.asList("id1")) + .metadatas(Arrays.asList(Map.of("updated", true, "version", 2))) + .documents(Arrays.asList("Updated document 1")) + .execute(); + + GetResponse getResult = collection.get() + .ids(Arrays.asList("id1")) + .include(Include.DOCUMENTS, Include.METADATAS) + .execute(); + + assertEquals("Updated document 1", getResult.getDocuments().get(0)); + assertEquals(true, getResult.getMetadatas().get(0).get("updated")); + } + + @Test + public void testUpsert() { + String collectionName = "test_upsert_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1")) + .documents(Arrays.asList("Original doc")) + .execute(); + + collection.upsert() + .ids(Arrays.asList("id1", "id2")) + .documents(Arrays.asList("Document 1", "Document 2")) + .execute(); + + assertEquals(2, collection.count()); + + GetResponse getResult = collection.get() + .ids(Arrays.asList("id1", "id2")) + .include(Include.DOCUMENTS) + .execute(); + + assertEquals(2, getResult.getDocuments().size()); + } + + @Test + public void testDelete() { + String collectionName = "test_delete_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3", "id4")) + .execute(); + + assertEquals(4, collection.count()); + + collection.delete() + .ids(Arrays.asList("id1", "id3")) + .execute(); + + assertEquals(2, collection.count()); + + GetResponse getResult = collection.get() + .include(Include.METADATAS) + .execute(); + + assertEquals(2, getResult.getIds().size()); + assertTrue(getResult.getIds().contains("id2")); + assertTrue(getResult.getIds().contains("id4")); + } + + @Test + public void testDeleteWithFilter() { + String collectionName = "test_delete_filter_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3")) + .metadatas(Arrays.asList( + Map.of("delete", true), + Map.of("delete", false), + Map.of("delete", true) + )) + .execute(); + + Where where = Where.eq("delete", true); + + collection.delete() + .where(where) + .execute(); + + assertEquals(1, collection.count()); + + GetResponse getResult = collection.get() + .include(Include.METADATAS) + .execute(); + + assertEquals(1, getResult.getIds().size()); + assertEquals("id2", getResult.getIds().get(0)); + } + + @Test + public void testListCollections() { + String prefix = "test_list_" + UUID.randomUUID().toString().substring(0, 8); + + for (int i = 0; i < 3; i++) { + client.createCollection(prefix + "_" + i); + } + + List collections = client.listCollections(); + assertNotNull(collections); + assertTrue(collections.size() >= 3); + + int count = 0; + for (Collection col : collections) { + if (col.getName().startsWith(prefix)) { + count++; + } + } + assertEquals(3, count); + } + + @Test + public void testCountCollections() { + int initialCount = client.countCollections(); + + String collectionName = "test_count_" + UUID.randomUUID().toString().substring(0, 8); + client.createCollection(collectionName); + + int newCount = client.countCollections(); + assertEquals(initialCount + 1, newCount); + } + + @Test + public void testLargeDataset() { + String collectionName = "test_large_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + int batchSize = 100; + List ids = new ArrayList<>(); + List documents = new ArrayList<>(); + List> metadataList = new ArrayList<>(); + + for (int i = 0; i < batchSize; i++) { + ids.add("id_" + i); + documents.add("This is document number " + i + " with some content about topic " + (i % 10)); + metadataList.add(Map.of("batch", i / 10, "topic", i % 10)); + } + + collection.add() + .ids(ids) + .documents(documents) + .metadatas(metadataList) + .execute(); + + assertEquals(batchSize, collection.count()); + + QueryResponse queryResult = collection.query() + .queryEmbeddings(Arrays.asList(Arrays.asList(0.5f, 0.5f, 0.5f))) + .nResults(10) + .include(Include.METADATAS, Include.DISTANCES) + .execute(); + + assertNotNull(queryResult); + assertEquals(1, queryResult.getIds().size()); + assertEquals(10, queryResult.getIds().get(0).size()); + + Where where = Where.eq("batch", 5); + + GetResponse batchResult = collection.get() + .where(where) + .include(Include.METADATAS) + .execute(); + + assertEquals(10, batchResult.getIds().size()); + } + + @Test + public void testComplexWhereFilters() { + String collectionName = "test_complex_filter_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + collection.add() + .ids(Arrays.asList("id1", "id2", "id3", "id4")) + .metadatas(Arrays.asList( + Map.of("age", 25, "city", "New York"), + Map.of("age", 30, "city", "San Francisco"), + Map.of("age", 35, "city", "New York"), + Map.of("age", 28, "city", "Boston") + )) + .execute(); + + Where ageFilter = Where.gte("age", 30); + + GetResponse result = collection.get() + .where(ageFilter) + .include(Include.METADATAS) + .execute(); + + assertEquals(2, result.getIds().size()); + + Where complexFilter = Where.and( + Where.eq("city", "New York"), + Where.gt("age", 30) + ); + + GetResponse complexResult = collection.get() + .where(complexFilter) + .include(Include.METADATAS) + .execute(); + + assertEquals(1, complexResult.getIds().size()); + assertEquals("id3", complexResult.getIds().get(0)); + } + + @Test + public void testFluentBuilders() { + String collectionName = "test_fluent_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + // Test fluent add - single approach, no consumer pattern + collection.add() + .ids(Arrays.asList("id1", "id2")) + .documents(Arrays.asList("Doc 1", "Doc 2")) + .metadatas(Arrays.asList( + Map.of("type", "A"), + Map.of("type", "B") + )) + .execute(); + + assertEquals(2, collection.count()); + + // Test fluent query - single approach + QueryResponse queryResult = collection.query() + .nResults(1) + .include(Include.DOCUMENTS, Include.DISTANCES) + .execute(); + + assertNotNull(queryResult); + assertEquals(1, queryResult.getIds().get(0).size()); + + // Test fluent get - single approach + GetResponse getResult = collection.get() + .where(Where.eq("type", "A")) + .include(Include.METADATAS) + .execute(); + + assertEquals(1, getResult.getIds().size()); + + // Test fluent update - single approach + collection.update() + .ids(Arrays.asList("id1")) + .documents(Arrays.asList("Updated Doc 1")) + .execute(); + + // Test fluent delete - single approach + collection.delete() + .ids(Arrays.asList("id2")) + .execute(); + + assertEquals(1, collection.count()); + } + + @Test + public void testStronglyTypedMetadata() { + String collectionName = "test_metadata_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + // Create metadata using builder + Metadata metadata1 = Metadata.builder() + .putString("title", "Document 1") + .putInt("version", 1) + .putBoolean("published", true) + .putList("tags", Arrays.asList("tech", "ai")) + .build(); + + Metadata metadata2 = Metadata.builder() + .putString("title", "Document 2") + .putInt("version", 2) + .putBoolean("published", false) + .putDouble("score", 95.5) + .build(); + + collection.add() + .ids(Arrays.asList("id1", "id2")) + .documents(Arrays.asList("Doc 1 content", "Doc 2 content")) + .metadatas(Arrays.asList(metadata1.toMap(), metadata2.toMap())) + .execute(); + + GetResponse result = collection.get() + .ids(Arrays.asList("id1", "id2")) + .include(Include.METADATAS) + .execute(); + + // Verify metadata retrieval + Map retrievedMeta1 = result.getMetadatas().get(0); + assertEquals("Document 1", retrievedMeta1.get("title")); + assertEquals(1, retrievedMeta1.get("version")); + assertEquals(true, retrievedMeta1.get("published")); + + Map retrievedMeta2 = result.getMetadatas().get(1); + assertEquals("Document 2", retrievedMeta2.get("title")); + assertEquals(2, retrievedMeta2.get("version")); + assertEquals(false, retrievedMeta2.get("published")); + assertEquals(95.5, retrievedMeta2.get("score")); + } +} \ No newline at end of file diff --git a/src/test/java/tech/amikos/chromadb/v2/SimplifiedAPITest.java b/src/test/java/tech/amikos/chromadb/v2/SimplifiedAPITest.java new file mode 100644 index 0000000..6df7a0e --- /dev/null +++ b/src/test/java/tech/amikos/chromadb/v2/SimplifiedAPITest.java @@ -0,0 +1,325 @@ +package tech.amikos.chromadb.v2; + +import org.junit.Test; +import java.util.*; + +import static org.junit.Assert.*; + +/** + * Unit tests to verify the simplified V2 API structure. + * These tests verify the API design without requiring a running ChromaDB instance. + */ +public class SimplifiedAPITest { + + @Test + public void testChromaClientBuilder() { + // Test that ChromaClient builder works correctly + ChromaClient client = ChromaClient.builder() + .serverUrl("http://localhost:8000") + .auth(AuthProvider.none()) + .tenant("test-tenant") + .database("test-db") + .connectTimeout(30) + .readTimeout(30) + .writeTimeout(30) + .build(); + + assertNotNull(client); + } + + @Test + public void testCloudModeBuilder() { + // Test cloud mode syntactic sugar + ChromaClient client = ChromaClient.builder() + .cloudUrl("https://api.trychroma.com") + .apiKey("test-api-key") + .tenant("my-tenant") + .database("my-database") + .build(); + + assertNotNull(client); + } + + @Test + public void testAuthProviderFactoryMethods() { + // Test all auth provider factory methods + AuthProvider none = AuthProvider.none(); + assertNotNull(none); + assertTrue(none instanceof NoAuthProvider); + + AuthProvider token = AuthProvider.token("test-token"); + assertNotNull(token); + assertTrue(token instanceof TokenAuthProvider); + + AuthProvider basic = AuthProvider.basic("user", "pass"); + assertNotNull(basic); + assertTrue(basic instanceof BasicAuthProvider); + + AuthProvider chromaToken = AuthProvider.chromaToken("chroma-token"); + assertNotNull(chromaToken); + assertTrue(chromaToken instanceof ChromaTokenAuthProvider); + } + + @Test + public void testMetadataBuilder() { + // Test strongly typed metadata + Metadata metadata = Metadata.builder() + .putString("title", "Test Document") + .putInt("version", 1) + .putLong("timestamp", System.currentTimeMillis()) + .putDouble("score", 95.5) + .putBoolean("published", true) + .putList("tags", Arrays.asList("test", "example")) + .build(); + + assertNotNull(metadata); + assertEquals("Test Document", metadata.getString("title")); + assertEquals(Integer.valueOf(1), metadata.getInt("version")); + assertEquals(Double.valueOf(95.5), metadata.getDouble("score")); + assertEquals(Boolean.TRUE, metadata.getBoolean("published")); + assertNotNull(metadata.getStringList("tags")); + assertEquals(2, metadata.getStringList("tags").size()); + } + + @Test + public void testMetadataImmutability() { + // Test that metadata is immutable + Metadata original = Metadata.builder() + .putString("key", "value") + .build(); + + Metadata modified = original.with("newKey", "newValue"); + + assertNotEquals(original, modified); + assertNull(original.getString("newKey")); + assertEquals("newValue", modified.getString("newKey")); + } + + @Test + public void testWhereFilters() { + // Test Where filter creation + Where eqFilter = Where.eq("field", "value"); + assertNotNull(eqFilter); + + Where gtFilter = Where.gt("age", 30); + assertNotNull(gtFilter); + + Where andFilter = Where.and( + Where.eq("city", "NYC"), + Where.gte("age", 25) + ); + assertNotNull(andFilter); + + Where orFilter = Where.or( + Where.eq("status", "active"), + Where.eq("status", "pending") + ); + assertNotNull(orFilter); + } + + @Test + public void testIncludeEnum() { + // Test Include enum exists and has expected values + Include[] values = Include.values(); + assertTrue(values.length > 0); + + // Common include types should exist + assertNotNull(Include.valueOf("DOCUMENTS")); + assertNotNull(Include.valueOf("METADATAS")); + assertNotNull(Include.valueOf("DISTANCES")); + } + + @Test + public void testCreateCollectionRequestBuilder() { + // Test that CreateCollectionRequest builder is public and works + CreateCollectionRequest request = new CreateCollectionRequest.Builder("test-collection") + .metadata(Map.of("key", "value")) + .build(); + + assertNotNull(request); + } + + @Test + public void testFlatPackageStructure() { + // Verify all main classes are in the flat v2 package + Class[] coreClasses = { + ChromaClient.class, + Collection.class, + Metadata.class, + AuthProvider.class, + NoAuthProvider.class, + TokenAuthProvider.class, + BasicAuthProvider.class, + ChromaTokenAuthProvider.class, + Where.class, + WhereDocument.class, + Include.class, + QueryResponse.class, + GetResponse.class, + ChromaV2Exception.class, + ChromaNotFoundException.class, + ChromaBadRequestException.class, + ChromaUnauthorizedException.class, + ChromaServerException.class + }; + + for (Class clazz : coreClasses) { + String packageName = clazz.getPackage().getName(); + assertEquals("All classes should be in flat v2 package", + "tech.amikos.chromadb.v2", packageName); + } + } + + @Test + public void testNoConsumerPatterns() { + // Verify Collection class doesn't have Consumer methods + // This test verifies at compile time that the old patterns are gone + + // The following would not compile if Consumer patterns existed: + // collection.query(builder -> builder.nResults(10)); // This pattern is removed + + // Only the fluent builder pattern should work: + // collection.query().nResults(10).execute(); // This is the only way + + // Test passes if compilation succeeds + assertTrue("Code compiles without Consumer patterns", true); + } + + @Test + public void testConvenienceMethodsExist() { + // Verify that convenience methods exist on Collection class + // This test ensures the API surface matches Chroma's simplicity + + // This test passes if the code compiles, proving the methods exist + Class collectionClass = Collection.class; + assertNotNull(collectionClass); + + // Verify method signatures exist (compile-time check) + try { + // add(List, List) + collectionClass.getDeclaredMethod("add", List.class, List.class); + + // add(List, List, List) + collectionClass.getDeclaredMethod("add", List.class, List.class, List.class); + + // add(List, List, List, List) + collectionClass.getDeclaredMethod("add", List.class, List.class, List.class, List.class); + + // query(List, int) + collectionClass.getDeclaredMethod("query", List.class, int.class); + + // query(List, int, Where) + collectionClass.getDeclaredMethod("query", List.class, int.class, Where.class); + + // queryByText(List, int) + collectionClass.getDeclaredMethod("queryByText", List.class, int.class); + + // get(List) + collectionClass.getDeclaredMethod("get", List.class); + + // delete(List) + collectionClass.getDeclaredMethod("delete", List.class); + + // delete(Where) + collectionClass.getDeclaredMethod("delete", Where.class); + + // upsert(List, List) + collectionClass.getDeclaredMethod("upsert", List.class, List.class); + + assertTrue("All convenience methods exist", true); + } catch (NoSuchMethodException e) { + fail("Convenience method missing: " + e.getMessage()); + } + } + + @Test + public void testBuilderMethodsStillExist() { + // Verify that builder methods still exist for complex cases + Class collectionClass = Collection.class; + + try { + // Builder methods should return builder instances + collectionClass.getDeclaredMethod("query"); + collectionClass.getDeclaredMethod("get"); + collectionClass.getDeclaredMethod("add"); + collectionClass.getDeclaredMethod("update"); + collectionClass.getDeclaredMethod("upsert"); + collectionClass.getDeclaredMethod("delete"); + + assertTrue("All builder methods exist", true); + } catch (NoSuchMethodException e) { + fail("Builder method missing: " + e.getMessage()); + } + } + + @Test + public void testQueryRequestSupportsQueryTexts() { + // Verify QueryRequest supports query_texts for Chroma API alignment + QueryRequest request = QueryRequest.builder() + .queryTexts(Arrays.asList("search text")) + .nResults(10) + .build(); + + assertNotNull(request); + } + + @Test + public void testQueryRequestValidation() { + // Verify QueryRequest validates that either embeddings or texts are provided + try { + QueryRequest.builder() + .nResults(10) + .build(); + fail("Should require either queryEmbeddings or queryTexts"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("queryEmbeddings or queryTexts")); + } + } + + @Test + public void testQueryRequestMutualExclusivity() { + // Verify QueryRequest doesn't allow both embeddings and texts + try { + QueryRequest.builder() + .queryEmbeddings(Arrays.asList(Arrays.asList(0.1f, 0.2f))) + .queryTexts(Arrays.asList("text")) + .nResults(10) + .build(); + fail("Should not allow both queryEmbeddings and queryTexts"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("Cannot provide both")); + } + } + + @Test + public void testAddRecordsRequestBuilder() { + // Test AddRecordsRequest builder works correctly + AddRecordsRequest request = AddRecordsRequest.builder() + .ids(Arrays.asList("id1", "id2")) + .embeddings(Arrays.asList( + Arrays.asList(0.1f, 0.2f), + Arrays.asList(0.3f, 0.4f) + )) + .documents(Arrays.asList("doc1", "doc2")) + .metadatas(Arrays.asList( + Map.of("key", "value1"), + Map.of("key", "value2") + )) + .build(); + + assertNotNull(request); + } + + @Test + public void testDualAPIApproach() { + // Verify the dual API approach (convenience + builders) is available + // This is a design validation test + + // Both approaches should be valid at compile time: + // 1. Convenience: collection.query(embeddings, 10) + // 2. Builder: collection.query().queryEmbeddings(embeddings).nResults(10).execute() + + // If this compiles, the dual approach is working + assertTrue("Dual API approach compiles successfully", true); + } +} \ No newline at end of file diff --git a/src/test/java/tech/amikos/chromadb/v2/V2StressTest.java b/src/test/java/tech/amikos/chromadb/v2/V2StressTest.java new file mode 100644 index 0000000..647b918 --- /dev/null +++ b/src/test/java/tech/amikos/chromadb/v2/V2StressTest.java @@ -0,0 +1,245 @@ +package tech.amikos.chromadb.v2; + +import org.junit.BeforeClass; +import org.junit.Test; +import tech.amikos.chromadb.v2.AuthProvider; +import tech.amikos.chromadb.v2.Collection; +import tech.amikos.chromadb.v2.ChromaClient; +import tech.amikos.chromadb.v2.*; + +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.Assert.*; + +public class V2StressTest { + private static ChromaClient client; + + @BeforeClass + public static void setup() { + String chromaUrl = System.getenv("CHROMA_URL"); + if (chromaUrl == null) { + chromaUrl = "http://localhost:8000"; + } + + client = ChromaClient.builder() + .serverUrl(chromaUrl) + .auth(AuthProvider.none()) + .connectTimeout(60) + .readTimeout(60) + .writeTimeout(60) + .tenant("default_tenant") + .database("default_database") + .build(); + } + + @Test + public void testLargeScale() throws Exception { + String collectionName = "stress_test_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + // Add 10,000 records in batches of 100 + for (int batch = 0; batch < 100; batch++) { + List ids = new ArrayList<>(); + List> embeddings = new ArrayList<>(); + List> metadatas = new ArrayList<>(); + + for (int i = 0; i < 100; i++) { + int recordId = batch * 100 + i; + ids.add("id_" + recordId); + + // Create random embedding + List embedding = new ArrayList<>(); + Random rand = new Random(recordId); + for (int j = 0; j < 384; j++) { + embedding.add(rand.nextFloat()); + } + embeddings.add(embedding); + + metadatas.add(Map.of( + "batch", batch, + "index", i, + "category", "category_" + (recordId % 10) + )); + } + + collection.add() + .ids(ids) + .embeddings(embeddings) + .metadatas(metadatas) + .execute(); + + if (batch % 10 == 0) { + System.out.println("Added " + ((batch + 1) * 100) + " records"); + } + } + + assertEquals(10000, collection.count()); + System.out.println("Successfully added 10,000 records"); + + // Test queries + Random rand = new Random(); + List queryEmbedding = IntStream.range(0, 384) + .mapToObj(i -> rand.nextFloat()) + .collect(Collectors.toList()); + + QueryResponse result = collection.query() + .queryEmbeddings(Arrays.asList(queryEmbedding)) + .nResults(100) + .include(Include.METADATAS, Include.DISTANCES) + .execute(); + + assertEquals(1, result.getIds().size()); + assertEquals(100, result.getIds().get(0).size()); + + client.deleteCollection(collectionName); + } + + @Test + public void testConcurrentOperations() throws Exception { + String collectionName = "concurrent_test_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + ExecutorService executor = Executors.newFixedThreadPool(10); + List> futures = new ArrayList<>(); + + // Submit 100 concurrent operations + for (int i = 0; i < 100; i++) { + final int taskId = i; + futures.add(executor.submit(() -> { + try { + String id = "concurrent_" + taskId; + List embedding = IntStream.range(0, 384) + .mapToObj(j -> (float) (taskId * 0.01)) + .collect(Collectors.toList()); + + collection.add() + .ids(Arrays.asList(id)) + .embeddings(Arrays.asList(embedding)) + .execute(); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + })); + } + + // Wait for all operations to complete + for (Future future : futures) { + assertTrue(future.get(30, TimeUnit.SECONDS)); + } + + executor.shutdown(); + executor.awaitTermination(1, TimeUnit.MINUTES); + + assertEquals(100, collection.count()); + client.deleteCollection(collectionName); + } + + @Test + public void testMemoryEfficiency() throws Exception { + String collectionName = "memory_test_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + Runtime runtime = Runtime.getRuntime(); + long initialMemory = runtime.totalMemory() - runtime.freeMemory(); + + // Add records in a memory-efficient way + int totalRecords = 5000; + int batchSize = 50; + + for (int batch = 0; batch < totalRecords / batchSize; batch++) { + List ids = new ArrayList<>(); + List> embeddings = new ArrayList<>(); + + for (int i = 0; i < batchSize; i++) { + int recordId = batch * batchSize + i; + ids.add("mem_" + recordId); + + // Create embedding + List embedding = new ArrayList<>(); + for (int j = 0; j < 384; j++) { + embedding.add((float) Math.random()); + } + embeddings.add(embedding); + } + + collection.add() + .ids(ids) + .embeddings(embeddings) + .execute(); + + // Clear local references + ids = null; + embeddings = null; + } + + long finalMemory = runtime.totalMemory() - runtime.freeMemory(); + long memoryUsed = (finalMemory - initialMemory) / (1024 * 1024); // MB + + System.out.println("Memory used: " + memoryUsed + " MB for " + totalRecords + " records"); + assertTrue("Memory usage should be reasonable", memoryUsed < 500); // Less than 500MB + + assertEquals(totalRecords, collection.count()); + client.deleteCollection(collectionName); + } + + @Test + public void testQueryPerformance() throws Exception { + String collectionName = "query_perf_" + UUID.randomUUID().toString().substring(0, 8); + Collection collection = client.createCollection(collectionName); + + // Add test data + List ids = new ArrayList<>(); + List> embeddings = new ArrayList<>(); + List> metadatas = new ArrayList<>(); + + for (int i = 0; i < 1000; i++) { + ids.add("perf_" + i); + + List embedding = new ArrayList<>(); + for (int j = 0; j < 384; j++) { + embedding.add((float) Math.random()); + } + embeddings.add(embedding); + + metadatas.add(Map.of( + "type", i % 2 == 0 ? "even" : "odd", + "value", i + )); + } + + collection.add() + .ids(ids) + .embeddings(embeddings) + .metadatas(metadatas) + .execute(); + + // Measure query performance + List queryEmbedding = IntStream.range(0, 384) + .mapToObj(i -> (float) Math.random()) + .collect(Collectors.toList()); + + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < 100; i++) { + QueryResponse result = collection.query() + .queryEmbeddings(Arrays.asList(queryEmbedding)) + .nResults(10) + .where(Where.eq("type", "even")) + .execute(); + assertNotNull(result); + } + + long duration = System.currentTimeMillis() - startTime; + double avgQueryTime = duration / 100.0; + + System.out.println("Average query time: " + avgQueryTime + " ms"); + assertTrue("Queries should be fast", avgQueryTime < 100); // Less than 100ms average + + client.deleteCollection(collectionName); + } +} \ No newline at end of file