mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-05-02 03:00:36 -04:00
feat(tools/firestore): Add firestore-query tool (#1305)
## Description --- This PR introduces a new tool kind `firestore-query` that enables parameterized querying of Firestore collections with support for Firestore native JSON value types, ensuring proper type handling for complex queries. ### Feature A new Firestore tool that allows: - __Parameterized collection paths, filters, select, orderBy, limit and analyzeQuery__ using Go template syntax - __Native JSON value type support__ for proper type handling in queries - __Complex filter structures__ with AND/OR logical operators - __Dynamic query building__ with template parameter substitution Example usage: <img width="761" height="721" alt="Screenshot 2025-09-09 at 1 21 16 PM" src="https://github.com/user-attachments/assets/bb359ea8-f750-492d-9f13-cef8f3b6bfd1" /> ## PR Checklist --- > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [x] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [x] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/langchain-google-alloydb-pg-python/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [x] Ensure the tests and linter pass - [x] Code coverage does not decrease (if any source code was changed) - [x] Appropriate docs were updated (if necessary) - [x] Make sure to add `!` if this involve a breaking change
This commit is contained in:
@@ -67,6 +67,7 @@ import (
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoregetrules"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorelistcollections"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequerycollection"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequery"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestoreupdatedocument"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorevalidaterules"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/http"
|
||||
|
||||
412
docs/en/resources/tools/firestore/firestore-query.md
Normal file
412
docs/en/resources/tools/firestore/firestore-query.md
Normal file
@@ -0,0 +1,412 @@
|
||||
---
|
||||
title: "firestore-query"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
Query a Firestore collection with parameterizable filters and Firestore native JSON value types
|
||||
aliases:
|
||||
- /resources/tools/firestore-query
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The `firestore-query` tool allows you to query Firestore collections with dynamic, parameterizable filters that support Firestore's native JSON value types. This tool is designed for querying single collection, which is the standard pattern in Firestore. The collection path itself can be parameterized, making it flexible for various use cases. This tool is particularly useful when you need to create reusable query templates with parameters that can be substituted at runtime.
|
||||
|
||||
**Developer Note**: This tool serves as the general querying foundation that developers can use to create custom tools with specific query patterns.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Parameterizable Queries**: Use Go template syntax to create dynamic queries
|
||||
- **Dynamic Collection Paths**: The collection path can be parameterized for flexibility
|
||||
- **Native JSON Value Types**: Support for Firestore's typed values (stringValue, integerValue, doubleValue, etc.)
|
||||
- **Complex Filter Logic**: Support for AND/OR logical operators in filters
|
||||
- **Template Substitution**: Dynamic collection paths, filters, and ordering
|
||||
- **Query Analysis**: Optional query performance analysis with explain metrics (non-parameterizable)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
query_countries:
|
||||
kind: firestore-query
|
||||
source: my-firestore-source
|
||||
description: Query countries with dynamic filters
|
||||
collectionPath: "countries"
|
||||
filters: |
|
||||
{
|
||||
"field": "continent",
|
||||
"op": "==",
|
||||
"value": {"stringValue": "{{.continent}}"}
|
||||
}
|
||||
parameters:
|
||||
- name: continent
|
||||
type: string
|
||||
description: Continent to filter by
|
||||
required: true
|
||||
```
|
||||
|
||||
### Advanced Configuration with Complex Filters
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
advanced_query:
|
||||
kind: firestore-query
|
||||
source: my-firestore-source
|
||||
description: Advanced query with complex filters
|
||||
collectionPath: "{{.collection}}"
|
||||
filters: |
|
||||
{
|
||||
"or": [
|
||||
{"field": "status", "op": "==", "value": {"stringValue": "{{.status}}"}},
|
||||
{
|
||||
"and": [
|
||||
{"field": "priority", "op": ">", "value": {"integerValue": "{{.priority}}"}},
|
||||
{"field": "area", "op": "<", "value": {"doubleValue": {{.maxArea}}}},
|
||||
{"field": "active", "op": "==", "value": {"booleanValue": {{.isActive}}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
select:
|
||||
- name
|
||||
- status
|
||||
- priority
|
||||
orderBy:
|
||||
field: "{{.sortField}}"
|
||||
direction: "{{.sortDirection}}"
|
||||
limit: 100
|
||||
analyzeQuery: true
|
||||
parameters:
|
||||
- name: collection
|
||||
type: string
|
||||
description: Collection to query
|
||||
required: true
|
||||
- name: status
|
||||
type: string
|
||||
description: Status to filter by
|
||||
required: true
|
||||
- name: priority
|
||||
type: string
|
||||
description: Minimum priority value
|
||||
required: true
|
||||
- name: maxArea
|
||||
type: float
|
||||
description: Maximum area value
|
||||
required: true
|
||||
- name: isActive
|
||||
type: boolean
|
||||
description: Filter by active status
|
||||
required: true
|
||||
- name: sortField
|
||||
type: string
|
||||
description: Field to sort by
|
||||
required: false
|
||||
default: "createdAt"
|
||||
- name: sortDirection
|
||||
type: string
|
||||
description: Sort direction (ASCENDING or DESCENDING)
|
||||
required: false
|
||||
default: "DESCENDING"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
### Configuration Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `kind` | string | Yes | Must be `firestore-query` |
|
||||
| `source` | string | Yes | Name of the Firestore source to use |
|
||||
| `description` | string | Yes | Description of what this tool does |
|
||||
| `collectionPath` | string | Yes | Path to the collection to query (supports templates) |
|
||||
| `filters` | string | No | JSON string defining query filters (supports templates) |
|
||||
| `select` | array | No | Fields to select from documents(supports templates - string or array) |
|
||||
| `orderBy` | object | No | Ordering configuration with `field` and `direction`(supports templates for the value of field or direction) |
|
||||
| `limit` | integer | No | Maximum number of documents to return (default: 100) (supports templates) |
|
||||
| `analyzeQuery` | boolean | No | Whether to analyze query performance (default: false) |
|
||||
| `parameters` | array | Yes | Parameter definitions for template substitution |
|
||||
|
||||
### Runtime Parameters
|
||||
|
||||
Runtime parameters are defined in the `parameters` array and can be used in templates throughout the configuration.
|
||||
|
||||
## Filter Format
|
||||
|
||||
### Simple Filter
|
||||
```json
|
||||
{
|
||||
"field": "age",
|
||||
"op": ">",
|
||||
"value": {"integerValue": "25"}
|
||||
}
|
||||
```
|
||||
|
||||
### AND Filter
|
||||
```json
|
||||
{
|
||||
"and": [
|
||||
{"field": "status", "op": "==", "value": {"stringValue": "active"}},
|
||||
{"field": "age", "op": ">=", "value": {"integerValue": "18"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### OR Filter
|
||||
```json
|
||||
{
|
||||
"or": [
|
||||
{"field": "role", "op": "==", "value": {"stringValue": "admin"}},
|
||||
{"field": "role", "op": "==", "value": {"stringValue": "moderator"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Nested Filters
|
||||
```json
|
||||
{
|
||||
"or": [
|
||||
{"field": "type", "op": "==", "value": {"stringValue": "premium"}},
|
||||
{
|
||||
"and": [
|
||||
{"field": "type", "op": "==", "value": {"stringValue": "standard"}},
|
||||
{"field": "credits", "op": ">", "value": {"integerValue": "1000"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Firestore Native Value Types
|
||||
|
||||
The tool supports all Firestore native JSON value types:
|
||||
|
||||
| Type | Format | Example |
|
||||
|------|--------|---------|
|
||||
| String | `{"stringValue": "text"}` | `{"stringValue": "{{.name}}"}` |
|
||||
| Integer | `{"integerValue": "123"}` or `{"integerValue": 123}` | `{"integerValue": "{{.age}}"}` or `{"integerValue": {{.age}}}` |
|
||||
| Double | `{"doubleValue": 45.67}` | `{"doubleValue": {{.price}}}` |
|
||||
| Boolean | `{"booleanValue": true}` | `{"booleanValue": {{.active}}}` |
|
||||
| Null | `{"nullValue": null}` | `{"nullValue": null}` |
|
||||
| Timestamp | `{"timestampValue": "RFC3339"}` | `{"timestampValue": "{{.date}}"}` |
|
||||
| GeoPoint | `{"geoPointValue": {"latitude": 0, "longitude": 0}}` | See below |
|
||||
| Array | `{"arrayValue": {"values": [...]}}` | See below |
|
||||
| Map | `{"mapValue": {"fields": {...}}}` | See below |
|
||||
|
||||
### Complex Type Examples
|
||||
|
||||
**GeoPoint:**
|
||||
```json
|
||||
{
|
||||
"field": "location",
|
||||
"op": "==",
|
||||
"value": {
|
||||
"geoPointValue": {
|
||||
"latitude": 37.7749,
|
||||
"longitude": -122.4194
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Array:**
|
||||
```json
|
||||
{
|
||||
"field": "tags",
|
||||
"op": "array-contains",
|
||||
"value": {"stringValue": "{{.tag}}"}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Operators
|
||||
|
||||
- `<` - Less than
|
||||
- `<=` - Less than or equal
|
||||
- `>` - Greater than
|
||||
- `>=` - Greater than or equal
|
||||
- `==` - Equal
|
||||
- `!=` - Not equal
|
||||
- `array-contains` - Array contains value
|
||||
- `array-contains-any` - Array contains any of the values
|
||||
- `in` - Value is in array
|
||||
- `not-in` - Value is not in array
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Query with Dynamic Collection Path
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
user_documents:
|
||||
kind: firestore-query
|
||||
source: my-firestore
|
||||
description: Query user-specific documents
|
||||
collectionPath: "users/{{.userId}}/documents"
|
||||
filters: |
|
||||
{
|
||||
"field": "type",
|
||||
"op": "==",
|
||||
"value": {"stringValue": "{{.docType}}"}
|
||||
}
|
||||
parameters:
|
||||
- name: userId
|
||||
type: string
|
||||
description: User ID
|
||||
required: true
|
||||
- name: docType
|
||||
type: string
|
||||
description: Document type to filter
|
||||
required: true
|
||||
```
|
||||
|
||||
### Example 2: Complex Geographic Query
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
location_search:
|
||||
kind: firestore-query
|
||||
source: my-firestore
|
||||
description: Search locations by area and population
|
||||
collectionPath: "cities"
|
||||
filters: |
|
||||
{
|
||||
"and": [
|
||||
{"field": "country", "op": "==", "value": {"stringValue": "{{.country}}"}},
|
||||
{"field": "population", "op": ">", "value": {"integerValue": "{{.minPopulation}}"}},
|
||||
{"field": "area", "op": "<", "value": {"doubleValue": {{.maxArea}}}}
|
||||
]
|
||||
}
|
||||
orderBy:
|
||||
field: "population"
|
||||
direction: "DESCENDING"
|
||||
limit: 50
|
||||
parameters:
|
||||
- name: country
|
||||
type: string
|
||||
description: Country code
|
||||
required: true
|
||||
- name: minPopulation
|
||||
type: string
|
||||
description: Minimum population (as string for large numbers)
|
||||
required: true
|
||||
- name: maxArea
|
||||
type: float
|
||||
description: Maximum area in square kilometers
|
||||
required: true
|
||||
```
|
||||
|
||||
### Example 3: Time-based Query with Analysis
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
activity_log:
|
||||
kind: firestore-query
|
||||
source: my-firestore
|
||||
description: Query activity logs within time range
|
||||
collectionPath: "logs"
|
||||
filters: |
|
||||
{
|
||||
"and": [
|
||||
{"field": "timestamp", "op": ">=", "value": {"timestampValue": "{{.startTime}}"}},
|
||||
{"field": "timestamp", "op": "<=", "value": {"timestampValue": "{{.endTime}}"}},
|
||||
{"field": "severity", "op": "in", "value": {"arrayValue": {"values": [
|
||||
{"stringValue": "ERROR"},
|
||||
{"stringValue": "CRITICAL"}
|
||||
]}}}
|
||||
]
|
||||
}
|
||||
select:
|
||||
- timestamp
|
||||
- message
|
||||
- severity
|
||||
- userId
|
||||
orderBy:
|
||||
field: "timestamp"
|
||||
direction: "DESCENDING"
|
||||
analyzeQuery: true
|
||||
parameters:
|
||||
- name: startTime
|
||||
type: string
|
||||
description: Start time in RFC3339 format
|
||||
required: true
|
||||
- name: endTime
|
||||
type: string
|
||||
description: End time in RFC3339 format
|
||||
required: true
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Invoking the Tool
|
||||
|
||||
```bash
|
||||
# Using curl
|
||||
curl -X POST http://localhost:5000/api/tool/your-tool-name/invoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"continent": "Europe",
|
||||
"minPopulation": "1000000",
|
||||
"maxArea": 500000.5,
|
||||
"isActive": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
**Without analyzeQuery:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "doc1",
|
||||
"path": "countries/doc1",
|
||||
"data": {
|
||||
"name": "France",
|
||||
"continent": "Europe",
|
||||
"population": 67000000,
|
||||
"area": 551695
|
||||
},
|
||||
"createTime": "2024-01-01T00:00:00Z",
|
||||
"updateTime": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**With analyzeQuery:**
|
||||
```json
|
||||
{
|
||||
"documents": [...],
|
||||
"explainMetrics": {
|
||||
"planSummary": {
|
||||
"indexesUsed": [...]
|
||||
},
|
||||
"executionStats": {
|
||||
"resultsReturned": 10,
|
||||
"executionDuration": "15ms",
|
||||
"readOperations": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Typed Values**: Always use Firestore's native JSON value types for proper type handling
|
||||
2. **String Numbers for Large Integers**: Use string representation for large integers to avoid precision loss
|
||||
3. **Template Security**: Validate all template parameters to prevent injection attacks
|
||||
4. **Index Optimization**: Use `analyzeQuery` to identify missing indexes
|
||||
5. **Limit Results**: Always set a reasonable `limit` to prevent excessive data retrieval
|
||||
6. **Field Selection**: Use `select` to retrieve only necessary fields
|
||||
|
||||
## Technical Notes
|
||||
|
||||
- Queries operate on a single collection (the standard Firestore pattern)
|
||||
- Maximum of 100 filters per query (configurable)
|
||||
- Template parameters must be properly escaped in JSON contexts
|
||||
- Complex nested queries may require composite indexes
|
||||
|
||||
## See Also
|
||||
|
||||
- [firestore-query-collection](firestore-query-collection.md) - Non-parameterizable query tool
|
||||
- [Firestore Source Configuration](../../sources/firestore.md)
|
||||
- [Firestore Query Documentation](https://firebase.google.com/docs/firestore/query-data/queries)
|
||||
@@ -87,18 +87,30 @@ func convertParamToJSON(param any) (string, error) {
|
||||
|
||||
// PopulateTemplateWithJSON populate a Go template with a custom `json` array formatter
|
||||
func PopulateTemplateWithJSON(templateName, templateString string, data map[string]any) (string, error) {
|
||||
funcMap := template.FuncMap{
|
||||
return PopulateTemplateWithFunc(templateName, templateString, data, template.FuncMap{
|
||||
"json": convertParamToJSON,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
tmpl, err := template.New(templateName).Funcs(funcMap).Parse(templateString)
|
||||
// PopulateTemplate populate a Go template with no custom formatters
|
||||
func PopulateTemplate(templateName, templateString string, data map[string]any) (string, error) {
|
||||
return PopulateTemplateWithFunc(templateName, templateString, data, nil)
|
||||
}
|
||||
|
||||
// PopulateTemplateWithFunc populate a Go template with provided functions
|
||||
func PopulateTemplateWithFunc(templateName, templateString string, data map[string]any, funcMap template.FuncMap) (string, error) {
|
||||
tmpl := template.New(templateName)
|
||||
if funcMap != nil {
|
||||
tmpl = tmpl.Funcs(funcMap)
|
||||
}
|
||||
|
||||
parsedTmpl, err := tmpl.Parse(templateString)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error parsing template '%s': %w", templateName, err)
|
||||
}
|
||||
|
||||
var result bytes.Buffer
|
||||
err = tmpl.Execute(&result, data)
|
||||
if err != nil {
|
||||
if err := parsedTmpl.Execute(&result, data); err != nil {
|
||||
return "", fmt.Errorf("error executing template '%s': %w", templateName, err)
|
||||
}
|
||||
return result.String(), nil
|
||||
|
||||
297
internal/tools/common_test.go
Normal file
297
internal/tools/common_test.go
Normal file
@@ -0,0 +1,297 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
)
|
||||
|
||||
func TestPopulateTemplate(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
templateName string
|
||||
templateString string
|
||||
data map[string]any
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple string substitution",
|
||||
templateName: "test",
|
||||
templateString: "Hello {{.name}}!",
|
||||
data: map[string]any{"name": "World"},
|
||||
want: "Hello World!",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple substitutions",
|
||||
templateName: "test",
|
||||
templateString: "{{.greeting}} {{.name}}, you are {{.age}} years old",
|
||||
data: map[string]any{"greeting": "Hello", "name": "Alice", "age": 30},
|
||||
want: "Hello Alice, you are 30 years old",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty template",
|
||||
templateName: "test",
|
||||
templateString: "",
|
||||
data: map[string]any{},
|
||||
want: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no substitutions",
|
||||
templateName: "test",
|
||||
templateString: "Plain text without templates",
|
||||
data: map[string]any{},
|
||||
want: "Plain text without templates",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid template syntax",
|
||||
templateName: "test",
|
||||
templateString: "{{.name",
|
||||
data: map[string]any{"name": "World"},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing field",
|
||||
templateName: "test",
|
||||
templateString: "{{.missing}}",
|
||||
data: map[string]any{"name": "World"},
|
||||
want: "<no value>",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid function call",
|
||||
templateName: "test",
|
||||
templateString: "{{.name.invalid}}",
|
||||
data: map[string]any{"name": "World"},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tools.PopulateTemplate(tc.templateName, tc.templateString, tc.data)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got); diff != "" {
|
||||
t.Fatalf("incorrect result (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulateTemplateWithFunc(t *testing.T) {
|
||||
// Custom function for testing
|
||||
customFuncs := template.FuncMap{
|
||||
"upper": strings.ToUpper,
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
}
|
||||
|
||||
tcs := []struct {
|
||||
name string
|
||||
templateName string
|
||||
templateString string
|
||||
data map[string]any
|
||||
funcMap template.FuncMap
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "with custom upper function",
|
||||
templateName: "test",
|
||||
templateString: "{{upper .text}}",
|
||||
data: map[string]any{"text": "hello"},
|
||||
funcMap: customFuncs,
|
||||
want: "HELLO",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "with custom add function",
|
||||
templateName: "test",
|
||||
templateString: "Result: {{add .x .y}}",
|
||||
data: map[string]any{"x": 5, "y": 3},
|
||||
funcMap: customFuncs,
|
||||
want: "Result: 8",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nil funcMap",
|
||||
templateName: "test",
|
||||
templateString: "Hello {{.name}}",
|
||||
data: map[string]any{"name": "World"},
|
||||
funcMap: nil,
|
||||
want: "Hello World",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "combine custom function with regular substitution",
|
||||
templateName: "test",
|
||||
templateString: "{{upper .greeting}} {{.name}}!",
|
||||
data: map[string]any{"greeting": "hello", "name": "Alice"},
|
||||
funcMap: customFuncs,
|
||||
want: "HELLO Alice!",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "undefined function",
|
||||
templateName: "test",
|
||||
templateString: "{{undefined .text}}",
|
||||
data: map[string]any{"text": "hello"},
|
||||
funcMap: nil,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong number of arguments",
|
||||
templateName: "test",
|
||||
templateString: "{{upper}}",
|
||||
data: map[string]any{},
|
||||
funcMap: template.FuncMap{"upper": strings.ToUpper},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tools.PopulateTemplateWithFunc(tc.templateName, tc.templateString, tc.data, tc.funcMap)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got); diff != "" {
|
||||
t.Fatalf("incorrect result (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulateTemplateWithJSON(t *testing.T) {
|
||||
tcs := []struct {
|
||||
name string
|
||||
templateName string
|
||||
templateString string
|
||||
data map[string]any
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "json string",
|
||||
templateName: "test",
|
||||
templateString: "Data: {{json .value}}",
|
||||
data: map[string]any{"value": "hello"},
|
||||
want: `Data: "hello"`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json number",
|
||||
templateName: "test",
|
||||
templateString: "Number: {{json .num}}",
|
||||
data: map[string]any{"num": 42},
|
||||
want: "Number: 42",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json boolean",
|
||||
templateName: "test",
|
||||
templateString: "Bool: {{json .flag}}",
|
||||
data: map[string]any{"flag": true},
|
||||
want: "Bool: true",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json array",
|
||||
templateName: "test",
|
||||
templateString: "Array: {{json .items}}",
|
||||
data: map[string]any{"items": []any{"a", "b", "c"}},
|
||||
want: `Array: ["a","b","c"]`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json object",
|
||||
templateName: "test",
|
||||
templateString: "Object: {{json .obj}}",
|
||||
data: map[string]any{"obj": map[string]any{"name": "Alice", "age": 30}},
|
||||
want: `Object: {"age":30,"name":"Alice"}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "json null",
|
||||
templateName: "test",
|
||||
templateString: "Null: {{json .nullValue}}",
|
||||
data: map[string]any{"nullValue": nil},
|
||||
want: "Null: null",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "combine json with regular substitution",
|
||||
templateName: "test",
|
||||
templateString: "User {{.name}} has data: {{json .data}}",
|
||||
data: map[string]any{"name": "Bob", "data": map[string]any{"id": 123}},
|
||||
want: `User Bob has data: {"id":123}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing field for json",
|
||||
templateName: "test",
|
||||
templateString: "{{json .missing}}",
|
||||
data: map[string]any{},
|
||||
want: "null",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tools.PopulateTemplateWithJSON(tc.templateName, tc.templateString, tc.data)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got); diff != "" {
|
||||
t.Fatalf("incorrect result (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
548
internal/tools/firestore/firestorequery/firestorequery.go
Normal file
548
internal/tools/firestore/firestorequery/firestorequery.go
Normal file
@@ -0,0 +1,548 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package firestorequery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
firestoreapi "cloud.google.com/go/firestore"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/googleapis/genai-toolbox/internal/sources"
|
||||
firestoreds "github.com/googleapis/genai-toolbox/internal/sources/firestore"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools/firestore/util"
|
||||
)
|
||||
|
||||
// Constants for tool configuration
|
||||
const (
|
||||
kind = "firestore-query"
|
||||
defaultLimit = 100
|
||||
)
|
||||
|
||||
// Firestore operators
|
||||
var validOperators = map[string]bool{
|
||||
"<": true,
|
||||
"<=": true,
|
||||
">": true,
|
||||
">=": true,
|
||||
"==": true,
|
||||
"!=": true,
|
||||
"array-contains": true,
|
||||
"array-contains-any": true,
|
||||
"in": true,
|
||||
"not-in": true,
|
||||
}
|
||||
|
||||
// Error messages
|
||||
const (
|
||||
errFilterParseFailed = "failed to parse filters: %w"
|
||||
errQueryExecutionFailed = "failed to execute query: %w"
|
||||
errTemplateParseFailed = "failed to parse template: %w"
|
||||
errTemplateExecFailed = "failed to execute template: %w"
|
||||
errLimitParseFailed = "failed to parse limit value '%s': %w"
|
||||
errSelectFieldParseFailed = "failed to parse select field: %w"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if !tools.Register(kind, newConfig) {
|
||||
panic(fmt.Sprintf("tool kind %q already registered", kind))
|
||||
}
|
||||
}
|
||||
|
||||
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
|
||||
actual := Config{Name: name}
|
||||
if err := decoder.DecodeContext(ctx, &actual); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actual, nil
|
||||
}
|
||||
|
||||
// compatibleSource defines the interface for sources that can provide a Firestore client
|
||||
type compatibleSource interface {
|
||||
FirestoreClient() *firestoreapi.Client
|
||||
}
|
||||
|
||||
// validate compatible sources are still compatible
|
||||
var _ compatibleSource = &firestoreds.Source{}
|
||||
|
||||
var compatibleSources = [...]string{firestoreds.SourceKind}
|
||||
|
||||
// Config represents the configuration for the Firestore query tool
|
||||
type Config struct {
|
||||
Name string `yaml:"name" validate:"required"`
|
||||
Kind string `yaml:"kind" validate:"required"`
|
||||
Source string `yaml:"source" validate:"required"`
|
||||
Description string `yaml:"description" validate:"required"`
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
|
||||
// Template fields
|
||||
CollectionPath string `yaml:"collectionPath" validate:"required"`
|
||||
Filters string `yaml:"filters"` // JSON string template
|
||||
Select []string `yaml:"select"` // Fields to select
|
||||
OrderBy map[string]any `yaml:"orderBy"` // Order by configuration
|
||||
Limit string `yaml:"limit"` // Limit template (can be a number or template)
|
||||
AnalyzeQuery bool `yaml:"analyzeQuery"` // Analyze query (boolean, not parameterizable)
|
||||
|
||||
// Parameters for template substitution
|
||||
Parameters tools.Parameters `yaml:"parameters"`
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.ToolConfig = Config{}
|
||||
|
||||
// ToolConfigKind returns the kind of tool configuration
|
||||
func (cfg Config) ToolConfigKind() string {
|
||||
return kind
|
||||
}
|
||||
|
||||
// Initialize creates a new Tool instance from the configuration
|
||||
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
|
||||
// verify source exists
|
||||
rawS, ok := srcs[cfg.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
|
||||
}
|
||||
|
||||
// verify the source is compatible
|
||||
s, ok := rawS.(compatibleSource)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source for %q tool: source kind must be one of %q", kind, compatibleSources)
|
||||
}
|
||||
|
||||
// Set default limit if not specified
|
||||
if cfg.Limit == "" {
|
||||
cfg.Limit = fmt.Sprintf("%d", defaultLimit)
|
||||
}
|
||||
|
||||
// Create MCP manifest
|
||||
mcpManifest := tools.McpManifest{
|
||||
Name: cfg.Name,
|
||||
Description: cfg.Description,
|
||||
InputSchema: cfg.Parameters.McpManifest(),
|
||||
}
|
||||
|
||||
// finish tool setup
|
||||
t := Tool{
|
||||
Name: cfg.Name,
|
||||
Kind: kind,
|
||||
AuthRequired: cfg.AuthRequired,
|
||||
Client: s.FirestoreClient(),
|
||||
CollectionPathTemplate: cfg.CollectionPath,
|
||||
FiltersTemplate: cfg.Filters,
|
||||
SelectTemplate: cfg.Select,
|
||||
OrderByTemplate: cfg.OrderBy,
|
||||
LimitTemplate: cfg.Limit,
|
||||
AnalyzeQuery: cfg.AnalyzeQuery,
|
||||
Parameters: cfg.Parameters,
|
||||
manifest: tools.Manifest{Description: cfg.Description, Parameters: cfg.Parameters.Manifest(), AuthRequired: cfg.AuthRequired},
|
||||
mcpManifest: mcpManifest,
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.Tool = Tool{}
|
||||
|
||||
// Tool represents the Firestore query tool
|
||||
type Tool struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
|
||||
Client *firestoreapi.Client
|
||||
CollectionPathTemplate string
|
||||
FiltersTemplate string
|
||||
SelectTemplate []string
|
||||
OrderByTemplate map[string]any
|
||||
LimitTemplate string
|
||||
AnalyzeQuery bool
|
||||
Parameters tools.Parameters
|
||||
|
||||
manifest tools.Manifest
|
||||
mcpManifest tools.McpManifest
|
||||
}
|
||||
|
||||
// SimplifiedFilter represents the simplified filter format
|
||||
type SimplifiedFilter struct {
|
||||
And []SimplifiedFilter `json:"and,omitempty"`
|
||||
Or []SimplifiedFilter `json:"or,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// OrderByConfig represents ordering configuration
|
||||
type OrderByConfig struct {
|
||||
Field string `json:"field"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
// GetDirection returns the Firestore direction constant
|
||||
func (o *OrderByConfig) GetDirection() firestoreapi.Direction {
|
||||
if strings.EqualFold(o.Direction, "DESCENDING") || strings.EqualFold(o.Direction, "DESC") {
|
||||
return firestoreapi.Desc
|
||||
}
|
||||
return firestoreapi.Asc
|
||||
}
|
||||
|
||||
// QueryResult represents a document result from the query
|
||||
type QueryResult struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Data map[string]any `json:"data"`
|
||||
CreateTime interface{} `json:"createTime,omitempty"`
|
||||
UpdateTime interface{} `json:"updateTime,omitempty"`
|
||||
ReadTime interface{} `json:"readTime,omitempty"`
|
||||
}
|
||||
|
||||
// QueryResponse represents the full response including optional metrics
|
||||
type QueryResponse struct {
|
||||
Documents []QueryResult `json:"documents"`
|
||||
ExplainMetrics map[string]any `json:"explainMetrics,omitempty"`
|
||||
}
|
||||
|
||||
// Invoke executes the Firestore query based on the provided parameters
|
||||
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
|
||||
paramsMap := params.AsMap()
|
||||
|
||||
// Process collection path with template substitution
|
||||
collectionPath, err := tools.PopulateTemplate("collectionPath",t.CollectionPathTemplate, paramsMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process collection path: %w", err)
|
||||
}
|
||||
|
||||
// Build the query
|
||||
query, err := t.buildQuery(collectionPath, paramsMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Execute the query and return results
|
||||
return t.executeQuery(ctx, query)
|
||||
}
|
||||
|
||||
// buildQuery constructs the Firestore query from parameters
|
||||
func (t Tool) buildQuery(collectionPath string, params map[string]any) (*firestoreapi.Query, error) {
|
||||
collection := t.Client.Collection(collectionPath)
|
||||
query := collection.Query
|
||||
|
||||
// Process and apply filters if template is provided
|
||||
if t.FiltersTemplate != "" {
|
||||
// Apply template substitution to filters
|
||||
filtersJSON, err := tools.PopulateTemplateWithJSON("filters", t.FiltersTemplate, params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process filters template: %w", err)
|
||||
}
|
||||
|
||||
// Parse the simplified filter format
|
||||
var simplifiedFilter SimplifiedFilter
|
||||
if err := json.Unmarshal([]byte(filtersJSON), &simplifiedFilter); err != nil {
|
||||
return nil, fmt.Errorf(errFilterParseFailed, err)
|
||||
}
|
||||
|
||||
// Convert simplified filter to Firestore filter
|
||||
if filter := t.convertToFirestoreFilter(simplifiedFilter); filter != nil {
|
||||
query = query.WhereEntity(filter)
|
||||
}
|
||||
}
|
||||
|
||||
// Process select fields
|
||||
selectFields, err := t.processSelectFields(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
query = query.Select(selectFields...)
|
||||
}
|
||||
|
||||
// Process and apply ordering
|
||||
orderBy, err := t.getOrderBy(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderBy != nil {
|
||||
query = query.OrderBy(orderBy.Field, orderBy.GetDirection())
|
||||
}
|
||||
|
||||
// Process and apply limit
|
||||
limit, err := t.getLimit(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = query.Limit(limit)
|
||||
|
||||
// Apply analyze options if enabled
|
||||
if t.AnalyzeQuery {
|
||||
query = query.WithRunOptions(firestoreapi.ExplainOptions{
|
||||
Analyze: true,
|
||||
})
|
||||
}
|
||||
|
||||
return &query, nil
|
||||
}
|
||||
|
||||
// convertToFirestoreFilter converts simplified filter format to Firestore EntityFilter
|
||||
func (t Tool) convertToFirestoreFilter(filter SimplifiedFilter) firestoreapi.EntityFilter {
|
||||
// Handle AND filters
|
||||
if len(filter.And) > 0 {
|
||||
filters := make([]firestoreapi.EntityFilter, 0, len(filter.And))
|
||||
for _, f := range filter.And {
|
||||
if converted := t.convertToFirestoreFilter(f); converted != nil {
|
||||
filters = append(filters, converted)
|
||||
}
|
||||
}
|
||||
if len(filters) > 0 {
|
||||
return firestoreapi.AndFilter{Filters: filters}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle OR filters
|
||||
if len(filter.Or) > 0 {
|
||||
filters := make([]firestoreapi.EntityFilter, 0, len(filter.Or))
|
||||
for _, f := range filter.Or {
|
||||
if converted := t.convertToFirestoreFilter(f); converted != nil {
|
||||
filters = append(filters, converted)
|
||||
}
|
||||
}
|
||||
if len(filters) > 0 {
|
||||
return firestoreapi.OrFilter{Filters: filters}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle simple property filter
|
||||
if filter.Field != "" && filter.Op != "" && filter.Value != nil {
|
||||
if validOperators[filter.Op] {
|
||||
// Convert the value using the Firestore native JSON converter
|
||||
convertedValue, err := util.JSONToFirestoreValue(filter.Value, t.Client)
|
||||
if err != nil {
|
||||
// If conversion fails, use the original value
|
||||
convertedValue = filter.Value
|
||||
}
|
||||
|
||||
return firestoreapi.PropertyFilter{
|
||||
Path: filter.Field,
|
||||
Operator: filter.Op,
|
||||
Value: convertedValue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processSelectFields processes the select fields with parameter substitution
|
||||
func (t Tool) processSelectFields(params map[string]any) ([]string, error) {
|
||||
var selectFields []string
|
||||
|
||||
// Process configured select fields with template substitution
|
||||
for _, field := range t.SelectTemplate {
|
||||
// Check if it's a template
|
||||
if strings.Contains(field, "{{") {
|
||||
processed, err := tools.PopulateTemplate("selectField", field, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if processed != "" {
|
||||
// The processed field might be an array format [a b c] or a single value
|
||||
trimmedProcessed := strings.TrimSpace(processed)
|
||||
|
||||
// Check if it's in array format [a b c]
|
||||
if strings.HasPrefix(trimmedProcessed, "[") && strings.HasSuffix(trimmedProcessed, "]") {
|
||||
// Remove brackets and split by spaces
|
||||
arrayContent := strings.TrimPrefix(trimmedProcessed, "[")
|
||||
arrayContent = strings.TrimSuffix(arrayContent, "]")
|
||||
fields := strings.Fields(arrayContent) // Fields splits by any whitespace
|
||||
for _, f := range fields {
|
||||
if f != "" {
|
||||
selectFields = append(selectFields, f)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectFields = append(selectFields, processed)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectFields = append(selectFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
return selectFields, nil
|
||||
}
|
||||
|
||||
// getOrderBy processes the orderBy configuration with parameter substitution
|
||||
func (t Tool) getOrderBy(params map[string]any) (*OrderByConfig, error) {
|
||||
if t.OrderByTemplate == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
orderBy := &OrderByConfig{}
|
||||
|
||||
// Process field
|
||||
field, err := t.getOrderByForKey("field", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderBy.Field = field
|
||||
|
||||
// Process direction
|
||||
direction, err := t.getOrderByForKey("direction", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderBy.Direction = direction
|
||||
|
||||
if orderBy.Field == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return orderBy, nil
|
||||
}
|
||||
|
||||
func (t Tool) getOrderByForKey(key string, params map[string]any) (string, error) {
|
||||
value, ok := t.OrderByTemplate[key].(string)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
processedValue, err := tools.PopulateTemplate(fmt.Sprintf("orderBy%s", key), value, params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return processedValue, nil
|
||||
}
|
||||
|
||||
// processLimit processes the limit field with parameter substitution
|
||||
func (t Tool) getLimit(params map[string]any) (int, error) {
|
||||
limit := defaultLimit
|
||||
if t.LimitTemplate != "" {
|
||||
processedValue, err := tools.PopulateTemplate("limit", t.LimitTemplate, params)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Try to parse as integer
|
||||
if processedValue != "" {
|
||||
parsedLimit, err := strconv.Atoi(processedValue)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(errLimitParseFailed, processedValue, err)
|
||||
}
|
||||
limit = parsedLimit
|
||||
}
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
// executeQuery runs the query and formats the results
|
||||
func (t Tool) executeQuery(ctx context.Context, query *firestoreapi.Query) (any, error) {
|
||||
docIterator := query.Documents(ctx)
|
||||
docs, err := docIterator.GetAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(errQueryExecutionFailed, err)
|
||||
}
|
||||
|
||||
// Convert results to structured format
|
||||
results := make([]QueryResult, len(docs))
|
||||
for i, doc := range docs {
|
||||
results[i] = QueryResult{
|
||||
ID: doc.Ref.ID,
|
||||
Path: doc.Ref.Path,
|
||||
Data: doc.Data(),
|
||||
CreateTime: doc.CreateTime,
|
||||
UpdateTime: doc.UpdateTime,
|
||||
ReadTime: doc.ReadTime,
|
||||
}
|
||||
}
|
||||
|
||||
// Return with explain metrics if requested
|
||||
if t.AnalyzeQuery {
|
||||
explainMetrics, err := t.getExplainMetrics(docIterator)
|
||||
if err == nil && explainMetrics != nil {
|
||||
response := QueryResponse{
|
||||
Documents: results,
|
||||
ExplainMetrics: explainMetrics,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// getExplainMetrics extracts explain metrics from the query iterator
|
||||
func (t Tool) getExplainMetrics(docIterator *firestoreapi.DocumentIterator) (map[string]any, error) {
|
||||
explainMetrics, err := docIterator.ExplainMetrics()
|
||||
if err != nil || explainMetrics == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
metricsData := make(map[string]any)
|
||||
|
||||
// Add plan summary if available
|
||||
if explainMetrics.PlanSummary != nil {
|
||||
planSummary := make(map[string]any)
|
||||
planSummary["indexesUsed"] = explainMetrics.PlanSummary.IndexesUsed
|
||||
metricsData["planSummary"] = planSummary
|
||||
}
|
||||
|
||||
// Add execution stats if available
|
||||
if explainMetrics.ExecutionStats != nil {
|
||||
executionStats := make(map[string]any)
|
||||
executionStats["resultsReturned"] = explainMetrics.ExecutionStats.ResultsReturned
|
||||
executionStats["readOperations"] = explainMetrics.ExecutionStats.ReadOperations
|
||||
|
||||
if explainMetrics.ExecutionStats.ExecutionDuration != nil {
|
||||
executionStats["executionDuration"] = explainMetrics.ExecutionStats.ExecutionDuration.String()
|
||||
}
|
||||
|
||||
if explainMetrics.ExecutionStats.DebugStats != nil {
|
||||
executionStats["debugStats"] = *explainMetrics.ExecutionStats.DebugStats
|
||||
}
|
||||
|
||||
metricsData["executionStats"] = executionStats
|
||||
}
|
||||
|
||||
return metricsData, nil
|
||||
}
|
||||
|
||||
// ParseParams parses and validates input parameters
|
||||
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
|
||||
return tools.ParseParams(t.Parameters, data, claims)
|
||||
}
|
||||
|
||||
// Manifest returns the tool manifest
|
||||
func (t Tool) Manifest() tools.Manifest {
|
||||
return t.manifest
|
||||
}
|
||||
|
||||
// McpManifest returns the MCP manifest
|
||||
func (t Tool) McpManifest() tools.McpManifest {
|
||||
return t.mcpManifest
|
||||
}
|
||||
|
||||
// Authorized checks if the tool is authorized based on verified auth services
|
||||
func (t Tool) Authorized(verifiedAuthServices []string) bool {
|
||||
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
|
||||
}
|
||||
|
||||
func (t Tool) RequiresClientAuthorization() bool {
|
||||
return false
|
||||
}
|
||||
492
internal/tools/firestore/firestorequery/firestorequery_test.go
Normal file
492
internal/tools/firestore/firestorequery/firestorequery_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package firestorequery_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/googleapis/genai-toolbox/internal/server"
|
||||
"github.com/googleapis/genai-toolbox/internal/testutils"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools/firestore/firestorequery"
|
||||
)
|
||||
|
||||
func TestParseFromYamlFirestoreQuery(t *testing.T) {
|
||||
ctx, err := testutils.ContextWithNewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
tcs := []struct {
|
||||
desc string
|
||||
in string
|
||||
want server.ToolConfigs
|
||||
}{
|
||||
{
|
||||
desc: "basic example with parameterized collection path",
|
||||
in: `
|
||||
tools:
|
||||
query_users_tool:
|
||||
kind: firestore-query
|
||||
source: my-firestore-instance
|
||||
description: Query users collection with parameterized path
|
||||
collectionPath: "users/{{.userId}}/documents"
|
||||
parameters:
|
||||
- name: userId
|
||||
type: string
|
||||
description: The user ID to query documents for
|
||||
required: true
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"query_users_tool": firestorequery.Config{
|
||||
Name: "query_users_tool",
|
||||
Kind: "firestore-query",
|
||||
Source: "my-firestore-instance",
|
||||
Description: "Query users collection with parameterized path",
|
||||
CollectionPath: "users/{{.userId}}/documents",
|
||||
AuthRequired: []string{},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("userId", "The user ID to query documents for", true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "with parameterized filters",
|
||||
in: `
|
||||
tools:
|
||||
query_products_tool:
|
||||
kind: firestore-query
|
||||
source: prod-firestore
|
||||
description: Query products with dynamic filters
|
||||
collectionPath: "products"
|
||||
filters: |
|
||||
{
|
||||
"and": [
|
||||
{"field": "category", "op": "==", "value": {"stringValue": "{{.category}}"}},
|
||||
{"field": "price", "op": "<=", "value": {"doubleValue": {{.maxPrice}}}}
|
||||
]
|
||||
}
|
||||
parameters:
|
||||
- name: category
|
||||
type: string
|
||||
description: Product category to filter by
|
||||
required: true
|
||||
- name: maxPrice
|
||||
type: float
|
||||
description: Maximum price for products
|
||||
required: true
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"query_products_tool": firestorequery.Config{
|
||||
Name: "query_products_tool",
|
||||
Kind: "firestore-query",
|
||||
Source: "prod-firestore",
|
||||
Description: "Query products with dynamic filters",
|
||||
CollectionPath: "products",
|
||||
Filters: `{
|
||||
"and": [
|
||||
{"field": "category", "op": "==", "value": {"stringValue": "{{.category}}"}},
|
||||
{"field": "price", "op": "<=", "value": {"doubleValue": {{.maxPrice}}}}
|
||||
]
|
||||
}
|
||||
`,
|
||||
AuthRequired: []string{},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("category", "Product category to filter by", true),
|
||||
tools.NewFloatParameterWithRequired("maxPrice", "Maximum price for products", true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "with select fields and orderBy",
|
||||
in: `
|
||||
tools:
|
||||
query_orders_tool:
|
||||
kind: firestore-query
|
||||
source: orders-firestore
|
||||
description: Query orders with field selection
|
||||
collectionPath: "orders"
|
||||
select:
|
||||
- orderId
|
||||
- customerName
|
||||
- totalAmount
|
||||
orderBy:
|
||||
field: "{{.sortField}}"
|
||||
direction: "DESCENDING"
|
||||
limit: 50
|
||||
parameters:
|
||||
- name: sortField
|
||||
type: string
|
||||
description: Field to sort by
|
||||
required: true
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"query_orders_tool": firestorequery.Config{
|
||||
Name: "query_orders_tool",
|
||||
Kind: "firestore-query",
|
||||
Source: "orders-firestore",
|
||||
Description: "Query orders with field selection",
|
||||
CollectionPath: "orders",
|
||||
Select: []string{"orderId", "customerName", "totalAmount"},
|
||||
OrderBy: map[string]any{
|
||||
"field": "{{.sortField}}",
|
||||
"direction": "DESCENDING",
|
||||
},
|
||||
Limit: "50",
|
||||
AuthRequired: []string{},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("sortField", "Field to sort by", true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "with auth requirements and complex filters",
|
||||
in: `
|
||||
tools:
|
||||
secure_query_tool:
|
||||
kind: firestore-query
|
||||
source: secure-firestore
|
||||
description: Query with authentication and complex filters
|
||||
collectionPath: "{{.collection}}"
|
||||
filters: |
|
||||
{
|
||||
"or": [
|
||||
{
|
||||
"and": [
|
||||
{"field": "status", "op": "==", "value": {"stringValue": "{{.status}}"}},
|
||||
{"field": "priority", "op": ">=", "value": {"integerValue": "{{.minPriority}}"}}
|
||||
]
|
||||
},
|
||||
{"field": "urgent", "op": "==", "value": {"booleanValue": true}}
|
||||
]
|
||||
}
|
||||
analyzeQuery: true
|
||||
authRequired:
|
||||
- google-auth-service
|
||||
- api-key-service
|
||||
parameters:
|
||||
- name: collection
|
||||
type: string
|
||||
description: Collection name to query
|
||||
required: true
|
||||
- name: status
|
||||
type: string
|
||||
description: Status to filter by
|
||||
required: true
|
||||
- name: minPriority
|
||||
type: integer
|
||||
description: Minimum priority level
|
||||
default: 1
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"secure_query_tool": firestorequery.Config{
|
||||
Name: "secure_query_tool",
|
||||
Kind: "firestore-query",
|
||||
Source: "secure-firestore",
|
||||
Description: "Query with authentication and complex filters",
|
||||
CollectionPath: "{{.collection}}",
|
||||
Filters: `{
|
||||
"or": [
|
||||
{
|
||||
"and": [
|
||||
{"field": "status", "op": "==", "value": {"stringValue": "{{.status}}"}},
|
||||
{"field": "priority", "op": ">=", "value": {"integerValue": "{{.minPriority}}"}}
|
||||
]
|
||||
},
|
||||
{"field": "urgent", "op": "==", "value": {"booleanValue": true}}
|
||||
]
|
||||
}
|
||||
`,
|
||||
AnalyzeQuery: true,
|
||||
AuthRequired: []string{"google-auth-service", "api-key-service"},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("collection", "Collection name to query", true),
|
||||
tools.NewStringParameterWithRequired("status", "Status to filter by", true),
|
||||
tools.NewIntParameterWithDefault("minPriority", 1, "Minimum priority level"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "with Firestore native JSON value types and template parameters",
|
||||
in: `
|
||||
tools:
|
||||
query_with_typed_values:
|
||||
kind: firestore-query
|
||||
source: typed-firestore
|
||||
description: Query with Firestore native JSON value types
|
||||
collectionPath: "countries"
|
||||
filters: |
|
||||
{
|
||||
"or": [
|
||||
{"field": "continent", "op": "==", "value": {"stringValue": "{{.continent}}"}},
|
||||
{
|
||||
"and": [
|
||||
{"field": "area", "op": ">", "value": {"integerValue": "2000000"}},
|
||||
{"field": "area", "op": "<", "value": {"integerValue": "3000000"}},
|
||||
{"field": "population", "op": ">=", "value": {"integerValue": "{{.minPopulation}}"}},
|
||||
{"field": "gdp", "op": ">", "value": {"doubleValue": {{.minGdp}}}},
|
||||
{"field": "isActive", "op": "==", "value": {"booleanValue": {{.isActive}}}},
|
||||
{"field": "lastUpdated", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
parameters:
|
||||
- name: continent
|
||||
type: string
|
||||
description: Continent to filter by
|
||||
required: true
|
||||
- name: minPopulation
|
||||
type: string
|
||||
description: Minimum population as string
|
||||
required: true
|
||||
- name: minGdp
|
||||
type: float
|
||||
description: Minimum GDP value
|
||||
required: true
|
||||
- name: isActive
|
||||
type: boolean
|
||||
description: Filter by active status
|
||||
required: true
|
||||
- name: startDate
|
||||
type: string
|
||||
description: Start date in RFC3339 format
|
||||
required: true
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"query_with_typed_values": firestorequery.Config{
|
||||
Name: "query_with_typed_values",
|
||||
Kind: "firestore-query",
|
||||
Source: "typed-firestore",
|
||||
Description: "Query with Firestore native JSON value types",
|
||||
CollectionPath: "countries",
|
||||
Filters: `{
|
||||
"or": [
|
||||
{"field": "continent", "op": "==", "value": {"stringValue": "{{.continent}}"}},
|
||||
{
|
||||
"and": [
|
||||
{"field": "area", "op": ">", "value": {"integerValue": "2000000"}},
|
||||
{"field": "area", "op": "<", "value": {"integerValue": "3000000"}},
|
||||
{"field": "population", "op": ">=", "value": {"integerValue": "{{.minPopulation}}"}},
|
||||
{"field": "gdp", "op": ">", "value": {"doubleValue": {{.minGdp}}}},
|
||||
{"field": "isActive", "op": "==", "value": {"booleanValue": {{.isActive}}}},
|
||||
{"field": "lastUpdated", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
AuthRequired: []string{},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("continent", "Continent to filter by", true),
|
||||
tools.NewStringParameterWithRequired("minPopulation", "Minimum population as string", true),
|
||||
tools.NewFloatParameterWithRequired("minGdp", "Minimum GDP value", true),
|
||||
tools.NewBooleanParameterWithRequired("isActive", "Filter by active status", true),
|
||||
tools.NewStringParameterWithRequired("startDate", "Start date in RFC3339 format", true),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to unmarshal: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
|
||||
t.Fatalf("incorrect parse: diff %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFromYamlMultipleQueryTools(t *testing.T) {
|
||||
ctx, err := testutils.ContextWithNewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
in := `
|
||||
tools:
|
||||
query_user_posts:
|
||||
kind: firestore-query
|
||||
source: social-firestore
|
||||
description: Query user posts with filtering
|
||||
collectionPath: "users/{{.userId}}/posts"
|
||||
filters: |
|
||||
{
|
||||
"and": [
|
||||
{"field": "visibility", "op": "==", "value": {"stringValue": "{{.visibility}}"}},
|
||||
{"field": "createdAt", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
|
||||
]
|
||||
}
|
||||
select:
|
||||
- title
|
||||
- content
|
||||
- likes
|
||||
orderBy:
|
||||
field: createdAt
|
||||
direction: "{{.sortOrder}}"
|
||||
limit: 20
|
||||
parameters:
|
||||
- name: userId
|
||||
type: string
|
||||
description: User ID whose posts to query
|
||||
required: true
|
||||
- name: visibility
|
||||
type: string
|
||||
description: Post visibility (public, private, friends)
|
||||
required: true
|
||||
- name: startDate
|
||||
type: string
|
||||
description: Start date for posts
|
||||
required: true
|
||||
- name: sortOrder
|
||||
type: string
|
||||
description: Sort order (ASCENDING or DESCENDING)
|
||||
default: "DESCENDING"
|
||||
query_inventory:
|
||||
kind: firestore-query
|
||||
source: inventory-firestore
|
||||
description: Query inventory items
|
||||
collectionPath: "warehouses/{{.warehouseId}}/inventory"
|
||||
filters: |
|
||||
{
|
||||
"field": "quantity", "op": "<", "value": {"integerValue": "{{.threshold}}"}}
|
||||
parameters:
|
||||
- name: warehouseId
|
||||
type: string
|
||||
description: Warehouse ID to check inventory
|
||||
required: true
|
||||
- name: threshold
|
||||
type: integer
|
||||
description: Quantity threshold for low stock
|
||||
required: true
|
||||
query_transactions:
|
||||
kind: firestore-query
|
||||
source: finance-firestore
|
||||
description: Query financial transactions
|
||||
collectionPath: "accounts/{{.accountId}}/transactions"
|
||||
filters: |
|
||||
{
|
||||
"or": [
|
||||
{"field": "type", "op": "==", "value": {"stringValue": "{{.transactionType}}"}},
|
||||
{"field": "amount", "op": ">", "value": {"doubleValue": {{.minAmount}}}}
|
||||
]
|
||||
}
|
||||
analyzeQuery: true
|
||||
authRequired:
|
||||
- finance-auth
|
||||
parameters:
|
||||
- name: accountId
|
||||
type: string
|
||||
description: Account ID for transactions
|
||||
required: true
|
||||
- name: transactionType
|
||||
type: string
|
||||
description: Type of transaction
|
||||
default: "all"
|
||||
- name: minAmount
|
||||
type: float
|
||||
description: Minimum transaction amount
|
||||
default: 0
|
||||
`
|
||||
want := server.ToolConfigs{
|
||||
"query_user_posts": firestorequery.Config{
|
||||
Name: "query_user_posts",
|
||||
Kind: "firestore-query",
|
||||
Source: "social-firestore",
|
||||
Description: "Query user posts with filtering",
|
||||
CollectionPath: "users/{{.userId}}/posts",
|
||||
Filters: `{
|
||||
"and": [
|
||||
{"field": "visibility", "op": "==", "value": {"stringValue": "{{.visibility}}"}},
|
||||
{"field": "createdAt", "op": ">=", "value": {"timestampValue": "{{.startDate}}"}}
|
||||
]
|
||||
}
|
||||
`,
|
||||
Select: []string{"title", "content", "likes"},
|
||||
OrderBy: map[string]any{
|
||||
"field": "createdAt",
|
||||
"direction": "{{.sortOrder}}",
|
||||
},
|
||||
Limit: "20",
|
||||
AuthRequired: []string{},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("userId", "User ID whose posts to query", true),
|
||||
tools.NewStringParameterWithRequired("visibility", "Post visibility (public, private, friends)", true),
|
||||
tools.NewStringParameterWithRequired("startDate", "Start date for posts", true),
|
||||
tools.NewStringParameterWithDefault("sortOrder", "DESCENDING", "Sort order (ASCENDING or DESCENDING)"),
|
||||
},
|
||||
},
|
||||
"query_inventory": firestorequery.Config{
|
||||
Name: "query_inventory",
|
||||
Kind: "firestore-query",
|
||||
Source: "inventory-firestore",
|
||||
Description: "Query inventory items",
|
||||
CollectionPath: "warehouses/{{.warehouseId}}/inventory",
|
||||
Filters: `{
|
||||
"field": "quantity", "op": "<", "value": {"integerValue": "{{.threshold}}"}}
|
||||
`,
|
||||
AuthRequired: []string{},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("warehouseId", "Warehouse ID to check inventory", true),
|
||||
tools.NewIntParameterWithRequired("threshold", "Quantity threshold for low stock", true),
|
||||
},
|
||||
},
|
||||
"query_transactions": firestorequery.Config{
|
||||
Name: "query_transactions",
|
||||
Kind: "firestore-query",
|
||||
Source: "finance-firestore",
|
||||
Description: "Query financial transactions",
|
||||
CollectionPath: "accounts/{{.accountId}}/transactions",
|
||||
Filters: `{
|
||||
"or": [
|
||||
{"field": "type", "op": "==", "value": {"stringValue": "{{.transactionType}}"}},
|
||||
{"field": "amount", "op": ">", "value": {"doubleValue": {{.minAmount}}}}
|
||||
]
|
||||
}
|
||||
`,
|
||||
AnalyzeQuery: true,
|
||||
AuthRequired: []string{"finance-auth"},
|
||||
Parameters: tools.Parameters{
|
||||
tools.NewStringParameterWithRequired("accountId", "Account ID for transactions", true),
|
||||
tools.NewStringParameterWithDefault("transactionType", "all", "Type of transaction"),
|
||||
tools.NewFloatParameterWithDefault("minAmount", 0, "Minimum transaction amount"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err = yaml.UnmarshalContext(ctx, testutils.FormatYaml(in), &got)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to unmarshal: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(want, got.Tools); diff != "" {
|
||||
t.Fatalf("incorrect parse: diff %v", diff)
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,8 @@ func TestFirestoreToolEndpoints(t *testing.T) {
|
||||
// Run specific Firestore tool tests
|
||||
runFirestoreGetDocumentsTest(t, docPath1, docPath2)
|
||||
runFirestoreQueryCollectionTest(t, testCollectionName)
|
||||
runFirestoreQueryTest(t, testCollectionName)
|
||||
runFirestoreQuerySelectArrayTest(t, testCollectionName)
|
||||
runFirestoreListCollectionsTest(t, testCollectionName, testSubCollectionName, docPath1)
|
||||
runFirestoreAddDocumentsTest(t, testCollectionName)
|
||||
runFirestoreUpdateDocumentTest(t, testCollectionName, testDocID1)
|
||||
@@ -562,6 +564,63 @@ func getFirestoreToolsConfig(sourceConfig map[string]any) map[string]any {
|
||||
"source": "my-instance",
|
||||
"description": "Query a Firestore collection",
|
||||
},
|
||||
"firestore-query-param": map[string]any{
|
||||
"kind": "firestore-query",
|
||||
"source": "my-instance",
|
||||
"description": "Query a Firestore collection with parameterizable filters",
|
||||
"collectionPath": "{{.collection}}",
|
||||
"filters": `{
|
||||
"field": "age", "op": "{{.operator}}", "value": {"integerValue": "{{.ageValue}}"}
|
||||
}`,
|
||||
"limit": 10,
|
||||
"parameters": []map[string]any{
|
||||
{
|
||||
"name": "collection",
|
||||
"type": "string",
|
||||
"description": "Collection to query",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"name": "operator",
|
||||
"type": "string",
|
||||
"description": "Comparison operator",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"name": "ageValue",
|
||||
"type": "string",
|
||||
"description": "Age value to compare",
|
||||
"required": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"firestore-query-select-array": map[string]any{
|
||||
"kind": "firestore-query",
|
||||
"source": "my-instance",
|
||||
"description": "Query with array-based select fields",
|
||||
"collectionPath": "{{.collection}}",
|
||||
"select": []string{"{{.fields}}"},
|
||||
"limit": 10,
|
||||
"parameters": []map[string]any{
|
||||
{
|
||||
"name": "collection",
|
||||
"type": "string",
|
||||
"description": "Collection to query",
|
||||
"required": true,
|
||||
},
|
||||
{
|
||||
"name": "fields",
|
||||
"type": "array",
|
||||
"description": "Fields to select",
|
||||
"required": true,
|
||||
"items": map[string]any{
|
||||
"name" : "field",
|
||||
"type": "string",
|
||||
"description":"field",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"firestore-get-rules": map[string]any{
|
||||
"kind": "firestore-get-rules",
|
||||
"source": "my-instance",
|
||||
@@ -1356,6 +1415,246 @@ func runFirestoreDeleteDocumentsTest(t *testing.T, docPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
func runFirestoreQueryTest(t *testing.T, collectionName string) {
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
api string
|
||||
requestBody io.Reader
|
||||
wantRegex string
|
||||
isErr bool
|
||||
}{
|
||||
{
|
||||
name: "query with parameterized filters - age greater than",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collection": "%s",
|
||||
"operator": ">",
|
||||
"ageValue": "25"
|
||||
}`, collectionName))),
|
||||
wantRegex: `"name":"Alice"`,
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "query with parameterized filters - exact name match",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collection": "%s",
|
||||
"operator": "==",
|
||||
"ageValue": "25"
|
||||
}`, collectionName))),
|
||||
wantRegex: `"name":"Bob"`,
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "query with parameterized filters - age less than or equal",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collection": "%s",
|
||||
"operator": "<=",
|
||||
"ageValue": "29"
|
||||
}`, collectionName))),
|
||||
wantRegex: `"name":"Bob"`,
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing required parameter",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(`{"collection": "test", "operator": ">"}`)),
|
||||
isErr: true,
|
||||
},
|
||||
{
|
||||
name: "query non-existent collection with parameters",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-param/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(`{
|
||||
"collection": "non-existent-collection",
|
||||
"operator": "==",
|
||||
"ageValue": "30"
|
||||
}`)),
|
||||
wantRegex: `^\[\]$`, // Empty array
|
||||
isErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range invokeTcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create request: %s", err)
|
||||
}
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to send request: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if tc.isErr {
|
||||
return
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing response body: %v", err)
|
||||
}
|
||||
|
||||
got, ok := body["result"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("unable to find result in response body")
|
||||
}
|
||||
|
||||
if tc.wantRegex != "" {
|
||||
matched, err := regexp.MatchString(tc.wantRegex, got)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid regex pattern: %v", err)
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("result does not match expected pattern.\nGot: %s\nWant pattern: %s", got, tc.wantRegex)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runFirestoreQuerySelectArrayTest(t *testing.T, collectionName string) {
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
api string
|
||||
requestBody io.Reader
|
||||
wantRegex string
|
||||
validateFields bool
|
||||
isErr bool
|
||||
}{
|
||||
{
|
||||
name: "query with array select fields - single field",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collection": "%s",
|
||||
"fields": ["name"]
|
||||
}`, collectionName))),
|
||||
wantRegex: `"name":"`,
|
||||
validateFields: true,
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "query with array select fields - multiple fields",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collection": "%s",
|
||||
"fields": ["name", "age"]
|
||||
}`, collectionName))),
|
||||
wantRegex: `"name":".*"age":`,
|
||||
validateFields: true,
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "query with empty array select fields",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{
|
||||
"collection": "%s",
|
||||
"fields": []
|
||||
}`, collectionName))),
|
||||
wantRegex: `\[.*\]`, // Should return documents with all fields
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing fields parameter",
|
||||
api: "http://127.0.0.1:5000/api/tool/firestore-query-select-array/invoke",
|
||||
requestBody: bytes.NewBuffer([]byte(fmt.Sprintf(`{"collection": "%s"}`, collectionName))),
|
||||
isErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range invokeTcs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPost, tc.api, tc.requestBody)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to create request: %s", err)
|
||||
}
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to send request: %s", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if tc.isErr {
|
||||
return
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("response status code is not 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing response body: %v", err)
|
||||
}
|
||||
|
||||
got, ok := body["result"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("unable to find result in response body")
|
||||
}
|
||||
|
||||
if tc.wantRegex != "" {
|
||||
matched, err := regexp.MatchString(tc.wantRegex, got)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid regex pattern: %v", err)
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("result does not match expected pattern.\nGot: %s\nWant pattern: %s", got, tc.wantRegex)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation for field selection
|
||||
if tc.validateFields {
|
||||
// Parse the result to check if only selected fields are present
|
||||
var results []map[string]interface{}
|
||||
err = json.Unmarshal([]byte(got), &results)
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing result as JSON array: %v", err)
|
||||
}
|
||||
|
||||
// For single field test, ensure only 'name' field is present in data
|
||||
if tc.name == "query with array select fields - single field" && len(results) > 0 {
|
||||
for _, result := range results {
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if _, hasName := data["name"]; !hasName {
|
||||
t.Fatalf("expected 'name' field in data, but not found")
|
||||
}
|
||||
// The 'age' field should not be present when only 'name' is selected
|
||||
if _, hasAge := data["age"]; hasAge {
|
||||
t.Fatalf("unexpected 'age' field in data when only 'name' was selected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For multiple fields test, ensure both fields are present
|
||||
if tc.name == "query with array select fields - multiple fields" && len(results) > 0 {
|
||||
for _, result := range results {
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if _, hasName := data["name"]; !hasName {
|
||||
t.Fatalf("expected 'name' field in data, but not found")
|
||||
}
|
||||
if _, hasAge := data["age"]; !hasAge {
|
||||
t.Fatalf("expected 'age' field in data, but not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runFirestoreQueryCollectionTest(t *testing.T, collectionName string) {
|
||||
invokeTcs := []struct {
|
||||
name string
|
||||
@@ -1385,7 +1684,7 @@ func runFirestoreQueryCollectionTest(t *testing.T, collectionName string) {
|
||||
"orderBy": "{\"field\": \"age\", \"direction\": \"DESCENDING\"}",
|
||||
"limit": 2
|
||||
}`, collectionName))),
|
||||
wantRegex: `"age":35.*"age":30`, // Should be ordered by age descending (Charlie=35, Alice=30, Bob=25)
|
||||
wantRegex: `"age":35.*"age":30`, // Should be ordered by age descending (Charlie=35, Alice=30)
|
||||
isErr: false,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user