Browse Source

new events package

query parser

use parser compiler to generate query parser

I used https://github.com/pointlander/peg which has a nice API and seems
to be the most popular Golang compiler parser using PEG on Github.

More about PEG:

- https://en.wikipedia.org/wiki/Parsing_expression_grammar
- https://github.com/PhilippeSigaud/Pegged/wiki/PEG-Basics
- https://github.com/PhilippeSigaud/Pegged/wiki/Grammar-Examples

rename

implement query match function

match function

uncomment test lines

add more test cases for query#Matches

fix int case

rename events to pubsub

add comment about cache

assertReceive helper to not block on receive in tests

fix bug with multiple conditions

uncomment benchmark

first results:

```
Benchmark10Clients-2                1000           1305493 ns/op         3957519 B/op     355 allocs/op
Benchmark100Clients-2                100          12278304 ns/op        39571751 B/op    3505 allocs/op
Benchmark1000Clients-2                10         124120909 ns/op        395714004 B/op   35005 allocs/op
```

124ms to publish message to 1000 clients. A lot.

use AST from query.peg.go

separate pubsub and query packages by using Query interface in pubsub

wrote docs and refactor code

updates from Frey's review

refactor type assertion to use type switch

cleanup during shutdown

subscriber should create output channel, not the server

overflow strategies, server buffer capacity

context as the first argument for Publish

log error

introduce Option type

update NewServer comment

move helpers into pubsub_test

increase assertReceive timeout

add query.MustParse

add more false tests for parser

add more false tests for query.Matches

parse numbers as int64 / float64

try our best to convert from other types

add number to panic output

add more comments

save commit

introduce client argument as first argument to Subscribe

> Why we do not specify buffer size on the output channel in Subscribe?

The choice of buffer size of N here depends on knowing the number of
messages server will receive and the number of messages downstream
subscribers will consume. This is fragile: if we publish an additional
message, or if one of the downstream subscribers reads any fewer
messages, we will again have blocked goroutines.

save commit

remove reference counting

fix test

test client resubscribe

test UnsubscribeAll

client options

[pubsub/query] fuzzy testing

do not print msg as it creates data race!
pull/1842/head
Anton Kaliaev 8 years ago
parent
commit
a99b8a6210
No known key found for this signature in database GPG Key ID: 7B6881D965918214
13 changed files with 2742 additions and 0 deletions
  1. +2
    -0
      .gitignore
  2. +24
    -0
      pubsub/example_test.go
  3. +314
    -0
      pubsub/pubsub.go
  4. +227
    -0
      pubsub/pubsub_test.go
  5. +11
    -0
      pubsub/query/Makefile
  6. +14
    -0
      pubsub/query/empty.go
  7. +16
    -0
      pubsub/query/empty_test.go
  8. +30
    -0
      pubsub/query/fuzz_test/main.go
  9. +81
    -0
      pubsub/query/parser_test.go
  10. +258
    -0
      pubsub/query/query.go
  11. +33
    -0
      pubsub/query/query.peg
  12. +1668
    -0
      pubsub/query/query.peg.go
  13. +64
    -0
      pubsub/query/query_test.go

+ 2
- 0
.gitignore View File

@ -1,2 +1,4 @@
vendor vendor
.glide .glide
pubsub/query/fuzz_test/output

+ 24
- 0
pubsub/example_test.go View File

@ -0,0 +1,24 @@
package pubsub_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/tendermint/tmlibs/log"
"github.com/tendermint/tmlibs/pubsub"
"github.com/tendermint/tmlibs/pubsub/query"
)
func TestExample(t *testing.T) {
s := pubsub.NewServer()
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch := make(chan interface{}, 1)
s.Subscribe("example-client", query.MustParse("abci.account.name=John"), ch)
err := s.PublishWithTags("Tombstone", map[string]interface{}{"abci.account.name": "John"})
require.NoError(t, err)
assertReceive(t, "Tombstone", ch)
}

+ 314
- 0
pubsub/pubsub.go View File

