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:
trehanshakuntG
2025-09-09 23:54:12 +05:30
committed by GitHub
parent 70e832bd08
commit cce602f280
7 changed files with 2067 additions and 6 deletions

View File

@@ -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"

View 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)

View File

@@ -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

View 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)
}
})
}
}

View 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
}

View 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)
}
}

View File

@@ -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,
},
{