Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,30 @@ It uses [FireQL](https://github.com/pgollangi/FireQL) to capture user query that
- [x] Limit query results
- [x] Query [Collection Groups](https://firebase.blog/posts/2019/06/understanding-collection-group-queries)
- [ ] Count query results
- [ ] Use of [Grafafa global variables](https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables) in queries.
- [x] Use of [Grafafa global variables](https://grafana.com/docs/grafana/latest/dashboards/variables/add-template-variables/#global-variables) in queries.

### Grafana macros support

The datasource expands a minimal set of Grafana macros before executing FireQL:

- `__$timeFrom()` → quoted RFC3339 timestamp for panel range start
- `__$timeTo()` → quoted RFC3339 timestamp for panel range end
- `__$interval_ms` → panel interval in milliseconds (integer)
- `__$timeFilter(field)` → expands to `field >= 'from' and field <= 'to'`

Examples:

```
select * from users where $__timeFilter(created_at)
```

```
select * from events where ts between $__timeFrom() and $__timeTo()
```

Notes:
- The `field` passed to `__$timeFilter(field)` must be a Firestore timestamp field compatible with FireQL comparisons.
- Timestamps are expanded in UTC as RFC3339 strings.

### Firestore data source configuration

Expand Down
69 changes: 66 additions & 3 deletions pkg/plugin/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import (
"golang.org/x/oauth2/google"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
"regexp"
"strconv"
"strings"
)

// Make sure Datasource implements required interfaces. This is important to do
Expand Down Expand Up @@ -123,10 +126,13 @@ func (d *Datasource) queryInternal(ctx context.Context, pCtx backend.PluginConte

log.DefaultLogger.Info("Created fireql.NewFireQLWithServiceAccountJSON")

if len(qm.Query) > 0 {
if len(qm.Query) > 0 {

log.DefaultLogger.Info("Executing query", qm.Query)
result, err := fQuery.Execute(qm.Query)
// Expand common Grafana macros before executing FireQL
expandedQuery := expandGrafanaMacros(qm.Query, query.TimeRange, query.Interval, query.MaxDataPoints)

log.DefaultLogger.Info("Executing query", expandedQuery)
result, err := fQuery.Execute(expandedQuery)
if err != nil {
return backend.ErrDataResponse(backend.StatusBadRequest, "fireql.Execute: "+err.Error())
}
Expand Down Expand Up @@ -213,6 +219,63 @@ func (d *Datasource) queryInternal(ctx context.Context, pCtx backend.PluginConte
return response
}

// expandGrafanaMacros replaces a minimal set of Grafana macros commonly used in SQL datasources
// with FireQL-compatible expressions.
// Supported:
// - $__timeFrom(), $__timeTo(): RFC3339 timestamps as quoted strings
// - $__interval_ms: integer milliseconds based on panel interval
// - $__timeFilter(field): expands to "field >= 'from' and field <= 'to'"
// Note: Users should pass the timestamp field name into $__timeFilter(field).
func expandGrafanaMacros(original string, tr backend.TimeRange, interval time.Duration, _ int64) string {
q := original

// Helper to format time in RFC3339 which FireQL treats as time.Time when comparing to Firestore timestamp fields
formatTime := func(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}

// $__timeFrom(), $__timeTo()
q = strings.ReplaceAll(q, "$__timeFrom()", "'"+formatTime(tr.From)+"'")
q = strings.ReplaceAll(q, "$__timeTo()", "'"+formatTime(tr.To)+"'")

// Unix epoch helpers (seconds and ms)
q = strings.ReplaceAll(q, "$__unixEpochFrom()", strconv.FormatInt(tr.From.Unix(), 10))
q = strings.ReplaceAll(q, "$__unixEpochTo()", strconv.FormatInt(tr.To.Unix(), 10))
q = strings.ReplaceAll(q, "$__unixEpochFromMs()", strconv.FormatInt(tr.From.UnixMilli(), 10))
q = strings.ReplaceAll(q, "$__unixEpochToMs()", strconv.FormatInt(tr.To.UnixMilli(), 10))

// $__interval_ms
intervalMs := strconv.FormatInt(interval.Milliseconds(), 10)
q = strings.ReplaceAll(q, "$__interval_ms", intervalMs)

// $__timeFilter(field) and $timeFilter(field) alias
// If you need epoch-ms numeric comparisons, use $__timeFilterMs(field)
re := regexp.MustCompile(`\$(?:__)?timeFilter\(\s*([^\)\s]+)\s*\)`) // captures field identifier
q = re.ReplaceAllStringFunc(q, func(m string) string {
sub := re.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
field := sub[1]
return field + " >= '" + formatTime(tr.From) + "' and " + field + " <= '" + formatTime(tr.To) + "'"
})

// $__timeFilterMs(field) and $timeFilterMs(field) → numeric epoch millis comparisons
reMs := regexp.MustCompile(`\$(?:__)?timeFilterMs\(\s*([^\)\s]+)\s*\)`) // captures field identifier
fromMs := strconv.FormatInt(tr.From.UnixMilli(), 10)
toMs := strconv.FormatInt(tr.To.UnixMilli(), 10)
q = reMs.ReplaceAllStringFunc(q, func(m string) string {
sub := reMs.FindStringSubmatch(m)
if len(sub) != 2 {
return m
}
field := sub[1]
return field + " >= " + fromMs + " and " + field + " <= " + toMs
})

return q
}

func newFirestoreClient(ctx context.Context, pCtx backend.PluginContext) (*firestore.Client, error) {
var settings FirestoreSettings
err := json.Unmarshal(pCtx.DataSourceInstanceSettings.JSONData, &settings)
Expand Down
194 changes: 194 additions & 0 deletions pkg/plugin/datasource_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//go:build integration

package plugin

import (
"cloud.google.com/go/firestore"
"context"
"encoding/json"
"fmt"
"github.com/stretchr/testify/require"
"io"
"log"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"testing"

"github.com/grafana/grafana-plugin-sdk-go/backend"
)

func TestQueryData(t *testing.T) {
ds := Datasource{}

var settings FirestoreSettings
settings.ProjectId = "test"
jsonSettings, err := json.Marshal(settings)
if err != nil {
t.Error(err)
}

var queries = make([]backend.DataQuery, len(queryTests))
var byRefs = make(map[string]TestExpect, len(queryTests))
for idx, queryTest := range queryTests {
refID := fmt.Sprintf("ref%d", idx)
queries[idx] = backend.DataQuery{
RefID: refID,
JSON: []byte(fmt.Sprintf(`{"query": "%s"}`, queryTest.query)),
}
byRefs[refID] = queryTest
}

resp, err := ds.QueryData(
context.Background(),
&backend.QueryDataRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: jsonSettings,
},
},
Queries: queries,
},
)
require.NoError(t, err)
require.NotNil(t, resp.Responses)
require.Len(t, resp.Responses, len(queryTests))

for refId, response := range resp.Responses {
require.NoError(t, response.Error)
testExp := byRefs[refId]
require.Len(t, response.Frames, 1)
require.Len(t, response.Frames[0].Fields, testExp.columnsLength)
for _, field := range response.Frames[0].Fields {
require.Equal(t, testExp.rowsLength, field.Len())
}
}
}

type healthTest struct {
settings string
decrypted map[string]string
status backend.HealthStatus
}

var healthTests = []healthTest{
{``, nil, backend.HealthStatusError},
{`{}`, nil, backend.HealthStatusError},
{`{"ProjectId": "test"}`, map[string]string{"serviceAccount": "test"}, backend.HealthStatusError},
{`{"ProjectId": "test"}`, map[string]string{"serviceAccount": `{}`}, backend.HealthStatusError},
{`{"ProjectId": "test"}`, nil, backend.HealthStatusOk},
}

func TestCheckHealth(t *testing.T) {
ds := Datasource{}
for _, test := range healthTests {
healthResponse, err := ds.CheckHealth(context.Background(), &backend.CheckHealthRequest{
PluginContext: backend.PluginContext{
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
JSONData: []byte(test.settings),
DecryptedSecureJSONData: test.decrypted,
},
},
})
require.NoError(t, err)
require.NotNil(t, healthResponse)
require.Equal(t, test.status, healthResponse.Status)
}

}

type TestExpect struct {
query string
rowsLength int
columnsLength int
columns []string
frames [][]interface{}
}

const FirestoreEmulatorHost = "FIRESTORE_EMULATOR_HOST"

var queryTests = []TestExpect{
{
query: "select * from users",
rowsLength: 5,
columnsLength: 6,
},
}

func newFirestoreTestClient(ctx context.Context) *firestore.Client {
client, err := firestore.NewClient(ctx, "test")
if err != nil {
log.Fatalf("firebase.NewClient err: %v", err)
}

return client
}

func TestMain(m *testing.M) {
// allow skipping if emulator component is unavailable
if os.Getenv(FirestoreEmulatorHost) == "" {
cmd := exec.Command("gcloud", "beta", "emulators", "firestore", "start", "--host-port=localhost:8765")

cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stderr, err := cmd.StderrPipe()
if err != nil {
log.Fatal(err)
}
defer stderr.Close()

if err := cmd.Start(); err != nil {
log.Println("skipping emulator-based tests: could not start emulator:", err)
os.Exit(m.Run())
}

var result int
defer func() {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
os.Exit(result)
}()

var wg sync.WaitGroup
wg.Add(1)
go func() {
buf := make([]byte, 256, 256)
for {
n, err := stderr.Read(buf[:])
if err != nil {
if err == io.EOF {
break
}
log.Fatalf("reading stderr %v", err)
}
if n > 0 {
d := string(buf[:n])
log.Printf("%s", d)
if strings.Contains(d, "Dev App Server is now running") {
wg.Done()
}
}
}
}()
wg.Wait()

os.Setenv(FirestoreEmulatorHost, "localhost:8765")
ctx := context.Background()
users := newFirestoreTestClient(ctx).Collection("users")

usersDataRaw, _ := os.ReadFile("../../test/data/users.json")
var usersData []map[string]interface{}
json.Unmarshal(usersDataRaw, &usersData)
for _, user := range usersData {
users.Doc(fmt.Sprintf("%v", user["id"].(float64))).Set(ctx, user)
}

result := m.Run()
_ = result
return
}

os.Exit(m.Run())
}


Loading