@ -0,0 +1,314 @@
// Package pubsub implements a pub-sub model with a single publisher (Server)
// and multiple subscribers (clients).
//
// Though you can have multiple publishers by sharing a pointer to a server or
// by giving the same channel to each publisher and publishing messages from
// that channel (fan-in).
//
// Clients subscribe for messages, which could be of any type, using a query.
// When some message is published, we match it with all queries. If there is a
// match, this message will be pushed to all clients, subscribed to that query.
// See query subpackage for our implementation.
//
// Overflow strategies (incoming publish requests):
//
// 1) drop - drops publish requests when there are too many of them
// 2) wait - blocks until the server is ready to accept more publish requests (default)
//
// Subscribe/Unsubscribe calls are always blocking.
//
// Overflow strategies (outgoing messages):
//
// 1) skip - do not send a message if the client is busy or slow (default)
// 2) wait - wait until the client is ready to accept new messages
//
package pubsub
import (
"errors"
cmn "github.com/tendermint/tmlibs/common"
"github.com/tendermint/tmlibs/log"
)
type operation int
const (
sub operation = iota
pub
unsub
shutdown
)
type overflowStrategy int
const (
drop overflowStrategy = iota
wait
)
var (
ErrorOverflow = errors.New("Server overflowed")
)
type cmd struct {
op operation
query Query
ch chan<- interface{}
clientID string
msg interface{}
tags map[string]interface{}
}
// Query defines an interface for a query to be used for subscribing.
type Query interface {
Matches(tags map[string]interface{}) bool
}
// Server allows clients to subscribe/unsubscribe for messages, pubsling
// messages with or without tags, and manages internal state.
type Server struct {
cmn.BaseService
cmds chan cmd
overflowStrategy overflowStrategy
slowClientStrategy overflowStrategy
}
// Option sets a parameter for the server.
type Option func(*Server)
// NewServer returns a new server. See the commentary on the Option functions
// for a detailed description of how to configure buffering and overflow
// behavior. If no options are provided, the resulting server's queue is
// unbuffered and it blocks when overflowed.
func NewServer(options ...Option) *Server {
s := &Server{overflowStrategy: wait, slowClientStrategy: drop}
s.BaseService = *cmn.NewBaseService(nil, "PubSub", s)
for _, option := range options {
option(s)
}
if s.cmds == nil { // if BufferCapacity was not set, create unbuffered channel
s.cmds = make(chan cmd)
}
return s
}
// BufferCapacity allows you to specify capacity for the internal server's
// queue. Since the server, given Y subscribers, could only process X messages,
// this option could be used to survive spikes (e.g. high amount of
// transactions during peak hours).
func BufferCapacity(cap int) Option {
return func(s *Server) {
if cap > 0 {
s.cmds = make(chan cmd, cap)
}
}
}
// OverflowStrategyDrop will tell the server to drop messages when it can't
// process more messages.
func OverflowStrategyDrop() Option {
return func(s *Server) {
s.overflowStrategy = drop
}
}
// OverflowStrategyWait will tell the server to block and wait for some time
// for server to process other messages. Default strategy.
func OverflowStrategyWait() func(*Server) {
return func(s *Server) {
s.overflowStrategy = wait
}
}
// WaitSlowClients will tell the server to block and wait until subscriber
// reads a messages even if it is fast enough to process them.
func WaitSlowClients() func(*Server) {
return func(s *Server) {
s.slowClientStrategy = wait
}
}
// SkipSlowClients will tell the server to skip subscriber if it is busy
// processing previous message(s). Default strategy.
func SkipSlowClients() func(*Server) {
return func(s *Server) {
s.slowClientStrategy = drop
}
}
// Subscribe returns a channel on which messages matching the given query can
// be received. If the subscription already exists old channel will be closed
// and new one returned.
func (s *Server) Subscribe(clientID string, query Query, out chan<- interface{}) {
s.cmds <- cmd{op: sub, clientID: clientID, query: query, ch: out}
}
// Unsubscribe unsubscribes the given client from the query.
func (s *Server) Unsubscribe(clientID string, query Query) {
s.cmds <- cmd{op: unsub, clientID: clientID, query: query}
}
// Unsubscribe unsubscribes the given channel.
func (s *Server) UnsubscribeAll(clientID string) {
s.cmds <- cmd{op: unsub, clientID: clientID}
}
// Publish publishes the given message.
func (s *Server) Publish(msg interface{}) error {
return s.PublishWithTags(msg, make(map[string]interface{}))
}
// PublishWithTags publishes the given message with a set of tags. This set of
// tags will be matched with client queries. If there is a match, the message
// will be sent to a client.
func (s *Server) PublishWithTags(msg interface{}, tags map[string]interface{}) error {
pubCmd := cmd{op: pub, msg: msg, tags: tags}
switch s.overflowStrategy {
case drop:
select {
case s.cmds <- pubCmd:
default:
s.Logger.Error("Server overflowed, dropping message...", "msg", msg)
return ErrorOverflow
}
case wait:
s.cmds <- pubCmd
}
return nil
}
// OnStop implements Service.OnStop by shutting down the server.
func (s *Server) OnStop() {
s.cmds <- cmd{op: shutdown}
}
// NOTE: not goroutine safe
type state struct {
// query -> client -> ch
queries map[Query]map[string]chan<- interface{}
// client -> query -> struct{}
clients map[string]map[Query]struct{}
}
// OnStart implements Service.OnStart by creating a main loop.
func (s *Server) OnStart() error {
go s.loop(state{
queries: make(map[Query]map[string]chan<- interface{}),
clients: make(map[string]map[Query]struct{}),
})
return nil
}
func (s *Server) loop(state state) {
loop:
for cmd := range s.cmds {
switch cmd.op {
case unsub:
if cmd.query != nil {
state.remove(cmd.clientID, cmd.query)
} else {
state.removeAll(cmd.clientID)
}
case shutdown:
state.reset()
break loop
case sub:
state.add(cmd.clientID, cmd.query, cmd.ch)
case pub:
state.send(cmd.msg, cmd.tags, s.slowClientStrategy, s.Logger)
}
}
}
func (state *state) add(clientID string, q Query, ch chan<- interface{}) {
// add query if needed
if clientToChannelMap, ok := state.queries[q]; !ok {
state.queries[q] = make(map[string]chan<- interface{})
} else {
// check if already subscribed
if oldCh, ok := clientToChannelMap[clientID]; ok {
close(oldCh)
}
}
state.queries[q][clientID] = ch
// add client if needed
if _, ok := state.clients[clientID]; !ok {
state.clients[clientID] = make(map[Query]struct{})
}
state.clients[clientID][q] = struct{}{}
// create subscription
clientToChannelMap := state.queries[q]
clientToChannelMap[clientID] = ch
}
func (state *state) remove(clientID string, q Query) {
clientToChannelMap, ok := state.queries[q]
if !ok {
return
}
ch, ok := clientToChannelMap[clientID]
if ok {
close(ch)
delete(state.clients[clientID], q)
// if it not subscribed to anything else, remove the client
if len(state.clients[clientID]) == 0 {
delete(state.clients, clientID)
}
delete(state.queries[q], clientID)
}
}
func (state *state) removeAll(clientID string) {
queryMap, ok := state.clients[clientID]
if !ok {
return
}
for q, _ := range queryMap {
ch := state.queries[q][clientID]
close(ch)
delete(state.queries[q], clientID)
}
delete(state.clients, clientID)
}
func (state *state) reset() {
state.queries = make(map[Query]map[string]chan<- interface{})
state.clients = make(map[string]map[Query]struct{})
}
func (state *state) send(msg interface{}, tags map[string]interface{}, slowClientStrategy overflowStrategy, logger log.Logger) {
for q, clientToChannelMap := range state.queries {
// NOTE we can use LRU cache to speed up common cases like query = "
// tm.events.type=NewBlock" and tags = {"tm.events.type": "NewBlock"}
if q.Matches(tags) {
for clientID, ch := range clientToChannelMap {
logger.Info("Sending message to client", "msg", msg, "client", clientID)
switch slowClientStrategy {
case drop:
select {
case ch <- msg:
default:
logger.Error("Client is busy, skipping...", "clientID", clientID)
}
case wait:
ch <- msg
}
}
}
}
}

