diff --git a/key.go b/key.go index 1c740a2..42cea30 100644 --- a/key.go +++ b/key.go @@ -212,20 +212,27 @@ func (k Key) ChildString(s string) Key { // NewKey("/Comedy").IsAncestorOf("/Comedy/MontyPython") // true func (k Key) IsAncestorOf(other Key) bool { - if other.string == k.string { + // equivalent to HasPrefix(other, k.string + "/") + + if len(other.string) <= len(k.string) { + // We're not long enough to be a child. return false } - return strings.HasPrefix(other.string, k.string) + + if k.string == "/" { + // We're the root and the other key is longer. + return true + } + + // "other" starts with /k.string/ + return other.string[len(k.string)] == '/' && other.string[:len(k.string)] == k.string } // IsDescendantOf returns whether this key contains another as a prefix. // NewKey("/Comedy/MontyPython").IsDescendantOf("/Comedy") // true func (k Key) IsDescendantOf(other Key) bool { - if other.string == k.string { - return false - } - return strings.HasPrefix(k.string, other.string) + return other.IsAncestorOf(k) } // IsTopLevel returns whether this key has only one namespace. diff --git a/key_test.go b/key_test.go index 3219063..7272af0 100644 --- a/key_test.go +++ b/key_test.go @@ -84,19 +84,23 @@ func CheckTrue(c *C, cond bool) { func (ks *KeySuite) TestKeyAncestry(c *C) { k1 := NewKey("/A/B/C") k2 := NewKey("/A/B/C/D") + k3 := NewKey("/AB") + k4 := NewKey("/A") c.Check(k1.String(), Equals, "/A/B/C") c.Check(k2.String(), Equals, "/A/B/C/D") CheckTrue(c, k1.IsAncestorOf(k2)) CheckTrue(c, k2.IsDescendantOf(k1)) - CheckTrue(c, NewKey("/A").IsAncestorOf(k2)) - CheckTrue(c, NewKey("/A").IsAncestorOf(k1)) - CheckTrue(c, !NewKey("/A").IsDescendantOf(k2)) - CheckTrue(c, !NewKey("/A").IsDescendantOf(k1)) - CheckTrue(c, k2.IsDescendantOf(NewKey("/A"))) - CheckTrue(c, k1.IsDescendantOf(NewKey("/A"))) - CheckTrue(c, !k2.IsAncestorOf(NewKey("/A"))) - CheckTrue(c, !k1.IsAncestorOf(NewKey("/A"))) + CheckTrue(c, k4.IsAncestorOf(k2)) + CheckTrue(c, k4.IsAncestorOf(k1)) + CheckTrue(c, !k4.IsDescendantOf(k2)) + CheckTrue(c, !k4.IsDescendantOf(k1)) + CheckTrue(c, !k3.IsDescendantOf(k4)) + CheckTrue(c, !k4.IsAncestorOf(k3)) + CheckTrue(c, k2.IsDescendantOf(k4)) + CheckTrue(c, k1.IsDescendantOf(k4)) + CheckTrue(c, !k2.IsAncestorOf(k4)) + CheckTrue(c, !k1.IsAncestorOf(k4)) CheckTrue(c, !k2.IsAncestorOf(k2)) CheckTrue(c, !k1.IsAncestorOf(k1)) c.Check(k1.Child(NewKey("D")).String(), Equals, k2.String()) diff --git a/mount/lookup_test.go b/mount/lookup_test.go new file mode 100644 index 0000000..119fa0d --- /dev/null +++ b/mount/lookup_test.go @@ -0,0 +1,45 @@ +package mount + +import ( + "testing" + + datastore "github.com/ipfs/go-datastore" +) + +func TestLookup(t *testing.T) { + mapds0 := datastore.NewMapDatastore() + mapds1 := datastore.NewMapDatastore() + mapds2 := datastore.NewMapDatastore() + mapds3 := datastore.NewMapDatastore() + m := New([]Mount{ + {Prefix: datastore.NewKey("/"), Datastore: mapds0}, + {Prefix: datastore.NewKey("/foo"), Datastore: mapds1}, + {Prefix: datastore.NewKey("/bar"), Datastore: mapds2}, + {Prefix: datastore.NewKey("/baz"), Datastore: mapds3}, + }) + _, mnts, _ := m.lookupAll(datastore.NewKey("/bar")) + if len(mnts) != 1 || mnts[0] != datastore.NewKey("/bar") { + t.Errorf("expected to find the mountpoint /bar, got %v", mnts) + } + + _, mnts, _ = m.lookupAll(datastore.NewKey("/fo")) + if len(mnts) != 1 || mnts[0] != datastore.NewKey("/") { + t.Errorf("expected to find the mountpoint /, got %v", mnts) + } + + _, mnt, _ := m.lookup(datastore.NewKey("/fo")) + if mnt != datastore.NewKey("/") { + t.Errorf("expected to find the mountpoint /, got %v", mnt) + } + + // /foo lives in /, /foo/bar lives in /foo. Most systems don't let us use the key "" or /. + _, mnt, _ = m.lookup(datastore.NewKey("/foo")) + if mnt != datastore.NewKey("/") { + t.Errorf("expected to find the mountpoint /, got %v", mnt) + } + + _, mnt, _ = m.lookup(datastore.NewKey("/foo/bar")) + if mnt != datastore.NewKey("/foo") { + t.Errorf("expected to find the mountpoint /foo, got %v", mnt) + } +} diff --git a/mount/mount.go b/mount/mount.go index c3c8474..c735005 100644 --- a/mount/mount.go +++ b/mount/mount.go @@ -18,11 +18,18 @@ var ( ErrNoMount = errors.New("no datastore mounted for this key") ) +// Mount defines a datastore mount. It mounts the given datastore at the given +// prefix. type Mount struct { Prefix ds.Key Datastore ds.Datastore } +// New creates a new mount datstore from the given mounts. See the documentation +// on Datastore for details. +// +// The order of the mounts does not matter, they will be applied most specific +// to least specific. func New(mounts []Mount) *Datastore { // make a copy so we're sure it doesn't mutate m := make([]Mount, len(mounts)) @@ -31,15 +38,39 @@ func New(mounts []Mount) *Datastore { return &Datastore{mounts: m} } +// Datastore is a mount datastore. In this datastore, keys live under the most +// specific mounted sub-datastore. That is, given sub-datastores mounted under: +// +// * / +// * /foo +// * /foo/bar +// +// Keys would be written as follows: +// +// * /foo, /foobar, /baz would all live under /. +// * /foo/baz, /foo/bar, etc. would live under /foo. +// * /foo/bar/baz would live under /foo/bar. +// +// Additionally, even if the datastore mounted at / contains the key /foo/thing, +// the datastore mounted at /foo would mask this value in get, deletes, and +// query results. +// +// Finally, if no root (/) mount is provided, operations on keys living outside +// all of the provided mounts will behave as follows: +// +// * Get - Returns datastore.ErrNotFound. +// * Query - Returns no results. +// * Put - Returns ErrNoMount. type Datastore struct { mounts []Mount } var _ ds.Datastore = (*Datastore)(nil) +// lookup looks up the datastore in which the given key lives. func (d *Datastore) lookup(key ds.Key) (ds.Datastore, ds.Key, ds.Key) { for _, m := range d.mounts { - if m.Prefix.Equal(key) || m.Prefix.IsAncestorOf(key) { + if m.Prefix.IsAncestorOf(key) { s := strings.TrimPrefix(key.String(), m.Prefix.String()) k := ds.NewKey(s) return m.Datastore, m.Prefix, k @@ -147,38 +178,58 @@ func (h *querySet) next() (query.Result, bool) { return next, true } -// lookupAll returns all mounts that might contain keys that are descendant of +// lookupAll returns all mounts that might contain keys that are strict +// descendants of . It will not return mounts that match key exactly. +// +// Specifically, this function will return three slices: +// +// * The matching datastores. +// * The prefixes where each matching datastore has been mounted. +// * The prefix within these datastores at which descendants of the passed key +// live. If the mounted datastore is fully contained within the given key, +// this will be /. +// +// By example, given the datastores: // -// Matching: /ao/e +// * / - root +// * /foo - +// * /bar +// * /foo/bar // -// / B /ao/e -// /a/ not matching -// /ao/ B /e -// /ao/e/ A / -// /ao/e/uh/ A / -// /aoe/ not matching +// This function function will behave as follows: +// +// * key -> ([mountpoints], [rests]) # comment +// * / -> ([/, /foo, /bar, /foo/bar], [/, /, /, /]) # all datastores +// * /foo -> ([/foo, /foo/bar], [/, /]) # all datastores under /foo +// * /foo/bar -> ([/foo/bar], [/]) # /foo/bar +// * /bar/foo -> ([/bar], [/foo]) # the datastore mounted at /bar, rest is /foo +// * /ba -> ([/], [/]) # the root; only full components are matched. func (d *Datastore) lookupAll(key ds.Key) (dst []ds.Datastore, mountpoint, rest []ds.Key) { for _, m := range d.mounts { - p := m.Prefix.String() - if len(p) > 1 { - p = p + "/" - } - - if strings.HasPrefix(p, key.String()) { + if m.Prefix.IsDescendantOf(key) { dst = append(dst, m.Datastore) mountpoint = append(mountpoint, m.Prefix) rest = append(rest, ds.NewKey("/")) - } else if strings.HasPrefix(key.String(), p) { + } else if m.Prefix.Equal(key) || m.Prefix.IsAncestorOf(key) { r := strings.TrimPrefix(key.String(), m.Prefix.String()) dst = append(dst, m.Datastore) mountpoint = append(mountpoint, m.Prefix) rest = append(rest, ds.NewKey(r)) + + // We've found an ancestor (or equal) key. We might have + // more general datastores, but they won't contain keys + // with this prefix so there's no point in searching them. + break } } return dst, mountpoint, rest } +// Put puts the given value into the datastore at the given key. +// +// Returns ErrNoMount if there no datastores are mounted at the appropriate +// prefix for the given key. func (d *Datastore) Put(key ds.Key, value []byte) error { cds, _, k := d.lookup(key) if cds == nil { @@ -191,20 +242,17 @@ func (d *Datastore) Put(key ds.Key, value []byte) error { func (d *Datastore) Sync(prefix ds.Key) error { // Sync all mount points below the prefix // Sync the mount point right at (or above) the prefix - dstores, mountPts, rest := d.lookupAll(prefix) + dstores, _, rest := d.lookupAll(prefix) for i, suffix := range rest { if err := dstores[i].Sync(suffix); err != nil { return err } - - if mountPts[i].Equal(prefix) || suffix.String() != "/" { - return nil - } } return nil } +// Get returns the value associated with the key from the appropriate datastore. func (d *Datastore) Get(key ds.Key) (value []byte, err error) { cds, _, k := d.lookup(key) if cds == nil { @@ -213,6 +261,8 @@ func (d *Datastore) Get(key ds.Key) (value []byte, err error) { return cds.Get(k) } +// Has returns the true if there exists a value associated with key in the +// appropriate datastore. func (d *Datastore) Has(key ds.Key) (exists bool, err error) { cds, _, k := d.lookup(key) if cds == nil { @@ -221,6 +271,8 @@ func (d *Datastore) Has(key ds.Key) (exists bool, err error) { return cds.Has(k) } +// Get returns the size of the value associated with the key in the appropriate +// datastore. func (d *Datastore) GetSize(key ds.Key) (size int, err error) { cds, _, k := d.lookup(key) if cds == nil { @@ -229,6 +281,10 @@ func (d *Datastore) GetSize(key ds.Key) (size int, err error) { return cds.GetSize(k) } +// Delete deletes the value associated with the key in the appropriate +// datastore. +// +// Delete returns no error if there is no value associated with the given key. func (d *Datastore) Delete(key ds.Key) error { cds, _, k := d.lookup(key) if cds == nil { @@ -237,6 +293,11 @@ func (d *Datastore) Delete(key ds.Key) error { return cds.Delete(k) } +// Query queries the appropriate mounted datastores, merging the results +// according to the given orders. +// +// If a query prefix is specified, Query will avoid querying datastores mounted +// outside that prefix. func (d *Datastore) Query(master query.Query) (query.Results, error) { childQuery := query.Query{ Prefix: master.Prefix, @@ -292,6 +353,7 @@ func (d *Datastore) Query(master query.Query) (query.Results, error) { return qr, nil } +// Close closes all mounted datastores. func (d *Datastore) Close() error { for _, d := range d.mounts { err := d.Datastore.Close() @@ -323,6 +385,7 @@ type mountBatch struct { d *Datastore } +// Batch returns a batch that operates over all mounted datastores. func (d *Datastore) Batch() (ds.Batch, error) { return &mountBatch{ mounts: make(map[string]ds.Batch), diff --git a/mount/mount_test.go b/mount/mount_test.go index 6db61d4..7d88b0a 100644 --- a/mount/mount_test.go +++ b/mount/mount_test.go @@ -268,49 +268,57 @@ func TestQueryAcrossMounts(t *testing.T) { t.Fatal(err) } - res, err := m.Query(query.Query{Prefix: "/ba"}) - if err != nil { - t.Fatalf("Query fail: %v\n", err) - } - entries, err := res.Rest() - if err != nil { - err = res.Close() + expect := func(prefix string, values map[string]string) { + t.Helper() + res, err := m.Query(query.Query{Prefix: prefix}) if err != nil { - t.Errorf("result.Close failed %d", err) + t.Fatalf("Query fail: %v\n", err) } - t.Fatalf("Query Results.Rest fail: %v\n", err) - } - seen := 0 - - expect := map[string]string{ - "/foo/lorem": "y u here", - "/bar/ipsum": "234", - "/bar/dolor": "345", - "/baz/sit": "456", - "/banana": "567", - } - for _, e := range entries { - v := expect[e.Key] - if v == "" { - t.Errorf("unexpected key %s", e.Key) + entries, err := res.Rest() + if err != nil { + err = res.Close() + if err != nil { + t.Errorf("result.Close failed %d", err) + } + t.Fatalf("Query Results.Rest fail: %v\n", err) } - - if v != string(e.Value) { - t.Errorf("key value didn't match expected %s: '%s' - '%s'", e.Key, v, e.Value) + if len(entries) != len(values) { + t.Errorf("expected %d results, got %d", len(values), len(entries)) } + for _, e := range entries { + v, ok := values[e.Key] + if !ok { + t.Errorf("unexpected key %s", e.Key) + continue + } - expect[e.Key] = "seen" - seen++ - } + if v != string(e.Value) { + t.Errorf("key value didn't match expected %s: '%s' - '%s'", e.Key, v, e.Value) + } - if seen != 4 { - t.Errorf("expected to see 3 values, saw %d", seen) + values[e.Key] = "seen" + } } - err = res.Close() - if err != nil { - t.Errorf("result.Close failed %d", err) - } + expect("/ba", nil) + expect("/bar", map[string]string{ + "/bar/ipsum": "234", + "/bar/dolor": "345", + }) + expect("/baz/", map[string]string{ + "/baz/sit": "456", + }) + expect("/foo", map[string]string{ + "/foo/lorem": "123", + }) + expect("/", map[string]string{ + "/foo/lorem": "123", + "/bar/ipsum": "234", + "/bar/dolor": "345", + "/baz/sit": "456", + "/banana": "567", + }) + expect("/banana", nil) } func TestQueryAcrossMountsWithSort(t *testing.T) { diff --git a/namespace/namespace_test.go b/namespace/namespace_test.go index 7c7d695..9062ea0 100644 --- a/namespace/namespace_test.go +++ b/namespace/namespace_test.go @@ -122,7 +122,6 @@ func (ks *DSSuite) TestQuery(c *C) { c.Check(err, Equals, nil) expect = []dsq.Entry{ - {Key: "/bar", Size: len([]byte("/foo/bar")), Value: []byte("/foo/bar")}, {Key: "/bar/baz", Size: len([]byte("/foo/bar/baz")), Value: []byte("/foo/bar/baz")}, } diff --git a/query/filter_test.go b/query/filter_test.go index 5591916..a6c78de 100644 --- a/query/filter_test.go +++ b/query/filter_test.go @@ -6,6 +6,7 @@ import ( ) func testKeyFilter(t *testing.T, f Filter, keys []string, expect []string) { + t.Helper() e := make([]Entry, len(keys)) for i, k := range keys { e[i] = Entry{Key: k} @@ -37,6 +38,8 @@ func TestFilterKeyCompare(t *testing.T) { testKeyFilter(t, FilterKeyCompare{GreaterThan, "/ab"}, sampleKeys, []string{ "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/abce", "/abcf", }) @@ -51,6 +54,8 @@ func TestFilterKeyPrefix(t *testing.T) { testKeyFilter(t, FilterKeyPrefix{"/a"}, sampleKeys, []string{ "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/a", "/abce", "/abcf", @@ -59,5 +64,7 @@ func TestFilterKeyPrefix(t *testing.T) { testKeyFilter(t, FilterKeyPrefix{"/ab/"}, sampleKeys, []string{ "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", }) } diff --git a/query/order_test.go b/query/order_test.go index 5012c13..71ad210 100644 --- a/query/order_test.go +++ b/query/order_test.go @@ -6,6 +6,8 @@ import ( ) func testKeyOrder(t *testing.T, f Order, keys []string, expect []string) { + t.Helper() + e := make([]Entry, len(keys)) for i, k := range keys { e[i] = Entry{Key: k} @@ -39,12 +41,16 @@ func TestOrderByKey(t *testing.T) { "/ab", "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/abce", "/abcf", }) testKeyOrder(t, OrderByKeyDescending{}, sampleKeys, []string{ "/abcf", "/abce", + "/ab/fg", + "/ab/ef", "/ab/cd", "/ab/c", "/ab", diff --git a/query/query.go b/query/query.go index 6cc0a89..a390e5b 100644 --- a/query/query.go +++ b/query/query.go @@ -22,41 +22,32 @@ the use of queries. The datastore Query model gleans a common set of operations performed when querying. To avoid pasting here years of database research, let’s summarize the operations datastore supports. -Query Operations: +Query Operations, applied in-order: - * namespace - scope the query, usually by object type + * prefix - scope the query to a given path prefix * filters - select a subset of values by applying constraints - * orders - sort the results by applying sort conditions - * limit - impose a numeric limit on the number of results + * orders - sort the results by applying sort conditions, hierarchically. * offset - skip a number of results (for efficient pagination) + * limit - impose a numeric limit on the number of results -datastore combines these operations into a simple Query class that allows +Datastore combines these operations into a simple Query class that allows applications to define their constraints in a simple, generic, way without introducing datastore specific calls, languages, etc. -Of course, different datastores provide relational query support across a -wide spectrum, from full support in traditional databases to none at all in -most key-value stores. Datastore aims to provide a common, simple interface -for the sake of application evolution over time and keeping large code bases -free of tool-specific code. It would be ridiculous to claim to support high- -performance queries on architectures that obviously do not. Instead, datastore -provides the interface, ideally translating queries to their native form -(e.g. into SQL for MySQL). - -However, on the wrong datastore, queries can potentially incur the high cost -of performing the aforemantioned query operations on the data set directly in -Go. It is the client’s responsibility to select the right tool for the job: -pick a data storage solution that fits the application’s needs now, and wrap -it with a datastore implementation. As the needs change, swap out datastore -implementations to support your new use cases. Some applications, particularly -in early development stages, can afford to incurr the cost of queries on non- -relational databases (e.g. using a FSDatastore and not worry about a database -at all). When it comes time to switch the tool for performance, updating the -application code can be as simple as swapping the datastore in one place, not -all over the application code base. This gain in engineering time, both at -initial development and during later iterations, can significantly offset the -cost of the layer of abstraction. +However, take heed: not all datastores support efficiently performing these +operations. Pick a datastore based on your needs. If you need efficient look-ups, +go for a simple key/value store. If you need efficient queries, consider an SQL +backed datastore. + +Notes: + * Prefix: When a query filters by prefix, it selects keys that are strict + children of the prefix. For example, a prefix "/foo" would select "/foo/bar" + but not "/foobar" or "/foo", + * Orders: Orders are applied hierarchically. Results are sorted by the first + ordering, then entries equal under the first ordering are sorted with the + second ordering, etc. + * Limits & Offset: Limits and offsets are applied after everything else. */ type Query struct { Prefix string // namespaces the query to results whose keys have Prefix diff --git a/query/query_impl.go b/query/query_impl.go index 175a4c2..8faba34 100644 --- a/query/query_impl.go +++ b/query/query_impl.go @@ -1,6 +1,8 @@ package query import ( + "path" + goprocess "github.com/jbenet/goprocess" ) @@ -116,7 +118,23 @@ func NaiveOrder(qr Results, orders ...Order) Results { func NaiveQueryApply(q Query, qr Results) Results { if q.Prefix != "" { - qr = NaiveFilter(qr, FilterKeyPrefix{q.Prefix}) + // Clean the prefix as a key and append / so a prefix of /bar + // only finds /bar/baz, not /barbaz. + prefix := q.Prefix + if len(prefix) == 0 { + prefix = "/" + } else { + if prefix[0] != '/' { + prefix = "/" + prefix + } + prefix = path.Clean(prefix) + } + // If the prefix isn't "/", end it in a "/" so we only find keys + // _under_ the prefix. + if prefix != "/" { + prefix += "/" + } + qr = NaiveFilter(qr, FilterKeyPrefix{prefix}) } for _, f := range q.Filters { qr = NaiveFilter(qr, f) diff --git a/query/query_test.go b/query/query_test.go index d82d817..3347f09 100644 --- a/query/query_test.go +++ b/query/query_test.go @@ -9,6 +9,8 @@ import ( var sampleKeys = []string{ "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/a", "/abce", "/abcf", @@ -16,6 +18,8 @@ var sampleKeys = []string{ } func testResults(t *testing.T, res Results, expect []string) { + t.Helper() + actualE, err := res.Rest() if err != nil { t.Fatal(err) @@ -37,6 +41,7 @@ func testResults(t *testing.T, res Results, expect []string) { func TestNaiveQueryApply(t *testing.T) { testNaiveQueryApply := func(t *testing.T, query Query, keys []string, expect []string) { + t.Helper() e := make([]Entry, len(keys)) for i, k := range keys { e[i] = Entry{Key: k} @@ -57,8 +62,8 @@ func TestNaiveQueryApply(t *testing.T) { q = Query{Offset: 3, Limit: 2} testNaiveQueryApply(t, q, sampleKeys, []string{ - "/abce", - "/abcf", + "/ab/fg", + "/a", }) f := &FilterKeyCompare{Op: Equal, Key: "/ab"} @@ -71,15 +76,16 @@ func TestNaiveQueryApply(t *testing.T) { testNaiveQueryApply(t, q, sampleKeys, []string{ "/ab/c", "/ab/cd", - "/abce", - "/abcf", - "/ab", + "/ab/ef", + "/ab/fg", }) q = Query{Orders: []Order{OrderByKeyDescending{}}} testNaiveQueryApply(t, q, sampleKeys, []string{ "/abcf", "/abce", + "/ab/fg", + "/ab/ef", "/ab/cd", "/ab/c", "/ab", @@ -87,20 +93,20 @@ func TestNaiveQueryApply(t *testing.T) { }) q = Query{ - Limit: 3, - Offset: 2, + Limit: 2, + Offset: 1, Prefix: "/ab", Orders: []Order{OrderByKey{}}, } testNaiveQueryApply(t, q, sampleKeys, []string{ "/ab/cd", - "/abce", - "/abcf", + "/ab/ef", }) } func TestLimit(t *testing.T) { testKeyLimit := func(t *testing.T, limit int, keys []string, expect []string) { + t.Helper() e := make([]Entry, len(keys)) for i, k := range keys { e[i] = Entry{Key: k} @@ -114,6 +120,8 @@ func TestLimit(t *testing.T) { testKeyLimit(t, 0, sampleKeys, []string{ // none "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/a", "/abce", "/abcf", @@ -123,6 +131,8 @@ func TestLimit(t *testing.T) { testKeyLimit(t, 10, sampleKeys, []string{ // large "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/a", "/abce", "/abcf", @@ -138,6 +148,7 @@ func TestLimit(t *testing.T) { func TestOffset(t *testing.T) { testOffset := func(t *testing.T, offset int, keys []string, expect []string) { + t.Helper() e := make([]Entry, len(keys)) for i, k := range keys { e[i] = Entry{Key: k} @@ -151,6 +162,8 @@ func TestOffset(t *testing.T) { testOffset(t, 0, sampleKeys, []string{ // none "/ab/c", "/ab/cd", + "/ab/ef", + "/ab/fg", "/a", "/abce", "/abcf", @@ -161,6 +174,8 @@ func TestOffset(t *testing.T) { }) testOffset(t, 2, sampleKeys, []string{ + "/ab/ef", + "/ab/fg", "/a", "/abce", "/abcf",