├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd └── fabric │ └── main.go ├── export.go ├── export_test.go ├── fabric.go ├── fabric_test.go ├── fql.go ├── go.mod ├── go.sum ├── inmem.go ├── query.go ├── query_test.go ├── server ├── httpapi.go ├── middlewares.go └── plot.go ├── sql.go ├── store.go ├── triple.go └── triple_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | vendor/ 3 | 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | 18 | .DS_Store 19 | 20 | 21 | # custom ignores 22 | expt/ 23 | .vscode/ 24 | dev/ 25 | .idea/ 26 | 27 | test.db 28 | fabric.db 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Shivaprasad Bhat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean build tidy test lint 2 | 3 | install: 4 | @echo "Installing Fabric to GOBIN..." 5 | @go install ./cmd/fabric 6 | 7 | clean: 8 | @echo "Cleaning up..." 9 | @rm -rf ./bin/ 10 | 11 | test: 12 | @echo "Running tests..." 13 | @go test -cover ./... 14 | 15 | lint: 16 | @echo "Running linter..." 17 | @golint ./... 18 | 19 | build: 20 | @echo "Building..." 21 | @mkdir ./bin/ 22 | @go build -o bin/fabric ./cmd/fabric/*.go 23 | 24 | tidy: 25 | @echo "Tidy up go mod files..." 26 | @go mod tidy -v 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fabric 2 | 3 | [![GoDoc](https://godoc.org/github.com/spy16/fabric?status.svg)](https://godoc.org/github.com/spy16/fabric) [![Go Report Card](https://goreportcard.com/badge/github.com/spy16/fabric)](https://goreportcard.com/report/github.com/spy16/fabric) 4 | 5 | Fabric is a triple-store written in `Go`. Fabric provides simple functions 6 | and store options to deal with "Subject->Predicate->Object" relations or so called 7 | triples. 8 | 9 | ## Usage 10 | 11 | Get fabric by using `go get -u github.com/spy16/fabric` (Fabric as a library has no 12 | external dependencies) 13 | 14 | ```go 15 | // See next snippet for using persistent SQL backend 16 | fab := fabric.New(&fabric.InMemoryStore{}) 17 | 18 | fab.Insert(context.Background(), fabric.Triple{ 19 | Source: "Bob", 20 | Predicate: "Knows", 21 | Target: "John", 22 | }) 23 | 24 | fab.Query(context.Background(), fabric.Query{ 25 | Source: fabric.Clause{ 26 | Type: "equal", 27 | Value: "Bob", 28 | }, 29 | }) 30 | ``` 31 | 32 | To use a SQL database for storing the triples, use the following snippet: 33 | 34 | ```go 35 | db, err := sql.Open("sqlite3", "fabric.db") 36 | if err != nil { 37 | panic(err) 38 | } 39 | 40 | store := &fabric.SQLStore{DB: db} 41 | store.Setup(context.Background()) // to create required tables 42 | 43 | fab := fabric.New(store) 44 | ``` 45 | 46 | > Fabric `SQLStore` uses Go's standard `database/sql` package. So any SQL database 47 | > supported through this interface (includes most major SQL databases) should work. 48 | 49 | Additional store support can be added by implementing the `Store` interface. 50 | 51 | ```go 52 | type Store interface { 53 | Insert(ctx context.Context, tri Triple) error 54 | Query(ctx context.Context, q Query) ([]Triple, error) 55 | Delete(ctx context.Context, q Query) (int, error) 56 | } 57 | ``` 58 | 59 | Optional `Counter` and `ReWeighter` can be implemented by the store implementations 60 | to support extended query options. 61 | 62 | ## REST API 63 | 64 | The `server` package exposes REST APIs (`/triples` endpoint) which can be used to query, 65 | insert/delete or reweight triples using any HTTP client. 66 | -------------------------------------------------------------------------------- /cmd/fabric/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "flag" 7 | "log" 8 | "net/http" 9 | 10 | _ "github.com/mattn/go-sqlite3" 11 | "github.com/spy16/fabric" 12 | "github.com/spy16/fabric/server" 13 | ) 14 | 15 | var ( 16 | db = flag.String("store", ":memory:", "Storage location") 17 | httpAddr = flag.String("http", ":8080", "HTTP Server Address") 18 | ) 19 | 20 | func main() { 21 | flag.Parse() 22 | 23 | fab := fabric.New(setupStore(*db)) 24 | 25 | mux := server.NewHTTP(fab) 26 | log.Printf("starting HTTP API server on '%s'...", *httpAddr) 27 | log.Fatalf("server exiting: %v", http.ListenAndServe(*httpAddr, mux)) 28 | } 29 | 30 | func setupStore(path string) fabric.Store { 31 | if path == ":memory:" { 32 | return &fabric.InMemoryStore{} 33 | } 34 | 35 | db, err := sql.Open("sqlite3", path) 36 | if err != nil { 37 | log.Fatalf("failed to open db: %v\n", err) 38 | } 39 | 40 | store := &fabric.SQLStore{ 41 | DB: db, 42 | } 43 | 44 | if err := store.Setup(context.Background()); err != nil { 45 | log.Fatalf("failed to setup db: %v", err) 46 | } 47 | 48 | return store 49 | } 50 | -------------------------------------------------------------------------------- /export.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // ExportDOT exports the given set of triples in DOT format. 9 | func ExportDOT(name string, triples []Triple) string { 10 | name = strings.TrimSpace(name) 11 | if name == "" { 12 | name = "fabric" 13 | } 14 | 15 | out := fmt.Sprintf("digraph %s {\n", name) 16 | for _, tri := range triples { 17 | out += fmt.Sprintf(" \"%s\" -> \"%s\" [label=\"%s\" weight=%f];\n", tri.Source, tri.Target, tri.Predicate, tri.Weight) 18 | } 19 | out += "}\n" 20 | return out 21 | } 22 | -------------------------------------------------------------------------------- /export_test.go: -------------------------------------------------------------------------------- 1 | package fabric_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spy16/fabric" 7 | ) 8 | 9 | func TestExportDOT(suite *testing.T) { 10 | suite.Parallel() 11 | 12 | suite.Run("WithNoName", func(t *testing.T) { 13 | expected := "digraph fabric {\n}\n" 14 | out := fabric.ExportDOT("", []fabric.Triple{}) 15 | 16 | if out != expected { 17 | t.Errorf("expected '%s', got '%s'", expected, out) 18 | } 19 | }) 20 | 21 | suite.Run("Normal", func(t *testing.T) { 22 | expected := "digraph hello {\n \"s\" -> \"t\" [label=\"p\" weight=0.000000];\n}\n" 23 | out := fabric.ExportDOT("hello", []fabric.Triple{ 24 | { 25 | Source: "s", 26 | Target: "t", 27 | Predicate: "p", 28 | }, 29 | }) 30 | 31 | if out != expected { 32 | t.Errorf("expected '%s', got '%s'", expected, out) 33 | } 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /fabric.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | ) 7 | 8 | // ErrNotSupported is returned when an operation is not supported. 9 | var ErrNotSupported = errors.New("not supported") 10 | 11 | // New returns a new instance of fabric with given store implementation. 12 | func New(store Store) *Fabric { 13 | if f, ok := store.(*Fabric); ok { 14 | return f 15 | } 16 | 17 | f := &Fabric{} 18 | f.store = store 19 | return f 20 | } 21 | 22 | // Fabric provides functions to query and manage triples. 23 | type Fabric struct { 24 | store Store 25 | } 26 | 27 | // Insert validates the triple and persists it to the store. 28 | func (f *Fabric) Insert(ctx context.Context, tri Triple) error { 29 | if err := tri.Validate(); err != nil { 30 | return err 31 | } 32 | 33 | return f.store.Insert(ctx, tri) 34 | } 35 | 36 | // Query finds all the triples matching the given query. 37 | func (f *Fabric) Query(ctx context.Context, query Query) ([]Triple, error) { 38 | query.normalize() 39 | return f.store.Query(ctx, query) 40 | } 41 | 42 | // Count returns the number of triples matching the query. If the store does 43 | // not implement the Counter interface, standard Query method will be used to 44 | // fetch all triples and the result set length is returned. 45 | func (f *Fabric) Count(ctx context.Context, query Query) (int, error) { 46 | counter, ok := f.store.(Counter) 47 | if ok { 48 | return counter.Count(ctx, query) 49 | } 50 | 51 | arr, err := f.store.Query(ctx, query) 52 | if err != nil { 53 | return 0, err 54 | } 55 | 56 | return len(arr), nil 57 | } 58 | 59 | // Delete removes all the triples from the store matching the given query and 60 | // returns the number of items deleted. 61 | func (f *Fabric) Delete(ctx context.Context, query Query) (int, error) { 62 | return f.store.Delete(ctx, query) 63 | } 64 | 65 | // ReWeight performs weight updates on all triples matching the query, if the 66 | // store implements ReWeighter interface. Otherwise, returns ErrNotSupported. 67 | func (f *Fabric) ReWeight(ctx context.Context, query Query, delta float64, replace bool) (int, error) { 68 | if delta == 0 && !replace { 69 | // adding delta has no effect since it is zero 70 | return 0, errors.New("update has no effect since delta is zero and replace is false") 71 | } 72 | 73 | rew, ok := f.store.(ReWeighter) 74 | if !ok { 75 | return 0, ErrNotSupported 76 | } 77 | 78 | return rew.ReWeight(ctx, query, delta, replace) 79 | } 80 | -------------------------------------------------------------------------------- /fabric_test.go: -------------------------------------------------------------------------------- 1 | package fabric_test 2 | -------------------------------------------------------------------------------- /fql.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spy16/fabric 2 | 3 | go 1.12 4 | 5 | require github.com/mattn/go-sqlite3 v1.9.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= 2 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 3 | -------------------------------------------------------------------------------- /inmem.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | var _ Store = &InMemoryStore{} 14 | var _ ReWeighter = &InMemoryStore{} 15 | var _ Counter = &InMemoryStore{} 16 | 17 | // InMemoryStore implements the Store interface using the golang 18 | // map type. 19 | type InMemoryStore struct { 20 | mu *sync.RWMutex 21 | data map[string]Triple 22 | } 23 | 24 | // Count returns the number of triples in the store matching the given query. 25 | func (mem *InMemoryStore) Count(ctx context.Context, query Query) (int, error) { 26 | if query.IsAny() { 27 | return len(mem.data), nil 28 | } 29 | 30 | triples, err := mem.Query(ctx, query) 31 | if err != nil { 32 | return 0, err 33 | } 34 | 35 | return len(triples), nil 36 | } 37 | 38 | // Insert stores the triple into the in-memory map. 39 | func (mem *InMemoryStore) Insert(ctx context.Context, tri Triple) error { 40 | mem.ensureInit() 41 | 42 | mem.mu.Lock() 43 | defer mem.mu.Unlock() 44 | 45 | if mem.data == nil { 46 | mem.data = map[string]Triple{} 47 | } 48 | 49 | if _, ok := mem.data[mem.idFor(tri)]; ok { 50 | return errors.New("triple already exists") 51 | } 52 | 53 | mem.data[mem.idFor(tri)] = tri 54 | 55 | return nil 56 | } 57 | 58 | // Query returns all the triples matching the given query. 59 | func (mem *InMemoryStore) Query(ctx context.Context, query Query) ([]Triple, error) { 60 | mem.ensureInit() 61 | 62 | mem.mu.RLock() 63 | defer mem.mu.RUnlock() 64 | 65 | triples := []Triple{} 66 | for _, tri := range mem.data { 67 | if query.Limit > 0 && len(triples) >= query.Limit { 68 | break 69 | } 70 | 71 | if query.IsAny() { 72 | triples = append(triples, tri) 73 | continue 74 | } 75 | 76 | match, err := isMatch(tri, query) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if match { 82 | triples = append(triples, tri) 83 | } 84 | 85 | } 86 | 87 | return triples, nil 88 | } 89 | 90 | // Delete removes all the triples that match the given query. 91 | func (mem *InMemoryStore) Delete(ctx context.Context, query Query) (int, error) { 92 | if mem.data == nil { 93 | return 0, nil 94 | } 95 | 96 | triples, err := mem.Query(ctx, query) 97 | if err != nil { 98 | return 0, err 99 | } 100 | 101 | mem.mu.Lock() 102 | defer mem.mu.Unlock() 103 | 104 | for _, tri := range triples { 105 | delete(mem.data, mem.idFor(tri)) 106 | } 107 | 108 | return len(triples), nil 109 | } 110 | 111 | // ReWeight re-weights all the triples matching the query. 112 | func (mem *InMemoryStore) ReWeight(ctx context.Context, query Query, delta float64, replace bool) (int, error) { 113 | triples, err := mem.Query(ctx, query) 114 | if err != nil { 115 | return 0, err 116 | } 117 | 118 | mem.mu.Lock() 119 | defer mem.mu.Unlock() 120 | 121 | for _, tri := range triples { 122 | if replace { 123 | tri.Weight = delta 124 | } else { 125 | tri.Weight += delta 126 | } 127 | 128 | mem.data[mem.idFor(tri)] = tri 129 | } 130 | 131 | return len(triples), nil 132 | } 133 | 134 | func (mem *InMemoryStore) ensureInit() { 135 | if mem.mu == nil { 136 | mem.mu = new(sync.RWMutex) 137 | } 138 | } 139 | 140 | func (mem *InMemoryStore) idFor(tri Triple) string { 141 | return fmt.Sprintf("%s %s %s", tri.Source, tri.Predicate, tri.Target) 142 | } 143 | 144 | func isMatch(tri Triple, query Query) (bool, error) { 145 | matchers := []matcher{ 146 | matchClause(tri.Source, query.Source), 147 | matchClause(tri.Predicate, query.Predicate), 148 | matchClause(tri.Target, query.Target), 149 | } 150 | 151 | for _, matcher := range matchers { 152 | match, err := matcher() 153 | if err != nil { 154 | return false, err 155 | } 156 | 157 | if !match { 158 | return false, nil 159 | } 160 | } 161 | 162 | match, err := isWeightMatch(tri.Weight, query.Weight) 163 | if err != nil { 164 | return false, err 165 | } 166 | 167 | return match, nil 168 | } 169 | 170 | func matchClause(actual string, clause Clause) matcher { 171 | if clause.IsAny() { 172 | return func() (bool, error) { 173 | return true, nil 174 | } 175 | } 176 | 177 | switch clause.Type { 178 | case "=", "==", "equal": 179 | return func() (bool, error) { 180 | return clause.Value == actual, nil 181 | } 182 | 183 | case "~", "~=", "like": 184 | return func() (bool, error) { 185 | exp := strings.Replace(clause.Value, "*", ".*", -1) 186 | re, err := regexp.Compile(exp) 187 | if err != nil { 188 | return false, err 189 | } 190 | 191 | return re.MatchString(actual), nil 192 | } 193 | 194 | } 195 | 196 | return func() (bool, error) { 197 | return false, fmt.Errorf("clause type '%s' not supported", clause.Type) 198 | } 199 | } 200 | 201 | func isWeightMatch(actual float64, clause Clause) (bool, error) { 202 | if clause.IsAny() { 203 | return true, nil 204 | } 205 | 206 | w, err := strconv.ParseFloat(clause.Value, 64) 207 | if err != nil { 208 | return false, err 209 | } 210 | 211 | switch clause.Type { 212 | case "=", "==", "equal": 213 | return actual == w, nil 214 | 215 | case ">=", "gte": 216 | return actual >= w, nil 217 | 218 | case "<=", "lte": 219 | return actual <= w, nil 220 | 221 | case ">", "gt": 222 | return actual > w, nil 223 | 224 | case "<", "lt": 225 | return actual < w, nil 226 | } 227 | 228 | return false, nil 229 | } 230 | 231 | type matcher func() (bool, error) 232 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Query represents a query to identify one or more triples. 8 | type Query struct { 9 | Source Clause `json:"source,omitempty"` 10 | Predicate Clause `json:"predicate,omitempty"` 11 | Target Clause `json:"target,omitempty"` 12 | Weight Clause `json:"weight,omitempty"` 13 | Limit int `json:"limit,omitempty"` 14 | } 15 | 16 | // IsAny returns true if all clauses are any clauses. 17 | func (q Query) IsAny() bool { 18 | return (q.Source.IsAny() && q.Predicate.IsAny() && 19 | q.Target.IsAny() && q.Weight.IsAny()) 20 | } 21 | 22 | // Map returns a map version of the query with all the any clauses removed. 23 | func (q Query) Map() map[string]Clause { 24 | m := map[string]Clause{} 25 | if !q.Source.IsAny() { 26 | m["source"] = q.Source 27 | } 28 | 29 | if !q.Predicate.IsAny() { 30 | m["predicate"] = q.Predicate 31 | } 32 | 33 | if !q.Target.IsAny() { 34 | m["target"] = q.Target 35 | } 36 | 37 | if !q.Weight.IsAny() { 38 | m["weight"] = q.Weight 39 | } 40 | 41 | return m 42 | } 43 | 44 | func (q *Query) normalize() { 45 | q.Source.normalize() 46 | q.Target.normalize() 47 | q.Predicate.normalize() 48 | q.Weight.normalize() 49 | } 50 | 51 | // Clause represents a query clause. Zero value of this struct will be used as 52 | // 'Any' clause which matches any value. 53 | type Clause struct { 54 | // Type represents the operation that should be used. Examples include equal, 55 | // gt, lt etc. Supported operations are dictated by store implementations. 56 | Type string 57 | 58 | // Value that should be used as the right operand for the operation. 59 | Value string 60 | } 61 | 62 | // IsAny returns true if cl is a nil clause or both Op and Value are empty. 63 | func (cl Clause) IsAny() bool { 64 | return cl.Type == "" && cl.Value == "" 65 | } 66 | 67 | func (cl Clause) String() string { 68 | return fmt.Sprintf("%s %s", cl.Type, cl.Value) 69 | } 70 | 71 | func (cl *Clause) normalize() { 72 | normalized, found := knownTypes[cl.Type] 73 | if found { 74 | cl.Type = normalized 75 | } 76 | } 77 | 78 | var knownTypes = map[string]string{ 79 | "equal": "eq", 80 | "equals": "eq", 81 | "==": "eq", 82 | "=": "eq", 83 | ">": "gt", 84 | "greater-than": "gt", 85 | "<": "lt", 86 | "lesser-than": "lt", 87 | "<=": "lte", 88 | ">=": "gte", 89 | "~": "like", 90 | "~=": "like", 91 | } 92 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package fabric_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/spy16/fabric" 8 | ) 9 | 10 | func TestQuery_IsAny(suite *testing.T) { 11 | suite.Parallel() 12 | 13 | suite.Run("WhenTrue", func(t *testing.T) { 14 | query := fabric.Query{} 15 | if !query.IsAny() { 16 | t.Errorf("expecting IsAny() to be true, got false") 17 | } 18 | }) 19 | 20 | suite.Run("WhenFalse", func(t *testing.T) { 21 | query := fabric.Query{ 22 | Source: fabric.Clause{ 23 | Type: "==", 24 | Value: "Bob", 25 | }, 26 | } 27 | 28 | if query.IsAny() { 29 | t.Errorf("expecting IsAny() to be false, got true") 30 | } 31 | }) 32 | } 33 | 34 | func TestQuery_Map(suite *testing.T) { 35 | suite.Parallel() 36 | 37 | suite.Run("WhenSourceIsAny", func(t *testing.T) { 38 | query := fabric.Query{ 39 | Predicate: fabric.Clause{"like", "knows"}, 40 | Target: fabric.Clause{"like", "Bob"}, 41 | Weight: fabric.Clause{"gt", "10"}, 42 | } 43 | 44 | src, present := query.Map()["source"] 45 | if present { 46 | t.Errorf("expecting source to be not present but found '%s'", src) 47 | } 48 | }) 49 | 50 | suite.Run("AllPresent", func(t *testing.T) { 51 | query := fabric.Query{ 52 | Source: fabric.Clause{"~", "John"}, 53 | Predicate: fabric.Clause{"~", "knows"}, 54 | Target: fabric.Clause{"!", "Bob"}, 55 | Weight: fabric.Clause{">", "10"}, 56 | } 57 | 58 | expected := map[string]fabric.Clause{ 59 | "source": query.Source, 60 | "predicate": query.Predicate, 61 | "target": query.Target, 62 | "weight": query.Weight, 63 | } 64 | m := query.Map() 65 | if !reflect.DeepEqual(expected, m) { 66 | t.Errorf("not expected: %v != %v", expected, m) 67 | } 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /server/httpapi.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/spy16/fabric" 11 | ) 12 | 13 | // NewHTTP initializes the an http router with all the query routes and 14 | // middlewares initialized. 15 | func NewHTTP(fab *fabric.Fabric) http.Handler { 16 | handleQuery := queryHandler(fab) 17 | handleInsert := insertHandler(fab) 18 | handleReWeight := reweightHandler(fab) 19 | handleDelete := deleteHandler(fab) 20 | 21 | mux := http.NewServeMux() 22 | mux.HandleFunc("/triples", func(wr http.ResponseWriter, req *http.Request) { 23 | switch req.Method { 24 | case http.MethodGet: 25 | handleQuery(wr, req) 26 | 27 | case http.MethodPost: 28 | handleInsert(wr, req) 29 | 30 | case http.MethodPatch: 31 | handleReWeight(wr, req) 32 | 33 | case http.MethodDelete: 34 | handleDelete(wr, req) 35 | 36 | default: 37 | writeResponse(wr, req, http.StatusMethodNotAllowed, map[string]string{ 38 | "error": "method not allowed", 39 | }) 40 | } 41 | }) 42 | return withLogs(mux) 43 | } 44 | 45 | func queryHandler(fab *fabric.Fabric) http.HandlerFunc { 46 | return func(wr http.ResponseWriter, req *http.Request) { 47 | query, err := readQuery(req.URL.Query()) 48 | if err != nil { 49 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 50 | "error": err.Error(), 51 | }) 52 | return 53 | } 54 | 55 | tri, err := fab.Query(req.Context(), *query) 56 | if err != nil { 57 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 58 | "error": err.Error(), 59 | }) 60 | return 61 | } 62 | 63 | writeTriples(wr, req, http.StatusOK, tri) 64 | } 65 | } 66 | 67 | func insertHandler(fab *fabric.Fabric) http.HandlerFunc { 68 | return func(wr http.ResponseWriter, req *http.Request) { 69 | var tri fabric.Triple 70 | if err := json.NewDecoder(req.Body).Decode(&tri); err != nil { 71 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 72 | "error": err.Error(), 73 | }) 74 | return 75 | } 76 | 77 | if err := tri.Validate(); err != nil { 78 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 79 | "error": err.Error(), 80 | }) 81 | return 82 | } 83 | 84 | if err := fab.Insert(req.Context(), tri); err != nil { 85 | writeResponse(wr, req, http.StatusInternalServerError, map[string]string{ 86 | "error": err.Error(), 87 | }) 88 | return 89 | } 90 | writeResponse(wr, req, http.StatusCreated, nil) 91 | } 92 | } 93 | 94 | func reweightHandler(fab *fabric.Fabric) http.HandlerFunc { 95 | return func(wr http.ResponseWriter, req *http.Request) { 96 | var payload struct { 97 | fabric.Query `json:",inline"` 98 | 99 | Delta float64 `json:"delta"` 100 | Replace bool `json:"replace"` 101 | } 102 | if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { 103 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 104 | "error": err.Error(), 105 | }) 106 | return 107 | } 108 | 109 | updates, err := fab.ReWeight(req.Context(), payload.Query, payload.Delta, payload.Replace) 110 | if err != nil { 111 | writeResponse(wr, req, http.StatusInternalServerError, map[string]string{ 112 | "error": err.Error(), 113 | }) 114 | return 115 | } 116 | 117 | writeResponse(wr, req, http.StatusOK, map[string]interface{}{ 118 | "updated": updates, 119 | }) 120 | } 121 | } 122 | 123 | func deleteHandler(fab *fabric.Fabric) http.HandlerFunc { 124 | return func(wr http.ResponseWriter, req *http.Request) { 125 | query, err := readQuery(req.URL.Query()) 126 | if err != nil { 127 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 128 | "error": err.Error(), 129 | }) 130 | return 131 | } 132 | 133 | deleted, err := fab.Delete(req.Context(), *query) 134 | if err != nil { 135 | writeResponse(wr, req, http.StatusBadRequest, map[string]string{ 136 | "error": err.Error(), 137 | }) 138 | return 139 | } 140 | 141 | writeResponse(wr, req, http.StatusOK, map[string]interface{}{ 142 | "deleted": deleted, 143 | }) 144 | } 145 | } 146 | 147 | func writeTriples(wr http.ResponseWriter, req *http.Request, status int, triples []fabric.Triple) { 148 | switch outputFormat(req) { 149 | case "dot": 150 | wr.Write([]byte(fabric.ExportDOT("fabric", triples))) 151 | 152 | case "plot": 153 | plotTemplate.Execute(wr, map[string]interface{}{ 154 | "graphVizStr": "`" + fabric.ExportDOT("fabric", triples) + "`", 155 | }) 156 | 157 | default: 158 | writeResponse(wr, req, status, triples) 159 | } 160 | } 161 | 162 | func writeResponse(wr http.ResponseWriter, req *http.Request, status int, body interface{}) { 163 | wr.Header().Set("Content-Type", "application/json; charset=utf-8") 164 | wr.WriteHeader(status) 165 | if body == nil || status == http.StatusNoContent { 166 | return 167 | } 168 | 169 | switch outputFormat(req) { 170 | default: 171 | json.NewEncoder(wr).Encode(body) 172 | } 173 | } 174 | 175 | func readQuery(vals url.Values) (*fabric.Query, error) { 176 | var q fabric.Query 177 | if err := readInto(vals, "source", &q.Source); err != nil { 178 | return nil, err 179 | } 180 | 181 | if err := readInto(vals, "predicate", &q.Predicate); err != nil { 182 | return nil, err 183 | } 184 | 185 | if err := readInto(vals, "target", &q.Target); err != nil { 186 | return nil, err 187 | } 188 | 189 | return &q, nil 190 | } 191 | 192 | func readInto(vals url.Values, name string, cl *fabric.Clause) error { 193 | parts := strings.Fields(vals.Get(name)) 194 | if len(parts) == 0 { 195 | return nil 196 | } 197 | 198 | if len(parts) != 2 { 199 | return fmt.Errorf("invalid %s clause", name) 200 | } 201 | 202 | cl.Type = parts[0] 203 | cl.Value = parts[1] 204 | return nil 205 | } 206 | 207 | func outputFormat(req *http.Request) string { 208 | f := strings.TrimSpace(req.URL.Query().Get("format")) 209 | if f != "" { 210 | return f 211 | } 212 | 213 | return "json" 214 | } 215 | -------------------------------------------------------------------------------- /server/middlewares.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func withLogs(next http.Handler) http.Handler { 10 | return http.HandlerFunc(func(wr http.ResponseWriter, req *http.Request) { 11 | start := time.Now() 12 | defer func() { 13 | dur := time.Now().Sub(start) 14 | log.Printf("completed {Method=%s, Path=%s} in %s", req.Method, req.URL.Path, dur.String()) 15 | }() 16 | 17 | next.ServeHTTP(wr, req) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /server/plot.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "text/template" 4 | 5 | var plotTemplate = template.Must(template.New("plot").Parse(` 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 24 | 25 | 26 | 27 | 28 | `)) 29 | -------------------------------------------------------------------------------- /sql.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | ) 10 | 11 | var ( 12 | _ Store = &SQLStore{} 13 | _ ReWeighter = &SQLStore{} 14 | _ Counter = &SQLStore{} 15 | ) 16 | 17 | // SQLStore implements Store interface using the Go standard library 18 | // sql package. 19 | type SQLStore struct { 20 | DB *sql.DB 21 | } 22 | 23 | // Count returns the number of triples that match the given query. 24 | func (ss *SQLStore) Count(ctx context.Context, query Query) (int, error) { 25 | sq := `SELECT count(*) FROM triples` 26 | 27 | where, args, err := getWhereClause(query) 28 | if err != nil { 29 | return 0, err 30 | } 31 | if where != "" { 32 | sq += fmt.Sprintf("WHERE %s", where) 33 | } 34 | 35 | if query.Limit > 0 { 36 | sq = fmt.Sprintf("%s LIMIT %d", sq, query.Limit) 37 | } 38 | 39 | var count int64 40 | row := ss.DB.QueryRowContext(ctx, sq, args...) 41 | if err := row.Scan(&count); err != nil { 42 | return 0, err 43 | } 44 | 45 | return int(count), nil 46 | } 47 | 48 | // Insert persists the given triple into the triples table. 49 | func (ss *SQLStore) Insert(ctx context.Context, tri Triple) error { 50 | query := `INSERT INTO triples (source, predicate, target, weight) VALUES (?, ?, ?, ?)` 51 | 52 | _, err := ss.DB.ExecContext(ctx, query, tri.Source, tri.Predicate, tri.Target, tri.Weight) 53 | return err 54 | } 55 | 56 | // Query converts the given query object into SQL SELECT and fetches all the triples. 57 | func (ss *SQLStore) Query(ctx context.Context, query Query) ([]Triple, error) { 58 | sq := `SELECT * FROM triples` 59 | 60 | where, args, err := getWhereClause(query) 61 | if err != nil { 62 | return nil, err 63 | } 64 | if where != "" { 65 | sq += fmt.Sprintf(" WHERE %s", where) 66 | } 67 | 68 | if query.Limit > 0 { 69 | sq = fmt.Sprintf("%s LIMIT %d", sq, query.Limit) 70 | } 71 | 72 | rows, err := ss.DB.QueryContext(ctx, sq, args...) 73 | if err != nil { 74 | return nil, err 75 | } 76 | defer rows.Close() 77 | 78 | var triples []Triple 79 | for rows.Next() { 80 | var tri Triple 81 | if err := rows.Scan(&tri.Source, &tri.Predicate, &tri.Target, &tri.Weight); err != nil { 82 | return nil, err 83 | } 84 | 85 | triples = append(triples, tri) 86 | } 87 | 88 | return triples, nil 89 | } 90 | 91 | // Delete removes all the triples from the database that match the query. 92 | func (ss *SQLStore) Delete(ctx context.Context, query Query) (int, error) { 93 | sq := `DELETE FROM triples WHERE %s` 94 | 95 | where, args, err := getWhereClause(query) 96 | if err != nil { 97 | return 0, err 98 | } 99 | if where == "" { 100 | return 0, errors.New("no query clause specified") 101 | } 102 | 103 | q := fmt.Sprintf(sq, where) 104 | 105 | res, err := ss.DB.ExecContext(ctx, q, args...) 106 | if err != nil { 107 | if err == sql.ErrNoRows { 108 | return 0, nil 109 | } 110 | 111 | return 0, err 112 | } 113 | 114 | count, err := res.RowsAffected() 115 | if err != nil { 116 | return 0, err 117 | } 118 | 119 | return int(count), nil 120 | } 121 | 122 | // ReWeight updates the weight of all the triples matching the given query. 123 | func (ss *SQLStore) ReWeight(ctx context.Context, query Query, delta float64, replace bool) (int, error) { 124 | args := []interface{}{delta} 125 | sq := "UPDATE triples " 126 | if replace { 127 | sq += "SET weight=?" 128 | } else { 129 | sq += "SET weight=weight + ?" 130 | } 131 | 132 | where, tmp, err := getWhereClause(query) 133 | if err != nil { 134 | return 0, err 135 | } 136 | if where != "" { 137 | sq += fmt.Sprintf(" WHERE %s", where) 138 | args = append(args, tmp...) 139 | } 140 | 141 | res, err := ss.DB.ExecContext(ctx, sq, args...) 142 | if err != nil { 143 | if err == sql.ErrNoRows { 144 | return 0, nil 145 | } 146 | 147 | return 0, err 148 | } 149 | 150 | count, err := res.RowsAffected() 151 | if err != nil { 152 | return 0, err 153 | } 154 | 155 | return int(count), nil 156 | } 157 | 158 | // Setup runs appropriate queries to setup all the required tables. 159 | func (ss *SQLStore) Setup(ctx context.Context) error { 160 | _, err := ss.DB.ExecContext(ctx, sqlMigration) 161 | return err 162 | } 163 | 164 | func getWhereClause(query Query) (string, []interface{}, error) { 165 | var where []string 166 | var args []interface{} 167 | for col, clause := range query.Map() { 168 | sqlOp, value, err := toSQL(clause) 169 | if err != nil { 170 | return "", nil, err 171 | } 172 | 173 | where = append(where, fmt.Sprintf("%s %s ?", col, sqlOp)) 174 | args = append(args, value) 175 | } 176 | return strings.TrimSpace(strings.Join(where, " AND ")), args, nil 177 | } 178 | 179 | func toSQL(clause Clause) (string, string, error) { 180 | switch clause.Type { 181 | case "eq": 182 | return "=", clause.Value, nil 183 | 184 | case "like": 185 | return " LIKE ", strings.Replace(clause.Value, "*", "%", -1), nil 186 | 187 | case "gt": 188 | return ">", clause.Value, nil 189 | 190 | case "lt": 191 | return "<", clause.Value, nil 192 | 193 | case "lte": 194 | return "<=", clause.Value, nil 195 | 196 | case "gte": 197 | return ">=", clause.Value, nil 198 | } 199 | 200 | return "", "", fmt.Errorf("clause type '%s' not supported", clause.Type) 201 | } 202 | 203 | const sqlMigration = ` 204 | create table if not exists triples ( 205 | source text not null, 206 | predicate text not null, 207 | target text not null, 208 | weight decimal not null default 0 209 | ); 210 | create unique index if not exists triple_idx on triples (source, predicate, target); 211 | ` 212 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import "context" 4 | 5 | // Store implementation should provide functions for managing persistence 6 | // of triples. 7 | type Store interface { 8 | // Insert should insert the given triple into the store. 9 | Insert(ctx context.Context, tri Triple) error 10 | 11 | // Query should return triples from store that match the given clauses. 12 | // Possible keys of the clauses map are: source, target, predicate, weight 13 | Query(ctx context.Context, q Query) ([]Triple, error) 14 | 15 | // Delete should delete triples from store that match the given clauses. 16 | // Clauses will follow same format as used in Query() method. 17 | Delete(ctx context.Context, q Query) (int, error) 18 | } 19 | 20 | // ReWeighter can be implemented by Store implementations to support weight 21 | // updates. In case, this interface is not implemented, update queries will 22 | // not be supported. 23 | type ReWeighter interface { 24 | // ReWeight should update all the triples matching the query as described 25 | // by delta and replace flag. If replace is true, weight of all the triples 26 | // should be set to delta. Otherwise, delta should be added to the current 27 | // weights. 28 | ReWeight(ctx context.Context, query Query, delta float64, replace bool) (int, error) 29 | } 30 | 31 | // Counter can be implemented by Store implementations to support count 32 | // operations. In case, this interface is not implemented, count queries 33 | // will not be supported. 34 | type Counter interface { 35 | // Count should return the number of triples in the store that match the 36 | // given query. 37 | Count(ctx context.Context, query Query) (int, error) 38 | } 39 | -------------------------------------------------------------------------------- /triple.go: -------------------------------------------------------------------------------- 1 | package fabric 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // Triple represents a subject-predicate-object. 10 | type Triple struct { 11 | Source string `json:"source" yaml:"source" db:"source"` 12 | Predicate string `json:"predicate" yaml:"predicate" db:"predicate"` 13 | Target string `json:"target" yaml:"target" db:"target"` 14 | Weight float64 `json:"weight" yaml:"weight" db:"weight"` // extension field 15 | } 16 | 17 | // Validate ensures the entity names are valid. 18 | func (tri Triple) Validate() error { 19 | if strings.ContainsAny(tri.Source, forbiddenChars) || tri.Source == "" { 20 | return errors.New("invalid source name") 21 | } 22 | 23 | if strings.ContainsAny(tri.Predicate, forbiddenChars) || tri.Predicate == "" { 24 | return errors.New("invalid predicate") 25 | } 26 | 27 | if strings.ContainsAny(tri.Target, forbiddenChars) || tri.Target == "" { 28 | return errors.New("invalid target name") 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func (tri Triple) String() string { 35 | return fmt.Sprintf("%s %s %s %f", tri.Source, tri.Predicate, tri.Target, tri.Weight) 36 | } 37 | 38 | var forbiddenChars = "? {}()" 39 | -------------------------------------------------------------------------------- /triple_test.go: -------------------------------------------------------------------------------- 1 | package fabric_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spy16/fabric" 7 | ) 8 | 9 | func TestTriple_Validate(suite *testing.T) { 10 | suite.Parallel() 11 | 12 | cases := []struct { 13 | title string 14 | triple fabric.Triple 15 | expectErr bool 16 | }{ 17 | { 18 | title: "InvalidSourceName", 19 | triple: fabric.Triple{ 20 | Source: "?", 21 | }, 22 | expectErr: true, 23 | }, 24 | { 25 | title: "InvalidPredicateName", 26 | triple: fabric.Triple{ 27 | Source: "bob", 28 | Predicate: "", 29 | }, 30 | expectErr: true, 31 | }, 32 | { 33 | title: "InvalidTarget", 34 | triple: fabric.Triple{ 35 | Source: "bob", 36 | Predicate: "knows", 37 | Target: "{", 38 | }, 39 | expectErr: true, 40 | }, 41 | { 42 | title: "Valid", 43 | triple: fabric.Triple{ 44 | Source: "bob", 45 | Predicate: "knows", 46 | Target: "john", 47 | }, 48 | }, 49 | } 50 | 51 | for _, cs := range cases { 52 | suite.Run(cs.title, func(t *testing.T) { 53 | err := cs.triple.Validate() 54 | if err != nil { 55 | if !cs.expectErr { 56 | t.Errorf("unexpected error: %v", err) 57 | return 58 | } 59 | return 60 | } 61 | 62 | if cs.expectErr { 63 | t.Error("expecting error, got nil") 64 | } 65 | }) 66 | } 67 | } 68 | 69 | func TestTriple_String(t *testing.T) { 70 | tri := fabric.Triple{ 71 | Source: "Bob", 72 | Predicate: "Knows", 73 | Target: "John", 74 | Weight: 10, 75 | } 76 | 77 | expected := "Bob Knows John 10.000000" 78 | if tri.String() != expected { 79 | t.Errorf("expected string to be '%s', got '%s'", expected, tri.String()) 80 | } 81 | } 82 | --------------------------------------------------------------------------------