+ 227
- 0
pubsub/pubsub_test.go View File

@ -0,0 +1,227 @@
package pubsub_test
import (
"fmt"
"runtime/debug"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tendermint/tmlibs/log"
"github.com/tendermint/tmlibs/pubsub"
"github.com/tendermint/tmlibs/pubsub/query"
)
const (
clientID = "test-client"
)
func TestSubscribe(t *testing.T) {
s := pubsub.NewServer()
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch := make(chan interface{}, 1)
s.Subscribe(clientID, query.Empty{}, ch)
err := s.Publish("Ka-Zar")
require.NoError(t, err)
assertReceive(t, "Ka-Zar", ch)
err = s.Publish("Quicksilver")
require.NoError(t, err)
assertReceive(t, "Quicksilver", ch)
}
func TestDifferentClients(t *testing.T) {
s := pubsub.NewServer()
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch1 := make(chan interface{}, 1)
s.Subscribe("client-1", query.MustParse("tm.events.type=NewBlock"), ch1)
err := s.PublishWithTags("Iceman", map[string]interface{}{"tm.events.type": "NewBlock"})
require.NoError(t, err)
assertReceive(t, "Iceman", ch1)
ch2 := make(chan interface{}, 1)
s.Subscribe("client-2", query.MustParse("tm.events.type=NewBlock AND abci.account.name=Igor"), ch2)
err = s.PublishWithTags("Ultimo", map[string]interface{}{"tm.events.type": "NewBlock", "abci.account.name": "Igor"})
require.NoError(t, err)
assertReceive(t, "Ultimo", ch1)
assertReceive(t, "Ultimo", ch2)
ch3 := make(chan interface{}, 1)
s.Subscribe("client-3", query.MustParse("tm.events.type=NewRoundStep AND abci.account.name=Igor AND abci.invoice.number = 10"), ch3)
err = s.PublishWithTags("Valeria Richards", map[string]interface{}{"tm.events.type": "NewRoundStep"})
require.NoError(t, err)
assert.Zero(t, len(ch3))
}
func TestClientResubscribes(t *testing.T) {
s := pubsub.NewServer()
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
q := query.MustParse("tm.events.type=NewBlock")
ch1 := make(chan interface{}, 1)
s.Subscribe(clientID, q, ch1)
err := s.PublishWithTags("Goblin Queen", map[string]interface{}{"tm.events.type": "NewBlock"})
require.NoError(t, err)
assertReceive(t, "Goblin Queen", ch1)
ch2 := make(chan interface{}, 1)
s.Subscribe(clientID, q, ch2)
_, ok := <-ch1
assert.False(t, ok)
err = s.PublishWithTags("Spider-Man", map[string]interface{}{"tm.events.type": "NewBlock"})
require.NoError(t, err)
assertReceive(t, "Spider-Man", ch2)
}
func TestUnsubscribe(t *testing.T) {
s := pubsub.NewServer()
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch := make(chan interface{})
s.Subscribe(clientID, query.Empty{}, ch)
s.Unsubscribe(clientID, query.Empty{})
err := s.Publish("Nick Fury")
require.NoError(t, err)
assert.Zero(t, len(ch), "Should not receive anything after Unsubscribe")
_, ok := <-ch
assert.False(t, ok)
}
func TestUnsubscribeAll(t *testing.T) {
s := pubsub.NewServer()
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch1, ch2 := make(chan interface{}, 1), make(chan interface{}, 1)
s.Subscribe(clientID, query.MustParse("tm.events.type=NewBlock"), ch1)
s.Subscribe(clientID, query.MustParse("tm.events.type=NewBlockHeader"), ch2)
s.UnsubscribeAll(clientID)
err := s.Publish("Nick Fury")
require.NoError(t, err)
assert.Zero(t, len(ch1), "Should not receive anything after UnsubscribeAll")
assert.Zero(t, len(ch2), "Should not receive anything after UnsubscribeAll")
_, ok := <-ch1
assert.False(t, ok)
_, ok = <-ch2
assert.False(t, ok)
}
func TestOverflowStrategyDrop(t *testing.T) {
s := pubsub.NewServer(pubsub.OverflowStrategyDrop())
s.SetLogger(log.TestingLogger())
err := s.Publish("Veda")
if assert.Error(t, err) {
assert.Equal(t, pubsub.ErrorOverflow, err)
}
}
func TestOverflowStrategyWait(t *testing.T) {
s := pubsub.NewServer(pubsub.OverflowStrategyWait())
s.SetLogger(log.TestingLogger())
go func() {
time.Sleep(1 * time.Second)
s.Start()
defer s.Stop()
}()
err := s.Publish("Veda")
assert.NoError(t, err)
}
func TestBufferCapacity(t *testing.T) {
s := pubsub.NewServer(pubsub.BufferCapacity(2))
s.SetLogger(log.TestingLogger())
err := s.Publish("Nighthawk")
require.NoError(t, err)
err = s.Publish("Sage")
require.NoError(t, err)
}
func TestWaitSlowClients(t *testing.T) {
s := pubsub.NewServer(pubsub.WaitSlowClients())
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch := make(chan interface{})
s.Subscribe(clientID, query.Empty{}, ch)
err := s.Publish("Wonderwoman")
require.NoError(t, err)
time.Sleep(1 * time.Second)
assertReceive(t, "Wonderwoman", ch)
}
func TestSkipSlowClients(t *testing.T) {
s := pubsub.NewServer(pubsub.SkipSlowClients())
s.SetLogger(log.TestingLogger())
s.Start()
defer s.Stop()
ch := make(chan interface{})
s.Subscribe(clientID, query.Empty{}, ch)
err := s.Publish("Cyclops")
require.NoError(t, err)
assert.Zero(t, len(ch))
}
func Benchmark10Clients(b *testing.B) { benchmarkNClients(10, b) }
func Benchmark100Clients(b *testing.B) { benchmarkNClients(100, b) }
func Benchmark1000Clients(b *testing.B) { benchmarkNClients(1000, b) }
func benchmarkNClients(n int, b *testing.B) {
s := pubsub.NewServer(pubsub.BufferCapacity(b.N))
s.Start()
defer s.Stop()
for i := 0; i < n; i++ {
ch := make(chan interface{})
s.Subscribe(clientID, query.MustParse(fmt.Sprintf("abci.Account.Owner = Ivan AND abci.Invoices.Number = %d", i)), ch)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.PublishWithTags("Gamora", map[string]interface{}{"abci.Account.Owner": "Ivan", "abci.Invoices.Number": i})
}
}
///////////////////////////////////////////////////////////////////////////////
/// HELPERS
///////////////////////////////////////////////////////////////////////////////
func assertReceive(t *testing.T, expected interface{}, ch <-chan interface{}, msgAndArgs ...interface{}) {
select {
case actual := <-ch:
if actual != nil {
assert.Equal(t, expected, actual, msgAndArgs...)
}
case <-time.After(1 * time.Second):
t.Errorf("Expected to receive %v from the channel, got nothing after 1s", expected)
debug.PrintStack()
}
}

