diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..ccf4f22 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.22 + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d0e5595 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Martin Kopka Drlík + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a5959a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# rex + +Exploring incomplete data for relational database model. \ No newline at end of file diff --git a/box/relation.go b/box/relation.go new file mode 100644 index 0000000..6a8d785 --- /dev/null +++ b/box/relation.go @@ -0,0 +1,115 @@ +package box + +import ( + "fmt" + "io" + "iter" + "strings" + "unicode/utf8" +) + +type relationBox struct { + schema []string + rows []map[string]string + max map[string]int +} + +func Relation(schema []string, tuples iter.Seq[map[string]any]) interface{ String() string } { + rb := &relationBox{ + schema: schema, + rows: []map[string]string{}, + max: map[string]int{}, + } + for _, s := range rb.schema { + rb.max[s] = utf8.RuneCountInString(s) + } + for t := range tuples { + rb.addRow(t) + } + return rb +} + +func (t *relationBox) addRow(tuple map[string]any) { + str := func(v any) string { return fmt.Sprintf("%v", v) } + row := map[string]string{} + for k, v := range tuple { + s := str(v) + if l := utf8.RuneCountInString(s); t.max[k] < l { + t.max[k] = l + } + row[k] = s + } + t.rows = append(t.rows, row) +} + +func (t *relationBox) String() string { + sb := &strings.Builder{} + t.writeTop(sb) + t.writeHeader(sb) + if len(t.rows) > 0 { + t.writeSeparator(sb) + t.writeRows(sb) + } + t.writeBottom(sb) + return sb.String() +} + +func (t *relationBox) writeTop(w io.Writer) { + // ┏━━━━━━┯━━━━━━┓ + t.writeRow(w, "┏", "┯", "┓", func(s string) string { + return strings.Repeat("━", t.max[s]+2) + }) +} + +func (t *relationBox) writeHeader(w io.Writer) { + // ┃ x │ y ┃ + t.writeRow(w, "┃", "│", "┃", func(s string) string { + return fmt.Sprintf(" %s ", t.pad(s, s)) + }) +} + +func (t *relationBox) writeSeparator(w io.Writer) { + // ┠──────┼──────┨ + t.writeRow(w, "┠", "┼", "┨", func(s string) string { + return strings.Repeat("─", t.max[s]+2) + }) +} + +func (t *relationBox) writeRows(w io.Writer) { + for _, row := range t.rows { + // ┃ 2023 │ 2024 ┃ + t.writeRow(w, "┃", "│", "┃", func(s string) string { + v, ok := row[s] + return fmt.Sprintf(" %s ", t.pad(s, val(v, ok))) + }) + } +} + +func (t *relationBox) writeBottom(w io.Writer) { + // ┗━━━━━━┷━━━━━━┛ + t.writeRow(w, "┗", "┷", "┛", func(s string) string { + return strings.Repeat("━", t.max[s]+2) + }) +} + +func val(v string, ok bool) string { + if ok { + return v + } + return "?" +} + +func (t *relationBox) writeRow(w io.Writer, left, middle, right string, valueFunc func(string) string) { + fmt.Fprint(w, left) + for i, s := range t.schema { + if i > 0 { + fmt.Fprint(w, middle) + } + fmt.Fprint(w, valueFunc(s)) + } + fmt.Fprintln(w, right) +} + +func (bt *relationBox) pad(s, v string) string { + return fmt.Sprintf("%s%s", v, strings.Repeat(" ", bt.max[s]-utf8.RuneCountInString(v))) +} diff --git a/box/relation_test.go b/box/relation_test.go new file mode 100644 index 0000000..c7374c2 --- /dev/null +++ b/box/relation_test.go @@ -0,0 +1,32 @@ +package box_test + +import ( + "fmt" + "slices" + + "github.com/martindrlik/rex/box" +) + +func ExampleRelation() { + fmt.Println(box.Relation( + []string{"title", "year"}, + slices.Values([]map[string]any{ + {"title": "Adventure Time", "year": 2010}, + {"title": "What We Do in the Shadows", "year": 2019}, + {"title": "The Last of Us"}}))) + + fmt.Println(box.Relation([]string{"empty", "table"}, slices.Values([]map[string]any{}))) + + // Output: + // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━┓ + // ┃ title │ year ┃ + // ┠───────────────────────────┼──────┨ + // ┃ Adventure Time │ 2010 ┃ + // ┃ What We Do in the Shadows │ 2019 ┃ + // ┃ The Last of Us │ ? ┃ + // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━┷━━━━━━┛ + // + // ┏━━━━━━━┯━━━━━━━┓ + // ┃ empty │ table ┃ + // ┗━━━━━━━┷━━━━━━━┛ +} diff --git a/example-difference.sh b/example-difference.sh new file mode 100755 index 0000000..8135e3e --- /dev/null +++ b/example-difference.sh @@ -0,0 +1,9 @@ +#!/bin/zsh + +table_one='[{"year": 2049}, {"year": 2050}]' +table_two='[{"year": 2050}]' +result=`go run . difference -ia $table_one -ia $table_two -of json` + +echo "Table 1: $table_one" +echo "Table 2: $table_two" +echo "Result: $result" diff --git a/example-natural-join.sh b/example-natural-join.sh new file mode 100755 index 0000000..9d747ec --- /dev/null +++ b/example-natural-join.sh @@ -0,0 +1,17 @@ +#!/bin/zsh + +cast='[{"movie_id": "br2049", "actor_id": "rg", "character_id": "br2049k"}]' +movies='[{"movie_id": "br2049", "movie_name": "Blade Runner 2049", "movie_release_year": 2017}]' +actors='[{"actor_id": "rg", "actor_name": "Ryan Gosling"}]' +characters='[{"character_id": "br2049k", "character_name": "K"}]' + +result=`go run . natural-join -ia $cast -ia $movies -of json` +result=`go run . natural-join -ia $result -ia $actors -of json` +result=`go run . natural-join -ia $result -ia $characters -of json movie_name actor_name character_name` + +echo "Cast: $cast" +echo "Movies: $movies" +echo "Actors: $actors" +echo "Characters: $characters" +echo "Projection: {\"movie_name\", \"actor_name\", \"character_name\"}" +echo "Result: $result" diff --git a/example-union.sh b/example-union.sh new file mode 100755 index 0000000..9d04302 --- /dev/null +++ b/example-union.sh @@ -0,0 +1,9 @@ +#!/bin/zsh + +table_one='[{"year": 2049}]' +table_two='[{"year": 2050}]' +result=`go run . union -ia $table_one -ia $table_two -of json` + +echo "Table 1: $table_one" +echo "Table 2: $table_two" +echo "Result: $result" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..80f6b5f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/martindrlik/rex + +go 1.23.1 diff --git a/go.work b/go.work new file mode 100644 index 0000000..8777c4c --- /dev/null +++ b/go.work @@ -0,0 +1,3 @@ +go 1.23.1 + +use . diff --git a/main.go b/main.go new file mode 100644 index 0000000..cc887fb --- /dev/null +++ b/main.go @@ -0,0 +1,192 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "maps" + "os" + "slices" + "strings" + + "github.com/martindrlik/rex/box" + "github.com/martindrlik/rex/persist" + "github.com/martindrlik/rex/table" +) + +func main() { + must := func(t *table.Table, err error) *table.Table { + if err != nil { + panic(err) + } + return t + } + bind("union", "", func(a, b *table.Table) *table.Table { return must(a.Union(b)) }) + bind("difference", "", func(a, b *table.Table) *table.Table { return must(a.Difference(b)) }) + bind("natural-join", "", func(a, b *table.Table) *table.Table { return a.NaturalJoin(b) }) + exec(parse(os.Args[1:])) +} + +func exec(op string, tables []*table.Table, outputFormat string, schema []string) { + func(fn func([]*table.Table) []*table.Table) { + for _, t := range fn(tables) { + projection(t, outputFormat, schema...) + } + }(binaryOp(op)) +} + +func binaryOp(op string) func([]*table.Table) []*table.Table { + return aggr(func(a, b *table.Table) *table.Table { + if desc, ok := ops[op]; ok { + return desc.fn(a, b) + } + panic("unreachable") + }) +} + +func aggr(fn func(a, b *table.Table) *table.Table) func([]*table.Table) []*table.Table { + return func(tables []*table.Table) []*table.Table { + result := tables[0] + for _, t := range tables[1:] { + result = fn(result, t) + } + return []*table.Table{result} + } +} + +func projection(t *table.Table, outputFormat string, schema ...string) { + if len(schema) == 0 { + schema = slices.Collect(maps.Keys(t.Schema)) + } + w, err := t.Projection(schema...) + if err != nil { + fmt.Fprintf(os.Stderr, "unable to project: %v\n", err) + return + } + switch outputFormat { + case "json": + if err := persist.WriteJson(os.Stdout, w); err != nil { + fmt.Fprintf(os.Stderr, "unable to write json output: %v", err) + } + case "table": + fmt.Println(box.Relation(schema, w.List())) + } +} + +func parse(args []string) (string, []*table.Table, string, []string) { + if len(args) < 2 { + usage(errors.New("missing arguments")) + } + fs := flag.NewFlagSet("", flag.ExitOnError) + var ( + schemalessFilenames = stringsFlag{} + schemalessInlines = stringsFlag{} + + outputFormat = fs.String("of", "table", "table or json") + ) + fs.Var(&schemalessFilenames, "fa", "name of file that contains array of tuples") + fs.Var(&schemalessInlines, "ia", "inline array of tuples") + + op := args[0] + _, ok := ops[op] + if !ok { + usage(fmt.Errorf("unknown op: %s", op)) + } + + fs.Parse(args[1:]) + if len(schemalessFilenames) == 0 && len(schemalessInlines) == 0 { + usage(errors.New("missing table")) + } + + tables := []*table.Table{} + load := func(r io.Reader, fn func(io.Reader) (*table.Table, error)) error { + t, err := fn(r) + if err != nil { + return err + } + tables = append(tables, t) + return nil + } + loadFile := func(name string, fn func(io.Reader) (*table.Table, error)) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return load(f, fn) + } + loadFiles := func(filenames []string, fn func(io.Reader) (*table.Table, error)) { + for _, name := range filenames { + if err := loadFile(name, fn); err != nil { + usage(fmt.Errorf("loading file: %w", err)) + } + } + } + loadInline := func(inlines []string, fn func(io.Reader) (*table.Table, error)) { + for _, inline := range inlines { + t, err := fn(strings.NewReader(inline)) + if err != nil { + usage(fmt.Errorf("loading inline %v: %w", inline, err)) + } + tables = append(tables, t) + } + } + + loadFiles(schemalessFilenames, persist.Load) + loadInline(schemalessInlines, persist.Load) + + return op, tables, *outputFormat, fs.Args() +} + +type stringsFlag []string + +func (s *stringsFlag) String() string { + return fmt.Sprint(*s) +} + +func (s *stringsFlag) Set(value string) error { + *s = append(*s, value) + return nil +} + +func usage(err error) { + if err != nil { + fmt.Println("Error:") + fmt.Printf(" %v\n", err) + } + fmt.Println("Usage:") + fmt.Println(" rex [attribute ...]") + fmt.Println("Commands:") + names := slices.Collect(maps.Keys(ops)) + slices.Sort(names) + for _, name := range names { + fmt.Printf(" %s", name) + desc := ops[name].desc + if desc == "" { + fmt.Println() + } else { + fmt.Printf("%s\n", desc) + } + } + fmt.Println("Input:") + fmt.Println(" -fa [-ta ...]: name of file that contains array of tuples") + fmt.Println(" -ia [-ia ...]: inline array of tuples") + fmt.Println("Options:") + fmt.Println(" -of : output format: table or json") + + fmt.Println("Note:") + fmt.Println(" JSON is used as an input format") + os.Exit(1) +} + +type opDesc struct { + desc string + fn func(a, b *table.Table) *table.Table +} + +var ops = map[string]opDesc{} + +func bind(name, desc string, fn func(a, b *table.Table) *table.Table) { + ops[name] = opDesc{desc, fn} +} diff --git a/persist/load.go b/persist/load.go new file mode 100644 index 0000000..0211f5e --- /dev/null +++ b/persist/load.go @@ -0,0 +1,34 @@ +package persist + +import ( + "encoding/json" + "io" + "reflect" + + "github.com/martindrlik/rex/table" +) + +func Load(r io.Reader) (*table.Table, error) { + dec := json.NewDecoder(r) + tt := []map[string]any{} + if err := dec.Decode(&tt); err != nil { + return nil, err + } + schema := map[string]reflect.Type{} + for _, t := range tt { + for k, v := range t { + schema[k] = reflect.TypeOf(v) + } + break + } + t, err := table.New(schema) + if err != nil { + return nil, err + } + for _, u := range tt { + if err := t.Add(u); err != nil { + return nil, err + } + } + return t, nil +} diff --git a/persist/store.go b/persist/store.go new file mode 100644 index 0000000..590e581 --- /dev/null +++ b/persist/store.go @@ -0,0 +1,14 @@ +package persist + +import ( + "encoding/json" + "io" + "slices" + + "github.com/martindrlik/rex/table" +) + +func WriteJson(w io.Writer, t *table.Table) error { + enc := json.NewEncoder(w) + return enc.Encode(slices.Collect(t.List())) +} diff --git a/relation/clone.go b/relation/clone.go new file mode 100644 index 0000000..832614c --- /dev/null +++ b/relation/clone.go @@ -0,0 +1,11 @@ +package relation + +import "slices" + +// Clone returns a new relation with the same schema and tuples as r. +// The schema is considered read-only so it is not cloned. +func Clone(r *Relation) *Relation { + w, _ := New(r.Schema) + w.TupleSet = slices.Clone(r.TupleSet) + return w +} diff --git a/relation/clone_test.go b/relation/clone_test.go new file mode 100644 index 0000000..30389d3 --- /dev/null +++ b/relation/clone_test.go @@ -0,0 +1,23 @@ +package relation_test + +import ( + "fmt" + "testing" + + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" +) + +func TestClone(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + r := newRelation(t, schema.FromTuple(foobar), foobar) + c := relation.Clone(r) + if !r.Schema.Equal(c.Schema) { + t.Errorf("expected %v got %v", r.Schema, c.Schema) + } + actual := fmt.Sprintf("%v", c.TupleSet) + expect := fmt.Sprintf("%v", r.TupleSet) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } +} diff --git a/relation/delete.go b/relation/delete.go new file mode 100644 index 0000000..7b510a2 --- /dev/null +++ b/relation/delete.go @@ -0,0 +1,10 @@ +package relation + +import "github.com/martindrlik/rex/schema" + +func (r *Relation) Delete(u map[string]any) { + us := schema.FromTuple(u) + if r.Schema.Equal(us) { + r.TupleSet.Delete(u) + } +} diff --git a/relation/delete_test.go b/relation/delete_test.go new file mode 100644 index 0000000..b547949 --- /dev/null +++ b/relation/delete_test.go @@ -0,0 +1,19 @@ +package relation_test + +import ( + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestDelete(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + r := newRelation(t, schema.FromTuple(foobar), foobar) + if !r.TupleSet.Has(foobar) { + t.Errorf("expected to contain %v", foobar) + } + r.Delete(foobar) + if r.TupleSet.Has(foobar) { + t.Errorf("expected to not contain %v", foobar) + } +} diff --git a/relation/difference.go b/relation/difference.go new file mode 100644 index 0000000..d8fcf0e --- /dev/null +++ b/relation/difference.go @@ -0,0 +1,17 @@ +package relation + +import "github.com/martindrlik/rex/schema" + +// Difference returns a new relation with tuples that are in r but not in s. +func (r *Relation) Difference(s *Relation) (*Relation, error) { + if !r.Schema.Equal(s.Schema) { + return nil, schema.ErrMismatch + } + w, _ := New(r.Schema) + for t := range r.List() { + if !s.TupleSet.Has(t) { + w.TupleSet.Add(t) + } + } + return w, nil +} diff --git a/relation/difference_test.go b/relation/difference_test.go new file mode 100644 index 0000000..15f5e6f --- /dev/null +++ b/relation/difference_test.go @@ -0,0 +1,35 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestDifference(t *testing.T) { + foo1 := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + r := newRelation(t, schema.FromTuple(foo1), foo1, foo2) + s := newRelation(t, schema.FromTuple(foo1), foo1) + w, err := r.Difference(s) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo2}) + + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + bar := map[string]any{"bar": 2.0} + s := newRelation(t, schema.FromTuple(bar)) + _, err := r.Difference(s) + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/relation/intersection.go b/relation/intersection.go new file mode 100644 index 0000000..cfbe086 --- /dev/null +++ b/relation/intersection.go @@ -0,0 +1,17 @@ +package relation + +import "github.com/martindrlik/rex/schema" + +// Intersection returns a new relation with tuples that are in r and in s. +func (r *Relation) Intersection(s *Relation) (*Relation, error) { + if !r.Schema.Equal(s.Schema) { + return nil, schema.ErrMismatch + } + w, _ := New(r.Schema) + for t := range r.List() { + if s.TupleSet.Has(t) { + w.TupleSet.Add(t) + } + } + return w, nil +} diff --git a/relation/intersection_test.go b/relation/intersection_test.go new file mode 100644 index 0000000..5c9e0bd --- /dev/null +++ b/relation/intersection_test.go @@ -0,0 +1,36 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestIntersection(t *testing.T) { + fb2 := map[string]any{"foo": 1, "bar": 2.0} + fb3 := map[string]any{"foo": 1, "bar": 3.0} + fb4 := map[string]any{"foo": 1, "bar": 4.0} + r := newRelation(t, schema.FromTuple(fb2)) + s := newRelation(t, schema.FromTuple(fb3)) + add(t, r, fb2, fb3) + add(t, s, fb3, fb4) + w, err := r.Intersection(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{fb3}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + s := newRelation(t, schema.FromTuple(map[string]any{"foo": 1})) + _, err := r.Intersection(s) + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/relation/list.go b/relation/list.go new file mode 100644 index 0000000..a189561 --- /dev/null +++ b/relation/list.go @@ -0,0 +1,14 @@ +package relation + +import "iter" + +// List returns a sequence of tuples in the relation. +func (r *Relation) List() iter.Seq[map[string]any] { + return func(yield func(map[string]any) bool) { + for _, t := range r.TupleSet { + if !yield(t) { + return + } + } + } +} diff --git a/relation/list_test.go b/relation/list_test.go new file mode 100644 index 0000000..2ef091f --- /dev/null +++ b/relation/list_test.go @@ -0,0 +1,40 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestList(t *testing.T) { + foo1 := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + r := newRelation(t, schema.FromTuple(foo1)) + add(t, r, foo1) + add(t, r, foo2) + actual := fmt.Sprintf("%v", slices.Collect(r.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo1, foo2}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } +} + +func TestListPartial(t *testing.T) { + foo1 := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + r := newRelation(t, schema.FromTuple(foo1)) + add(t, r, foo1) + add(t, r, foo2) + s := make([]map[string]any, 0) + for t := range r.List() { + s = append(s, t) + break + } + actual := fmt.Sprintf("%v", s) + expect := fmt.Sprintf("%v", []map[string]any{foo1}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } +} diff --git a/relation/naturaljoin.go b/relation/naturaljoin.go new file mode 100644 index 0000000..14ed276 --- /dev/null +++ b/relation/naturaljoin.go @@ -0,0 +1,36 @@ +package relation + +import ( + "maps" + + "github.com/martindrlik/rex/tuple" +) + +func (r *Relation) NaturalJoin(s *Relation) (*Relation, bool) { + common := r.Schema.Intersection(s.Schema) + concat := func(u, v map[string]any) (map[string]any, bool) { + w := make(map[string]any) + for a := range common { + if u[a] != v[a] { + return nil, false + } + } + maps.Copy(w, u) + maps.Copy(w, v) + return w, true + } + + w := func() *Relation { + cs := tuple.Merge(r.Schema, s.Schema) + w, _ := New(cs) + return w + }() + for rt := range r.List() { + for st := range s.List() { + if t, ok := concat(rt, st); ok { + w.TupleSet.Add(t) + } + } + } + return w, len(w.TupleSet) > 0 +} diff --git a/relation/naturaljoin_test.go b/relation/naturaljoin_test.go new file mode 100644 index 0000000..0a2b950 --- /dev/null +++ b/relation/naturaljoin_test.go @@ -0,0 +1,70 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func TestNaturalJoin(t *testing.T) { + t.Run("", func(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + foobaz := map[string]any{"foo": 1, "baz": "3"} + r := newRelation(t, schema.FromTuple(foobar)) + s := newRelation(t, schema.FromTuple(foobaz)) + add(t, r, foobar) + add(t, s, foobaz) + w, ok := r.NaturalJoin(s) + if !ok { + t.Fatal("unexpected empty result") + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{{"foo": 1, "bar": 2.0, "baz": "3"}}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + }) + + t.Run("cartasian product", func(t *testing.T) { + foo1 := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + bar1 := map[string]any{"bar": 1.0} + bar2 := map[string]any{"bar": 2.0} + r := newRelation(t, schema.FromTuple(foo1)) + s := newRelation(t, schema.FromTuple(bar1)) + add(t, r, foo1) + add(t, r, foo2) + add(t, s, bar1) + add(t, s, bar2) + w, ok := r.NaturalJoin(s) + if !ok { + t.Fatal("unexpected empty result") + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{ + tuple.Merge(foo1, bar1), + tuple.Merge(foo1, bar2), + tuple.Merge(foo2, bar1), + tuple.Merge(foo2, bar2), + }) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + }) + + t.Run("empty result", func(t *testing.T) { + foo1bar := map[string]any{"foo": 1, "bar": 1.0} + foo2bar := map[string]any{"foo": 2, "bar": 2.0} + r := newRelation(t, schema.FromTuple(foo1bar)) + s := newRelation(t, schema.FromTuple(foo2bar)) + add(t, r, foo1bar) + add(t, s, foo2bar) + _, ok := r.NaturalJoin(s) + if ok { + t.Error("unexpected non-empty result") + } + }) +} diff --git a/relation/projection.go b/relation/projection.go new file mode 100644 index 0000000..f21aa62 --- /dev/null +++ b/relation/projection.go @@ -0,0 +1,21 @@ +package relation + +import ( + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func (r *Relation) Projection(p ...string) (*Relation, error) { + s, ok := r.Schema.Projection(p...) + if !ok { + return nil, schema.ErrMismatch + } + w, _ := New(s) + for rt := range r.List() { + wt := tuple.Tuple(rt).Projection(p...) + if !w.TupleSet.Has(wt) { + w.TupleSet.Add(wt) + } + } + return w, nil +} diff --git a/relation/projection_test.go b/relation/projection_test.go new file mode 100644 index 0000000..8198af1 --- /dev/null +++ b/relation/projection_test.go @@ -0,0 +1,32 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestProjection(t *testing.T) { + fbb := map[string]any{"foo": 1, "bar": 2.0, "baz": "3"} + r := newRelation(t, schema.FromTuple(fbb)) + add(t, r, fbb) + + s, err := r.Projection("foo", "baz") + if err != nil { + t.Fatalf("unexpected error %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(s.List())) + expect := "[map[baz:3 foo:1]]" + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + _, err := r.Projection("foo", "qux") + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/relation/relation.go b/relation/relation.go new file mode 100644 index 0000000..7dab2c9 --- /dev/null +++ b/relation/relation.go @@ -0,0 +1,32 @@ +package relation + +import ( + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +// Relation is a set of tuples with a common schema. +type Relation struct { + schema.Schema + tuple.TupleSet +} + +// New creates a new relation with the given schema. +func New(s schema.Schema) (*Relation, error) { + if len(s) == 0 { + return nil, schema.ErrEmpty + } + return &Relation{Schema: s}, nil +} + +// Add adds a tuple to the relation. If the tuple is already in the relation, it does nothing. +// If the tuple has a different schema than the relation, it returns ErrSchemaMismatch. +func (r *Relation) Add(t tuple.Tuple) error { + if !r.Schema.Equal(schema.FromTuple(t)) { + return schema.ErrMismatch + } + if !r.TupleSet.Has(t) { + r.TupleSet.Add(t) + } + return nil +} diff --git a/relation/relation_test.go b/relation/relation_test.go new file mode 100644 index 0000000..ce6cb03 --- /dev/null +++ b/relation/relation_test.go @@ -0,0 +1,82 @@ +package relation_test + +import ( + "fmt" + "testing" + + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" +) + +func TestRelation(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + t.Run("schema", func(t *testing.T) { + r := newRelation(t, schema.FromTuple(foobar)) + if !r.Schema.Equal(schema.FromTuple(foobar)) { + t.Error("relation is created without expected schema") + } + }) + t.Run("add", func(t *testing.T) { + r := newRelation(t, schema.FromTuple(foobar)) + err := r.Add(foobar) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if a, e := fmt.Sprintf("%v", *r), "{map[bar:float64 foo:int] [map[bar:2 foo:1]]}"; a != e { + t.Errorf("expected %v got %v", e, a) + } + }) + t.Run("duplicate", func(t *testing.T) { + r := newRelation(t, schema.FromTuple(foobar)) + err := r.Add(foobar) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + err = r.Add(foobar) + if err != nil { + t.Errorf("expected no error got %v", err) + } + }) + t.Run("mismatch", func(t *testing.T) { + r := newRelation(t, schema.FromTuple(foobar)) + err := r.Add(map[string]any{"foo": 1}) + if err != schema.ErrMismatch { + t.Fatalf("expected error %v got %v", schema.ErrMismatch, err) + } + }) + t.Run("mismatch type", func(t *testing.T) { + r := newRelation(t, schema.FromTuple(map[string]any{"foo": 1})) + err := r.Add(map[string]any{"foo": "1"}) + if err != schema.ErrMismatch { + t.Fatalf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} + +func newRelation(t *testing.T, s schema.Schema, tt ...map[string]any) *relation.Relation { + t.Helper() + r, err := relation.New(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + add(t, r, tt...) + return r +} + +func TestNew(t *testing.T) { + _, err := relation.New(nil) + if err != schema.ErrEmpty { + t.Errorf("expected error %v got %v", schema.ErrEmpty, err) + } +} + +func add(t *testing.T, r *relation.Relation, tt ...map[string]any) { + t.Helper() + for _, tup := range tt { + err := r.Add(tup) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } +} diff --git a/relation/relationset.go b/relation/relationset.go new file mode 100644 index 0000000..81a12b3 --- /dev/null +++ b/relation/relationset.go @@ -0,0 +1,20 @@ +package relation + +import "github.com/martindrlik/rex/schema" + +type RelationSet []*Relation + +// Add adds a relation to the set. +func (rs *RelationSet) Add(r *Relation) { + *rs = append(*rs, r) +} + +// Relation returns the relation with the given schema and true if it is in the set. +func (rs *RelationSet) Relation(schema schema.Schema) (*Relation, bool) { + for _, r := range *rs { + if r.Schema.Equal(schema) { + return r, true + } + } + return nil, false +} diff --git a/relation/relationset_test.go b/relation/relationset_test.go new file mode 100644 index 0000000..4ec6f18 --- /dev/null +++ b/relation/relationset_test.go @@ -0,0 +1,27 @@ +package relation_test + +import ( + "testing" + + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" +) + +func TestRelationSet(t *testing.T) { + rs := relation.RelationSet{} + r1 := newRelation(t, schema.FromTuple(map[string]any{"foo": 1})) + rs.Add(r1) + r, ok := rs.Relation(r1.Schema) + if !ok { + t.Error("expected to find relation in set") + } + if r != r1 { + t.Error("expected to find the same relation in set") + } + t.Run("not found", func(t *testing.T) { + _, ok := rs.Relation(schema.FromTuple(map[string]any{"bar": 2})) + if ok { + t.Error("expected not to find relation in set") + } + }) +} diff --git a/relation/rename.go b/relation/rename.go new file mode 100644 index 0000000..82121b5 --- /dev/null +++ b/relation/rename.go @@ -0,0 +1,30 @@ +package relation + +import ( + "maps" + + "github.com/martindrlik/rex/schema" +) + +// Rename1 returns a new relation with the attribute old renamed to new. +func (r *Relation) Rename1(old, new string) (*Relation, error) { + if !r.Schema.Has(old) || r.Schema.Has(new) { + return nil, schema.ErrMismatch + } + + schema := make(schema.Schema, len(r.Schema)) + for k, v := range r.Schema { + schema[k] = v + } + schema[new] = schema[old] + delete(schema, old) + + w, _ := New(schema) + for t := range r.List() { + wt := maps.Clone(t) + wt[new] = wt[old] + delete(wt, old) + w.TupleSet.Add(wt) + } + return w, nil +} diff --git a/relation/rename_test.go b/relation/rename_test.go new file mode 100644 index 0000000..8c33525 --- /dev/null +++ b/relation/rename_test.go @@ -0,0 +1,35 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestRename1(t *testing.T) { + foobaz := map[string]any{"foo": 1, "baz": 2.0} + foobar := map[string]any{"foo": 1, "bar": 2.0} + r := newRelation(t, schema.FromTuple(foobaz), foobaz) + w, err := r.Rename1("baz", "bar") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foobar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + _, err := r.Rename1("pub", "bar") + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + _, err = r.Rename1("baz", "foo") + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/relation/union.go b/relation/union.go new file mode 100644 index 0000000..57eaf42 --- /dev/null +++ b/relation/union.go @@ -0,0 +1,22 @@ +package relation + +import ( + "slices" + + "github.com/martindrlik/rex/schema" +) + +// Union returns a new relation with tuples that are in r or in s. +func (r *Relation) Union(s *Relation) (*Relation, error) { + if !r.Schema.Equal(s.Schema) { + return nil, schema.ErrMismatch + } + w, _ := New(r.Schema) + w.TupleSet = slices.Clone(r.TupleSet) + for t := range s.List() { + if !w.TupleSet.Has(t) { + w.TupleSet.Add(t) + } + } + return w, nil +} diff --git a/relation/union_test.go b/relation/union_test.go new file mode 100644 index 0000000..7380583 --- /dev/null +++ b/relation/union_test.go @@ -0,0 +1,37 @@ +package relation_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestUnion(t *testing.T) { + foo1 := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + r := newRelation(t, schema.FromTuple(foo1)) + s := newRelation(t, schema.FromTuple(foo2)) + add(t, r, foo1) + add(t, s, foo2) + w, err := r.Union(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo1, foo2}) + + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + bar := map[string]any{"bar": 2.0} + s := newRelation(t, schema.FromTuple(bar)) + _, err := r.Union(s) + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/schema/projection.go b/schema/projection.go new file mode 100644 index 0000000..f25aa32 --- /dev/null +++ b/schema/projection.go @@ -0,0 +1,14 @@ +package schema + +// Projection returns a new schema with the keys of p that are in s. +func (s Schema) Projection(p ...string) (Schema, bool) { + w := make(Schema) + for _, a := range p { + if t, ok := s[a]; ok { + w[a] = t + } else { + return nil, false + } + } + return w, true +} diff --git a/schema/projection_test.go b/schema/projection_test.go new file mode 100644 index 0000000..736917b --- /dev/null +++ b/schema/projection_test.go @@ -0,0 +1,26 @@ +package schema_test + +import ( + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestProjection(t *testing.T) { + s := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + actual, ok := s.Projection("foo") + if !ok { + t.Fatal("unexpected empty result") + } + expect := schema.FromTuple(map[string]any{"foo": 1}) + if !actual.Equal(expect) { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("empty", func(t *testing.T) { + _, ok := s.Projection("baz") + if ok { + t.Error("unexpected non-empty result") + } + }) +} diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..e81c808 --- /dev/null +++ b/schema/schema.go @@ -0,0 +1,40 @@ +package schema + +import ( + "errors" + "maps" + "reflect" +) + +var ( + ErrEmpty = errors.New("empty schema") + ErrMismatch = errors.New("schema mismatch") +) + +type Schema map[string]reflect.Type + +func FromTuple(t map[string]any) Schema { + s := make(Schema) + for k, v := range t { + s[k] = reflect.TypeOf(v) + } + return s +} + +// Equal reports whether two schemas contain the same key/value pairs. +// Values are compared using ==. +func (s Schema) Equal(t Schema) bool { return maps.Equal(s, t) } + +// Intersection returns a new schema with the common key/value pairs of s and t. +func (s Schema) Intersection(t Schema) Schema { + w := make(Schema) + for a, st := range s { + if tt, ok := t[a]; ok && st == tt { + w[a] = st + } + } + return w +} + +// Has reports whether s contains the key k. +func (s Schema) Has(k string) bool { _, ok := s[k]; return ok } diff --git a/schema/schema_test.go b/schema/schema_test.go new file mode 100644 index 0000000..405bfb3 --- /dev/null +++ b/schema/schema_test.go @@ -0,0 +1,51 @@ +package schema_test + +import ( + "maps" + "reflect" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestFromTuple(t *testing.T) { + actual := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + expect := map[string]reflect.Type{"foo": reflect.TypeOf(1), "bar": reflect.TypeOf(2.0)} + + if !maps.Equal(actual, expect) { + t.Errorf("expected %v got %v", expect, actual) + } +} + +func TestEqual(t *testing.T) { + schema1 := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + schema2 := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + + if !schema1.Equal(schema2) { + t.Errorf("expected %v and %v to contain the same key/value pairs", schema1, schema2) + } +} + +func TestIntersection(t *testing.T) { + schema1 := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + schema2 := schema.FromTuple(map[string]any{"bar": 2.0, "baz": "3"}) + + actual := schema1.Intersection(schema2) + expect := schema.FromTuple(map[string]any{"bar": 2.0}) + + if !maps.Equal(actual, expect) { + t.Errorf("expected %v got %v", expect, actual) + } +} + +func TestHas(t *testing.T) { + s := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + + if !s.Has("foo") { + t.Error("expected schema to contain key foo") + } + + if s.Has("baz") { + t.Error("expected schema to not contain key baz") + } +} diff --git a/schema/subset.go b/schema/subset.go new file mode 100644 index 0000000..a6ac4b7 --- /dev/null +++ b/schema/subset.go @@ -0,0 +1,13 @@ +package schema + +func (s Schema) IsSubsetOf(t Schema) bool { + if len(s) > len(t) { + return false + } + for a, st := range s { + if tt, ok := t[a]; !ok || st != tt { + return false + } + } + return true +} diff --git a/schema/subset_test.go b/schema/subset_test.go new file mode 100644 index 0000000..a407944 --- /dev/null +++ b/schema/subset_test.go @@ -0,0 +1,41 @@ +package schema_test + +import ( + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestIsSubsetOf(t *testing.T) { + u := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0}) + v := schema.FromTuple(map[string]any{"foo": 1}) + if !v.IsSubsetOf(u) { + t.Errorf("expected %v to be a subset of %v", v, u) + } + + t.Run("equal", func(t *testing.T) { + if !u.IsSubsetOf(u) { + t.Errorf("expected %v to be a subset of %v", u, u) + } + }) + + t.Run("not a subset", func(t *testing.T) { + v := schema.FromTuple(map[string]any{"foo": 1, "baz": "3"}) + if u.IsSubsetOf(v) { + t.Errorf("expected %v not to be a subset of %v", u, v) + } + }) + + t.Run("no a subset 2", func(t *testing.T) { + v := schema.FromTuple(map[string]any{"foo": 1, "bar": 2.0, "baz": "3"}) + if v.IsSubsetOf(u) { + t.Errorf("expected %v not to be a subset of %v", v, u) + } + }) + + t.Run("empty", func(t *testing.T) { + if !schema.FromTuple(map[string]any{}).IsSubsetOf(u) { + t.Errorf("expected empty schema to be a subset of %v", u) + } + }) +} diff --git a/table/delete.go b/table/delete.go new file mode 100644 index 0000000..c3c5eba --- /dev/null +++ b/table/delete.go @@ -0,0 +1,12 @@ +package table + +import "github.com/martindrlik/rex/schema" + +// Delete deletes tuples from the table. +func (t *Table) Delete(u map[string]any) { + us := schema.FromTuple(u) + r, ok := t.Relation(us) + if ok { + r.Delete(u) + } +} diff --git a/table/delete_test.go b/table/delete_test.go new file mode 100644 index 0000000..618dcaa --- /dev/null +++ b/table/delete_test.go @@ -0,0 +1,19 @@ +package table_test + +import ( + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestDelete(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + q := newTable(t, schema.FromTuple(foobar), foobar) + if !q.Has(foobar) { + t.Errorf("expected to contain %v", foobar) + } + q.Delete(foobar) + if q.Has(foobar) { + t.Errorf("expected to not contain %v", foobar) + } +} diff --git a/table/difference.go b/table/difference.go new file mode 100644 index 0000000..a83d07f --- /dev/null +++ b/table/difference.go @@ -0,0 +1,29 @@ +package table + +import ( + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" +) + +func (t *Table) Difference(u *Table) (*Table, error) { + if !t.Schema.Equal(u.Schema) { + return nil, schema.ErrMismatch + } + + w, _ := New(t.Schema) + for _, tr := range t.RelationSet { + if tr.Schema.Equal(t.Schema) { + if ur, ok := u.Relation(tr.Schema); ok { + if w0, _ := tr.Difference(ur); len(w0.TupleSet) > 0 { + w.RelationSet.Add(w0) + } + } + continue + } + if len(tr.TupleSet) > 0 { + w.RelationSet.Add(relation.Clone(tr)) + } + } + + return w, nil +} diff --git a/table/difference_test.go b/table/difference_test.go new file mode 100644 index 0000000..79fcb55 --- /dev/null +++ b/table/difference_test.go @@ -0,0 +1,42 @@ +package table_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func TestDifference(t *testing.T) { + foo1 := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + bar := map[string]any{"bar": 3.0} + + foo1bar := tuple.Merge(foo1, bar) + foo2bar := tuple.Merge(foo2, bar) + + q := newTable(t, schema.FromTuple(foo1bar), foo1, foo2, foo1bar, foo2bar) + r := newTable(t, schema.FromTuple(foo1bar), foo1, foo1bar) + + w, err := q.Difference(r) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo1, foo2, foo2bar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + baz := map[string]any{"baz": "qux"} + r := newTable(t, schema.FromTuple(baz), baz) + _, err := q.Difference(r) + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/table/example_test.go b/table/example_test.go new file mode 100644 index 0000000..591cdce --- /dev/null +++ b/table/example_test.go @@ -0,0 +1,90 @@ +package table_test + +import ( + "fmt" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/table" + "github.com/martindrlik/rex/tuple" +) + +func ExampleTable() { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 2.0} + foobar := tuple.Merge(foo, bar) + + q, _ := table.New(schema.FromTuple(foobar)) + q.Add(foo) + q.Add(bar) + q.Add(foobar) + + for t := range q.List() { + fmt.Println(t) + } + + // Output: + // map[foo:1] + // map[bar:2] + // map[bar:2 foo:1] +} + +func ExampleTable_Add() { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 2.0} + q, _ := table.New(schema.FromTuple(foo)) + fmt.Println(q.Add(bar) == schema.ErrMismatch) + + r, _ := table.New(schema.FromTuple(tuple.Merge(foo, bar))) + baz := map[string]any{"baz": "3"} + fmt.Println(r.Add(baz) == schema.ErrMismatch) + fmt.Println(r.Add(bar) == nil) + + // Output: + // true + // true + // true +} + +func ExampleTable_NaturalJoin() { + foobar := map[string]any{"foo": 1, "bar": 2.0} + barbaz := map[string]any{"bar": 2.0, "baz": "3"} + + q, _ := table.New(schema.FromTuple(foobar)) + q.Add(foobar) + r, _ := table.New(schema.FromTuple(barbaz)) + r.Add(barbaz) + + s := q.NaturalJoin(r) + + for t := range s.List() { + fmt.Println(t) + } + + // Output: + // map[bar:2 baz:3 foo:1] +} + +func ExampleTable_Projection() { + foo := map[string]any{"foo": 1} + foo2 := map[string]any{"foo": 2} + bar := map[string]any{"bar": 3.0} + foobar := tuple.Merge(foo, bar) + foo2bar := tuple.Merge(foo2, bar) + + q, _ := table.New(schema.FromTuple(foobar)) + _ = q.Add(foo) + _ = q.Add(foo2) + _ = q.Add(bar) + _ = q.Add(foobar) + _ = q.Add(foo2bar) + + s, _ := q.Projection("foo") + + for t := range s.List() { + fmt.Println(t) + } + + // Output: + // map[foo:1] + // map[foo:2] +} diff --git a/table/list.go b/table/list.go new file mode 100644 index 0000000..9d21d9c --- /dev/null +++ b/table/list.go @@ -0,0 +1,16 @@ +package table + +import "iter" + +// List returns a sequence of all tuples in the table. +func (t *Table) List() iter.Seq[map[string]any] { + return func(yield func(map[string]any) bool) { + for _, r := range t.RelationSet { + for _, t := range r.TupleSet { + if !yield(t) { + return + } + } + } + } +} diff --git a/table/list_test.go b/table/list_test.go new file mode 100644 index 0000000..772333d --- /dev/null +++ b/table/list_test.go @@ -0,0 +1,36 @@ +package table_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func TestList(t *testing.T) { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 1.0} + foobar := tuple.Merge(foo, bar) + q := newTable(t, schema.FromTuple(foobar), foo, bar, foobar) + actual := fmt.Sprintf("%v", slices.Collect(q.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo, bar, foobar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + t.Run("break", func(t *testing.T) { + uu := []map[string]any{} + for u := range q.List() { + uu = append(uu, u) + if len(uu) == 2 { + break + } + } + actual := fmt.Sprintf("%v", uu) + expect := fmt.Sprintf("%v", []map[string]any{foo, bar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + }) +} diff --git a/table/naturaljoin.go b/table/naturaljoin.go new file mode 100644 index 0000000..634b0d5 --- /dev/null +++ b/table/naturaljoin.go @@ -0,0 +1,47 @@ +package table + +import ( + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func (t *Table) NaturalJoin(u *Table) *Table { + common := t.Schema.Intersection(u.Schema) + if len(common) == 0 { + return t.cartasianProduct(u) + } else { + return t.naturalJoin(u, common) + } +} + +func (t *Table) cartasianProduct(u *Table) *Table { + w, _ := New(tuple.Merge(t.Schema, u.Schema)) + for _, tr := range t.RelationSet { + for _, ur := range u.RelationSet { + if wr, ok := tr.NaturalJoin(ur); ok { + w.RelationSet.Add(wr) + } + } + } + return w +} + +func (t *Table) naturalJoin(u *Table, common schema.Schema) *Table { + rr := []*relation.Relation{} + for _, tr := range t.RelationSet { + for _, ur := range u.RelationSet { + if common.IsSubsetOf(tr.Schema) && common.IsSubsetOf(ur.Schema) { + if wr, ok := tr.NaturalJoin(ur); ok { + rr = append(rr, wr) + } + } + } + } + + w, _ := New(tuple.Merge(t.Schema, u.Schema)) + for _, r := range rr { + w.RelationSet.Add(r) + } + return w +} diff --git a/table/naturaljoin_test.go b/table/naturaljoin_test.go new file mode 100644 index 0000000..30e6f85 --- /dev/null +++ b/table/naturaljoin_test.go @@ -0,0 +1,65 @@ +package table_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func TestNaturalJoin(t *testing.T) { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 2.0} + baz := map[string]any{"baz": "3"} + foobar := tuple.Merge(foo, bar) + foobaz := tuple.Merge(foo, baz) + q := newTable(t, schema.FromTuple(foobar), foo, foobar) + r := newTable(t, schema.FromTuple(foobaz), foo, foobaz) + w := q.NaturalJoin(r) + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{ + foo, + foobaz, + tuple.Merge(foobar, foo), + tuple.Merge(foobar, foobaz), + }) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("empty", func(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + baz := map[string]any{"baz": "3"} + q := newTable(t, schema.FromTuple(foobar)) + r := newTable(t, schema.FromTuple(baz), baz) + w := q.NaturalJoin(r) + for range w.List() { + t.Error("unexpected non-empty result") + } + + }) +} + +func TestNaturalJoinCartesianProduct(t *testing.T) { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 2.0} + baz := map[string]any{"baz": "3"} + pub := map[string]any{"pub": complex(4.0, 1.0)} + foobar := tuple.Merge(foo, bar) + bazpub := tuple.Merge(baz, pub) + q := newTable(t, schema.FromTuple(foobar), foo, foobar) + r := newTable(t, schema.FromTuple(bazpub), baz, bazpub) + w := q.NaturalJoin(r) + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{ + tuple.Merge(foo, baz), + tuple.Merge(foo, bazpub), + tuple.Merge(foobar, baz), + tuple.Merge(foobar, bazpub), + }) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } +} diff --git a/table/projection.go b/table/projection.go new file mode 100644 index 0000000..0ae2442 --- /dev/null +++ b/table/projection.go @@ -0,0 +1,31 @@ +package table + +import ( + "maps" + "slices" + + "github.com/martindrlik/rex/schema" +) + +func (t *Table) Projection(p ...string) (*Table, error) { + s, ok := t.Schema.Projection(p...) + if !ok { + return nil, schema.ErrMismatch + } + + w, _ := New(s) + + for _, r := range t.RelationSet { + i := s.Intersection(r.Schema) + if len(i) == 0 { + continue + } + s := slices.Collect(maps.Keys(i)) + wr, _ := r.Projection(s...) + for wt := range wr.List() { + _ = w.Add(wt) + } + } + + return w, nil +} diff --git a/table/projection_test.go b/table/projection_test.go new file mode 100644 index 0000000..b2b8a80 --- /dev/null +++ b/table/projection_test.go @@ -0,0 +1,47 @@ +package table_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func TestProjection(t *testing.T) { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 2.0} + foobar := tuple.Merge(foo, bar) + q := newTable(t, schema.FromTuple(foobar), foo, bar, foobar) + w, err := q.Projection("foo") + if err != nil { + t.Fatalf("unexpected error %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("keep relation with schema subset", func(t *testing.T) { + foobarbaz := map[string]any{"foo": 1, "bar": 2.0, "baz": "3"} + q := newTable(t, schema.FromTuple(foobarbaz), foo, bar, foobarbaz) + w, err := q.Projection("foo", "bar") + if err != nil { + t.Fatalf("unexpected error %v", err) + } + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo, bar, foobar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + }) + + t.Run("mismatch", func(t *testing.T) { + _, err := q.Projection("qux") + if err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/table/rename.go b/table/rename.go new file mode 100644 index 0000000..639ac2b --- /dev/null +++ b/table/rename.go @@ -0,0 +1,28 @@ +package table + +import "github.com/martindrlik/rex/schema" + +// Rename1 returns a new table with the attribute old renamed to new. +func (t *Table) Rename1(old, new string) (*Table, error) { + if !t.Schema.Has(old) || t.Schema.Has(new) { + return nil, schema.ErrMismatch + } + + s := schema.Schema{} + for k, v := range t.Schema { + s[k] = v + } + s[new] = s[old] + delete(s, old) + + w, _ := New(s) + + for _, tr := range t.RelationSet { + if tr.Schema.Has(old) { + wr, _ := tr.Rename1(old, new) + w.RelationSet.Add(wr) + } + } + + return w, nil +} diff --git a/table/rename_test.go b/table/rename_test.go new file mode 100644 index 0000000..c20b33d --- /dev/null +++ b/table/rename_test.go @@ -0,0 +1,32 @@ +package table_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" +) + +func TestRename1(t *testing.T) { + foobaz := map[string]any{"foo": 1, "baz": 2.0} + q := newTable(t, schema.FromTuple(foobaz), foobaz) + r, err := q.Rename1("baz", "bar") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + foobar := map[string]any{"foo": 1, "bar": 2.0} + actual := fmt.Sprintf("%v", slices.Collect(r.List())) + expect := fmt.Sprintf("%v", []map[string]any{foobar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + _, err := q.Rename1("pub", "bar") + if err != schema.ErrMismatch { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/table/table.go b/table/table.go new file mode 100644 index 0000000..a23b0b9 --- /dev/null +++ b/table/table.go @@ -0,0 +1,44 @@ +package table + +import ( + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" +) + +type Table struct { + schema.Schema + relation.RelationSet +} + +// New returns a new table with the given schema. +func New(s schema.Schema) (*Table, error) { + if len(s) == 0 { + return nil, schema.ErrEmpty + } + return &Table{Schema: s}, nil +} + +// Add adds a tuple to the table. If the tuple is already in the table, it does nothing. +func (t *Table) Add(u map[string]any) error { + if len(u) == 0 { + return schema.ErrEmpty + } + us := schema.FromTuple(u) + if !us.IsSubsetOf(t.Schema) { + return schema.ErrMismatch + } + r, ok := t.Relation(us) + if !ok { + r, _ = relation.New(us) + t.RelationSet.Add(r) + } + if !r.TupleSet.Has(u) { + r.TupleSet.Add(u) + } + return nil +} + +func (t *Table) Has(u map[string]any) bool { + r, ok := t.Relation(schema.FromTuple(u)) + return ok && r.TupleSet.Has(u) +} diff --git a/table/table_test.go b/table/table_test.go new file mode 100644 index 0000000..ccaa4ca --- /dev/null +++ b/table/table_test.go @@ -0,0 +1,101 @@ +package table_test + +import ( + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/table" + "github.com/martindrlik/rex/tuple" +) + +func TestTable(t *testing.T) { + t.Run("empty schema", func(t *testing.T) { + _, err := table.New(schema.Schema{}) + if err != schema.ErrEmpty { + t.Errorf("expected error %v got %v", schema.ErrEmpty, err) + } + q, err := table.New(schema.FromTuple(map[string]any{"foo": 1})) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if err := q.Add(map[string]any{}); err != schema.ErrEmpty { + t.Errorf("expected error %v got %v", schema.ErrEmpty, err) + } + }) + t.Run("mismatch", func(t *testing.T) { + q, err := table.New(schema.FromTuple(map[string]any{"foo": 1})) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if err := q.Add(map[string]any{"bar": 2.0}); err != schema.ErrMismatch { + t.Errorf("expected error %v got %v", schema.ErrMismatch, err) + } + }) + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 2.0} + foobar := tuple.Merge(foo, bar) + q, err := table.New(schema.FromTuple(foobar)) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + add := func(u map[string]any) { + t.Helper() + if err := q.Add(u); err != nil { + t.Errorf("unexpected error: %v", err) + } + } + add(foobar) + add(foo) + add(bar) + + has := func(u map[string]any) { + t.Helper() + r, ok := q.Relation(schema.FromTuple(u)) + if !ok { + t.Error("expected to find relation in set") + } + if !r.TupleSet.Has(u) { + t.Errorf("expected to find %v in relation", u) + } + } + has(foobar) + has(foo) + has(bar) +} + +func TestHas(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + q := newTable(t, schema.FromTuple(foobar), foobar) + if !q.Has(foobar) { + t.Error("expected to find tuple in table") + } + + foo := map[string]any{"foo": 1} + if q.Has(foo) { + t.Errorf("expected to not find %v in table", foo) + } + + fbb := map[string]any{"foo": 1, "bar": 2.0, "baz": "3"} + if q.Has(fbb) { + t.Errorf("expected to not find %v in table", fbb) + } +} + +func newTable(t *testing.T, s schema.Schema, tt ...map[string]any) *table.Table { + t.Helper() + q, err := table.New(s) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + add(t, q, tt...) + return q +} + +func add(t *testing.T, q *table.Table, tt ...map[string]any) { + t.Helper() + for _, u := range tt { + if err := q.Add(u); err != nil { + t.Fatalf("unexpected error: %v", err) + } + } +} diff --git a/table/union.go b/table/union.go new file mode 100644 index 0000000..d072a18 --- /dev/null +++ b/table/union.go @@ -0,0 +1,32 @@ +package table + +import ( + "github.com/martindrlik/rex/relation" + "github.com/martindrlik/rex/schema" +) + +// Union returns a new table that is the union of t and u. +func (t *Table) Union(u *Table) (*Table, error) { + if !t.Schema.Equal(u.Schema) { + return nil, schema.ErrMismatch + } + + w, _ := New(t.Schema) + for _, tr := range t.RelationSet { + ur, ok := u.Relation(tr.Schema) + if ok { + wr, _ := tr.Union(ur) + w.RelationSet.Add(wr) + } else { + w.RelationSet.Add(relation.Clone(tr)) + } + } + for _, ur := range u.RelationSet { + _, ok := t.Relation(ur.Schema) + if !ok { + w.RelationSet.Add(relation.Clone(ur)) + } + } + + return w, nil +} diff --git a/table/union_test.go b/table/union_test.go new file mode 100644 index 0000000..7f0f9eb --- /dev/null +++ b/table/union_test.go @@ -0,0 +1,41 @@ +package table_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/martindrlik/rex/schema" + "github.com/martindrlik/rex/tuple" +) + +func TestUnion(t *testing.T) { + foo := map[string]any{"foo": 1} + bar := map[string]any{"bar": 1.0} + bar2 := map[string]any{"bar": 2.0} + foobar := tuple.Merge(foo, bar) + foobar2 := tuple.Merge(foo, bar2) + + q := newTable(t, schema.FromTuple(foobar), foo, foobar) + r := newTable(t, schema.FromTuple(foobar), bar, foobar2) + + w, err := q.Union(r) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + actual := fmt.Sprintf("%v", slices.Collect(w.List())) + expect := fmt.Sprintf("%v", []map[string]any{foo, foobar, foobar2, bar}) + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("mismatch", func(t *testing.T) { + baz := map[string]any{"baz": "qux"} + r := newTable(t, schema.FromTuple(baz), baz) + _, err := q.Union(r) + if err != schema.ErrMismatch { + t.Errorf("expected %v got %v", schema.ErrMismatch, err) + } + }) +} diff --git a/testdata/casting.json b/testdata/casting.json new file mode 100644 index 0000000..137595a --- /dev/null +++ b/testdata/casting.json @@ -0,0 +1,65 @@ +[ + { + "movie_id": "M1", + "actor": "Keanu Reeves", + "character": "Neo" + }, + { + "movie_id": "M2", + "actor": "Harrison Ford", + "character": "Rick Deckard" + }, + { + "movie_id": "M2", + "actor": "Ryan Gosling", + "character": "K" + }, + { + "movie_id": "M2", + "actor": "Ana de Armas", + "character": "Joi" + }, + { + "movie_id": "M5", + "actor": "Chris Pratt", + "character": "Star-Lord" + }, + { + "movie_id": "M5", + "actor": "Chukwudi Iwuji", + "character": "High Evolutionary" + }, + { + "movie_id": "M5", + "actor": "Bradley Cooper", + "character": "Rocket" + }, + { + "movie_id": "M4", + "actor": "Chris Hemsworth", + "character": "Thor" + }, + { + "movie_id": "M6", + "actor": "Margot Robbie", + "character": "Barbie" + }, + { + "movie_id": "M7", + "actor": "Timothée Chalamet", + "character": "Paul Atreides" + }, + { + "movie_id": "M7", + "actor": "Zendaya", + "character": "Chani" + }, + { + "movie_id": "M7", + "actor": "Rebecca Ferguson" + }, + { + "movie_id": "M7", + "actor": "Javier Bardem" + } +] \ No newline at end of file diff --git a/testdata/movies.json b/testdata/movies.json new file mode 100644 index 0000000..555c7d0 --- /dev/null +++ b/testdata/movies.json @@ -0,0 +1,37 @@ +[ + { + "movie_id": "M1", + "title": "The Matrix", + "year": 1999 + }, + { + "movie_id": "M2", + "title": "Blade Runner 2049", + "year": 2017 + }, + { + "movie_id": "M3", + "title": "Dune: Part One", + "year": 2021 + }, + { + "movie_id": "M4", + "title": "Thor: Love and Thunder", + "year": 2022 + }, + { + "movie_id": "M5", + "title": "Guardians of the Galaxy Vol. 3", + "year": 2023 + }, + { + "movie_id": "M6", + "title": "Barbie", + "year": 2023 + }, + { + "movie_id": "M7", + "title": "Dune: Part Two", + "year": 2024 + } +] \ No newline at end of file diff --git a/tuple/merge.go b/tuple/merge.go new file mode 100644 index 0000000..87943e4 --- /dev/null +++ b/tuple/merge.go @@ -0,0 +1,12 @@ +package tuple + +func Merge[V any](u, v map[string]V) map[string]V { + w := make(map[string]V) + for k, v := range u { + w[k] = v + } + for k, v := range v { + w[k] = v + } + return w +} diff --git a/tuple/merge_test.go b/tuple/merge_test.go new file mode 100644 index 0000000..cef7ce5 --- /dev/null +++ b/tuple/merge_test.go @@ -0,0 +1,28 @@ +package tuple_test + +import ( + "maps" + "testing" + + "github.com/martindrlik/rex/tuple" +) + +func TestMerge(t *testing.T) { + u := map[string]any{"foo": 1, "bar": 2.0} + v := map[string]any{"foo": 1, "baz": "3"} + actual := tuple.Merge(u, v) + expect := map[string]any{"foo": 1, "bar": 2.0, "baz": "3"} + if !maps.Equal(actual, expect) { + t.Errorf("expected %v got %v", expect, actual) + } + + t.Run("overwrite", func(t *testing.T) { + const fooval = 2 + v := map[string]any{"foo": fooval, "baz": "3"} + actual := tuple.Merge(u, v) + expect := map[string]any{"foo": fooval, "bar": 2.0, "baz": "3"} + if !maps.Equal(actual, expect) { + t.Errorf("expected %v got %v", expect, actual) + } + }) +} diff --git a/tuple/projection.go b/tuple/projection.go new file mode 100644 index 0000000..4a5796d --- /dev/null +++ b/tuple/projection.go @@ -0,0 +1,9 @@ +package tuple + +func (t Tuple) Projection(p ...string) Tuple { + w := make(Tuple, len(p)) + for _, a := range p { + w[a] = t[a] + } + return w +} diff --git a/tuple/projection_test.go b/tuple/projection_test.go new file mode 100644 index 0000000..34b1f91 --- /dev/null +++ b/tuple/projection_test.go @@ -0,0 +1,17 @@ +package tuple_test + +import ( + "maps" + "testing" + + "github.com/martindrlik/rex/tuple" +) + +func TestProjection(t *testing.T) { + u := tuple.Tuple{"foo": 1, "bar": 2.0, "baz": "3"} + actual := u.Projection("foo", "baz") + expect := tuple.Tuple{"foo": 1, "baz": "3"} + if !maps.Equal(actual, expect) { + t.Errorf("expected %v got %v", expect, actual) + } +} diff --git a/tuple/tuple.go b/tuple/tuple.go new file mode 100644 index 0000000..a4475c9 --- /dev/null +++ b/tuple/tuple.go @@ -0,0 +1,3 @@ +package tuple + +type Tuple map[string]any diff --git a/tuple/tupleset.go b/tuple/tupleset.go new file mode 100644 index 0000000..fe2e433 --- /dev/null +++ b/tuple/tupleset.go @@ -0,0 +1,33 @@ +package tuple + +import ( + "maps" + "slices" +) + +type TupleSet []Tuple + +func (ts *TupleSet) Add(t Tuple) { + *ts = append(*ts, t) +} + +func (ts *TupleSet) Has(t Tuple) bool { + _, ok := ts.index(t) + return ok +} + +func (ts *TupleSet) Delete(t Tuple) { + i, ok := ts.index(t) + if ok { + *ts = slices.Delete(*ts, i, i+1) + } +} + +func (ts *TupleSet) index(t Tuple) (int, bool) { + for i, t0 := range *ts { + if maps.Equal(t0, t) { + return i, true + } + } + return 0, false +} diff --git a/tuple/tupleset_test.go b/tuple/tupleset_test.go new file mode 100644 index 0000000..e63787e --- /dev/null +++ b/tuple/tupleset_test.go @@ -0,0 +1,38 @@ +package tuple_test + +import ( + "fmt" + "testing" + + "github.com/martindrlik/rex/tuple" +) + +func TestTupleSet(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + ts := tuple.TupleSet{} + ts.Add(foobar) + actual := fmt.Sprintf("%v", ts) + expect := "[map[bar:2 foo:1]]" + if actual != expect { + t.Errorf("expected %v got %v", expect, actual) + } + t.Run("has", func(t *testing.T) { + barbaz := map[string]any{"bar": 2.0, "baz": "3"} + if !ts.Has(foobar) { + t.Errorf("expected %v to contain %v", ts, foobar) + } + if ts.Has(barbaz) { + t.Errorf("expected %v to not contain %v", ts, barbaz) + } + }) +} + +func TestDelete(t *testing.T) { + foobar := map[string]any{"foo": 1, "bar": 2.0} + ts := tuple.TupleSet{} + ts.Add(foobar) + ts.Delete(foobar) + if ts.Has(foobar) { + t.Errorf("expected %v to not contain %v", ts, foobar) + } +}