+ 11
- 0
pubsub/query/Makefile View File

@ -0,0 +1,11 @@
gen_query_parser:
@go get github.com/pointlander/peg
peg -inline -switch query.peg
fuzzy_test:
@go get github.com/dvyukov/go-fuzz/go-fuzz
@go get github.com/dvyukov/go-fuzz/go-fuzz-build
go-fuzz-build github.com/tendermint/tmlibs/pubsub/query/fuzz_test
go-fuzz -bin=./fuzz_test-fuzz.zip -workdir=./fuzz_test/output
.PHONY: gen_query_parser fuzzy_test

+ 14
- 0
pubsub/query/empty.go View File

@ -0,0 +1,14 @@
package query
// Empty query matches any set of tags.
type Empty struct {
}
// Matches always returns true.
func (Empty) Matches(tags map[string]interface{}) bool {
return true
}
func (Empty) String() string {
return "empty"
}

+ 16
- 0
pubsub/query/empty_test.go View File

@ -0,0 +1,16 @@
package query_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tendermint/tmlibs/pubsub/query"
)
func TestEmptyQueryMatchesAnything(t *testing.T) {
q := query.Empty{}
assert.True(t, q.Matches(map[string]interface{}{}))
assert.True(t, q.Matches(map[string]interface{}{"Asher": "Roth"}))
assert.True(t, q.Matches(map[string]interface{}{"Route": 66}))
assert.True(t, q.Matches(map[string]interface{}{"Route": 66, "Billy": "Blue"}))
}

+ 30
- 0
pubsub/query/fuzz_test/main.go View File

@ -0,0 +1,30 @@
package fuzz_test
import (
"fmt"
"github.com/tendermint/tmlibs/pubsub/query"
)
func Fuzz(data []byte) int {
sdata := string(data)
q0, err := query.New(sdata)
if err != nil {
return 0
}
sdata1 := q0.String()
q1, err := query.New(sdata1)
if err != nil {
panic(err)
}
sdata2 := q1.String()
if sdata1 != sdata2 {
fmt.Printf("q0: %q\n", sdata1)
fmt.Printf("q1: %q\n", sdata2)
panic("query changed")
}
return 1
}

+ 81
- 0
pubsub/query/parser_test.go View File

@ -0,0 +1,81 @@
package query_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tendermint/tmlibs/pubsub/query"
)
// TODO: fuzzy testing?
func TestParser(t *testing.T) {
cases := []struct {
query string
valid bool
}{
{"tm.events.type=NewBlock", true},
{"tm.events.type = NewBlock", true},
{"tm.events.type=TIME", true},
{"tm.events.type=DATE", true},
{"tm.events.type==", false},
{">==", false},
{"tm.events.type NewBlock =", false},
{"tm.events.type>NewBlock", false},
{"", false},
{"=", false},
{"=NewBlock", false},
{"tm.events.type=", false},
{"tm.events.typeNewBlock", false},
{"NewBlock", false},
{"", false},
{"tm.events.type=NewBlock AND abci.account.name=Igor", true},
{"tm.events.type=NewBlock AND", false},
{"tm.events.type=NewBlock AN", false},
{"tm.events.type=NewBlock AN tm.events.type=NewBlockHeader", false},
{"AND tm.events.type=NewBlock ", false},
{"abci.account.name CONTAINS Igor", true},
{"tx.date > DATE 2013-05-03", true},
{"tx.date < DATE 2013-05-03", true},
{"tx.date <= DATE 2013-05-03", true},
{"tx.date >= DATE 2013-05-03", true},
{"tx.date >= DAT 2013-05-03", false},
{"tx.date <= DATE2013-05-03", false},
{"tx.date <= DATE -05-03", false},
{"tx.date >= DATE 20130503", false},
{"tx.date >= DATE 2013+01-03", false},
// incorrect year, month, day
{"tx.date >= DATE 0013-01-03", false},
{"tx.date >= DATE 2013-31-03", false},
{"tx.date >= DATE 2013-01-83", false},
{"tx.date > TIME 2013-05-03T14:45:00+07:00", true},
{"tx.date < TIME 2013-05-03T14:45:00-02:00", true},
{"tx.date <= TIME 2013-05-03T14:45:00Z", true},
{"tx.date >= TIME 2013-05-03T14:45:00Z", true},
{"tx.date >= TIME2013-05-03T14:45:00Z", false},
{"tx.date = IME 2013-05-03T14:45:00Z", false},
{"tx.date = TIME 2013-05-:45:00Z", false},
{"tx.date >= TIME 2013-05-03T14:45:00", false},
{"tx.date >= TIME 0013-00-00T14:45:00Z", false},
{"tx.date >= TIME 2013+05=03T14:45:00Z", false},
{"account.balance=100", true},
{"account.balance >= 200", true},
{"account.balance >= -300", false},
{"account.balance >>= 400", false},
{"account.balance=33.22.1", false},
}
for _, c := range cases {
_, err := query.New(c.query)
if c.valid {
assert.NoError(t, err, "Query was '%s'", c.query)
} else {
assert.Error(t, err, "Query was '%s'", c.query)
}
}
}

+ 258
- 0
pubsub/query/query.go View File

@ -0,0 +1,258 @@
// Package query provides a parser for a custom query format:
//
// abci.invoice.number=22 AND abci.invoice.owner=Ivan
//
// See query.peg for the grammar, which is a https://en.wikipedia.org/wiki/Parsing_expression_grammar.
// More: https://github.com/PhilippeSigaud/Pegged/wiki/PEG-Basics
//
// It has a support for numbers (integer and floating point), dates and times.
package query
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
)
// Query holds the query string and the query parser.
type Query struct {
str string
parser *QueryParser
}
// New parses the given string and returns a query or error if the string is
// invalid.
func New(s string) (*Query, error) {
p := &QueryParser{Buffer: fmt.Sprintf(`"%s"`, s)}
p.Init()
if err := p.Parse(); err != nil {
return nil, err
}
return &Query{str: s, parser: p}, nil
}
// MustParse turns the given string into a query or panics; for tests or others
// cases where you know the string is valid.
func MustParse(s string) *Query {
q, err := New(s)
if err != nil {
panic(fmt.Sprintf("failed to parse %s: %v", s, err))
}
return q
}
// String returns the original string.
func (q *Query) String() string {
return q.str
}
type operator uint8
const (
opLessEqual operator = iota
opGreaterEqual
opLess
opGreater
opEqual
opContains
)
// Matches returns true if the query matches the given set of tags, false otherwise.
//
// For example, query "name=John" matches tags = {"name": "John"}. More
// examples could be found in parser_test.go and query_test.go.
func (q *Query) Matches(tags map[string]interface{}) bool {
if len(tags) == 0 {
return false
}
buffer, begin, end := q.parser.Buffer, 0, 0
var tag string
var op operator
// tokens must be in the following order: tag ("tx.gas") -> operator ("=") -> operand ("7")
for _, token := range q.parser.Tokens() {
switch token.pegRule {
case rulePegText:
begin, end = int(token.begin), int(token.end)
case ruletag:
tag = buffer[begin:end]
case rulele:
op = opLessEqual
case rulege:
op = opGreaterEqual
case rulel:
op = opLess
case ruleg:
op = opGreater
case ruleequal:
op = opEqual
case rulecontains:
op = opContains
case rulevalue:
// see if the triplet (tag, operator, operand) matches any tag
// "tx.gas", "=", "7", { "tx.gas": 7, "tx.ID": "4AE393495334" }
if !match(tag, op, reflect.ValueOf(buffer[begin:end]), tags) {
return false
}
case rulenumber:
number := buffer[begin:end]
if strings.Contains(number, ".") { // if it looks like a floating-point number
value, err := strconv.ParseFloat(number, 64)
if err != nil {
panic(fmt.Sprintf("got %v while trying to parse %s as float64 (should never happen if the grammar is correct)", err, number))
}
if !match(tag, op, reflect.ValueOf(value), tags) {
return false
}
} else {
value, err := strconv.ParseInt(number, 10, 64)
if err != nil {
panic(fmt.Sprintf("got %v while trying to parse %s as int64 (should never happen if the grammar is correct)", err, number))
}
if !match(tag, op, reflect.ValueOf(value), tags) {
return false
}
}
case ruletime:
value, err := time.Parse(time.RFC3339, buffer[begin:end])
if err != nil {
panic(fmt.Sprintf("got %v while trying to parse %s as time.Time / RFC3339 (should never happen if the grammar is correct)", err, buffer[begin:end]))
}
if !match(tag, op, reflect.ValueOf(value), tags) {
return false
}
case ruledate:
value, err := time.Parse("2006-01-02", buffer[begin:end])
if err != nil {
panic(fmt.Sprintf("got %v while trying to parse %s as time.Time / '2006-01-02' (should never happen if the grammar is correct)", err, buffer[begin:end]))
}
if !match(tag, op, reflect.ValueOf(value), tags) {
return false
}
}
}
return true
}
// match returns true if the given triplet (tag, operator, operand) matches any tag.
//
// First, it looks up the tag in tags and if it finds one, tries to compare the
// value from it to the operand using the operator.
//
// "tx.gas", "=", "7", { "tx.gas": 7, "tx.ID": "4AE393495334" }
func match(tag string, op operator, operand reflect.Value, tags map[string]interface{}) bool {
// look up the tag from the query in tags
value, ok := tags[tag]
if !ok {
return false
}
switch operand.Kind() {
case reflect.Struct: // time
operandAsTime := operand.Interface().(time.Time)
v, ok := value.(time.Time)
if !ok { // if value from tags is not time.Time
return false
}
switch op {
case opLessEqual:
return v.Before(operandAsTime) || v.Equal(operandAsTime)
case opGreaterEqual:
return v.Equal(operandAsTime) || v.After(operandAsTime)
case opLess:
return v.Before(operandAsTime)
case opGreater:
return v.After(operandAsTime)
case opEqual:
return v.Equal(operandAsTime)
}
case reflect.Float64:
operandFloat64 := operand.Interface().(float64)
var v float64
// try our best to convert value from tags to float64
switch vt := value.(type) {
case float64:
v = vt
case float32:
v = float64(vt)
case int:
v = float64(vt)
case int8:
v = float64(vt)
case int16:
v = float64(vt)
case int32:
v = float64(vt)
case int64:
v = float64(vt)
default: // fail for all other types
panic(fmt.Sprintf("Incomparable types: %T (%v) vs float64 (%v)", value, value, operandFloat64))
}
switch op {
case opLessEqual:
return v <= operandFloat64
case opGreaterEqual:
return v >= operandFloat64
case opLess:
return v < operandFloat64
case opGreater:
return v > operandFloat64
case opEqual:
return v == operandFloat64
}
case reflect.Int64:
operandInt := operand.Interface().(int64)
var v int64
// try our best to convert value from tags to int64
switch vt := value.(type) {
case int64:
v = vt
case int8:
v = int64(vt)
case int16:
v = int64(vt)
case int32:
v = int64(vt)
case int:
v = int64(vt)
case float64:
v = int64(vt)
case float32:
v = int64(vt)
default: // fail for all other types
panic(fmt.Sprintf("Incomparable types: %T (%v) vs int64 (%v)", value, value, operandInt))
}
switch op {
case opLessEqual:
return v <= operandInt
case opGreaterEqual:
return v >= operandInt
case opLess:
return v < operandInt
case opGreater:
return v > operandInt
case opEqual:
return v == operandInt
}
case reflect.String:
v, ok := value.(string)
if !ok { // if value from tags is not string
return false
}
switch op {
case opEqual:
return v == operand.String()
case opContains:
return strings.Contains(v, operand.String())
}
default:
panic(fmt.Sprintf("Unknown kind of operand %v", operand.Kind()))
}
return false
}

+ 33
- 0
pubsub/query/query.peg View File

@ -0,0 +1,33 @@
package query
type QueryParser Peg {
}
e <- '\"' condition ( ' '+ and ' '+ condition )* '\"' !.
condition <- tag ' '* (le ' '* (number / time / date)
/ ge ' '* (number / time / date)
/ l ' '* (number / time / date)
/ g ' '* (number / time / date)
/ equal ' '* (number / time / date / value)
/ contains ' '* value
)
tag <- < (![ \t\n\r\\()"=><] .)+ >
value <- < (![ \t\n\r\\()"=><] .)+ >
number <- < ('0'
/ [1-9] digit* ('.' digit*)?) >
digit <- [0-9]
time <- "TIME " < year '-' month '-' day 'T' digit digit ':' digit digit ':' digit digit (('-' / '+') digit digit ':' digit digit / 'Z') >
date <- "DATE " < year '-' month '-' day >
year <- ('1' / '2') digit digit digit
month <- ('0' / '1') digit
day <- ('0' / '1' / '2' / '3') digit
and <- "AND"
equal <- "="
contains <- "CONTAINS"
le <- "<="
ge <- ">="
l <- "<"
g <- ">"

+ 1668
- 0
pubsub/query/query.peg.go
File diff suppressed because it is too large
View File


+ 64
- 0
pubsub/query/query_test.go View File

@ -0,0 +1,64 @@
package query_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tendermint/tmlibs/pubsub/query"
)
func TestMatches(t *testing.T) {
const shortForm = "2006-Jan-02"
txDate, err := time.Parse(shortForm, "2017-Jan-01")
require.NoError(t, err)
txTime, err := time.Parse(time.RFC3339, "2018-05-03T14:45:00Z")
require.NoError(t, err)
testCases := []struct {
s string
tags map[string]interface{}
err bool
matches bool
}{
{"tm.events.type=NewBlock", map[string]interface{}{"tm.events.type": "NewBlock"}, false, true},
{"tx.gas > 7", map[string]interface{}{"tx.gas": 8}, false, true},
{"tx.gas > 7 AND tx.gas < 9", map[string]interface{}{"tx.gas": 8}, false, true},
{"body.weight >= 3.5", map[string]interface{}{"body.weight": 3.5}, false, true},
{"account.balance < 1000.0", map[string]interface{}{"account.balance": 900}, false, true},
{"apples.kg <= 4", map[string]interface{}{"apples.kg": 4.0}, false, true},
{"body.weight >= 4.5", map[string]interface{}{"body.weight": float32(4.5)}, false, true},
{"oranges.kg < 4 AND watermellons.kg > 10", map[string]interface{}{"oranges.kg": 3, "watermellons.kg": 12}, false, true},
{"peaches.kg < 4", map[string]interface{}{"peaches.kg": 5}, false, false},
{"tx.date > DATE 2017-01-01", map[string]interface{}{"tx.date": time.Now()}, false, true},
{"tx.date = DATE 2017-01-01", map[string]interface{}{"tx.date": txDate}, false, true},
{"tx.date = DATE 2018-01-01", map[string]interface{}{"tx.date": txDate}, false, false},
{"tx.time >= TIME 2013-05-03T14:45:00Z", map[string]interface{}{"tx.time": time.Now()}, false, true},
{"tx.time = TIME 2013-05-03T14:45:00Z", map[string]interface{}{"tx.time": txTime}, false, false},
{"abci.owner.name CONTAINS Igor", map[string]interface{}{"abci.owner.name": "Igor,Ivan"}, false, true},
{"abci.owner.name CONTAINS Igor", map[string]interface{}{"abci.owner.name": "Pavel,Ivan"}, false, false},
}
for _, tc := range testCases {
query, err := query.New(tc.s)
if !tc.err {
require.Nil(t, err)
}
if tc.matches {
assert.True(t, query.Matches(tc.tags), "Query '%s' should match %v", tc.s, tc.tags)
} else {
assert.False(t, query.Matches(tc.tags), "Query '%s' should not match %v", tc.s, tc.tags)
}
}
}
func TestMustParse(t *testing.T) {
assert.Panics(t, func() { query.MustParse("=") })
assert.NotPanics(t, func() { query.MustParse("tm.events.type=NewBlock") })
}

Loading…
Cancel
Save