namespace: a value that names one entity database

The fleet stores OLTP data as one SQLite file per entity, and the name of
that file is a path -- <dir>/<ns>.db locally, a key prefix in object
storage. orm/db canonicalised it with path.Clean plus "must begin with a
letter", which leaves a namespace an opaque string that every consumer
re-derives structure from and re-validates: orm/replicated re-split and
re-checked each segment, commerce/db has splitTenant.

So make it a value. Namespace wraps an unexported string, the only routes
to one are the constructors and Parse, and the shape is kind[/id][/group].
Segments are [a-z0-9][a-z0-9_-]*, which leaves no spelling of a separator,
a dot-segment or a hidden name to reject later, and ids fold to lowercase
because on a case-insensitive filesystem org/Acme and org/acme are two
namespaces over one file.

This package is what a namespace IS; orm/db.Namespaces resolves one to a
handle.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-28 09:39:30 -07:00
co-authored by Hanzo Dev
commit 1a3cc15421
6 changed files with 699 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/namespace
*.test
testdata/fuzz/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hanzo AI Inc.
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.
+27
View File
@@ -0,0 +1,27 @@
# namespace
Names the one database an entity's data lives in.
```go
ns := namespace.MustRepo("42").WithGroup(Issues) // repo/42/issues
ns, err := namespace.Org(id) // org/<id>
namespace.System().WithGroup(Notices) // system/notices
```
A namespace is `kind[/id][/group]`, and its string form IS a path: `<dir>/<ns>.db`
on disk, a key prefix in object storage. So it is a value with constructors, not
a string with a validator — `Namespace` wraps an unexported field, and the only
routes to one are the constructors and `Parse`. Illegal names do not exist to be
caught.
Two rules do the work. Segments are `[a-z0-9][a-z0-9_-]*`, which leaves no
spelling of a separator, a dot-segment or a hidden name to reject later. And ids
fold to lowercase at construction, because on a case-insensitive filesystem
`org/Acme` and `org/acme` are two namespaces over one file — two handles, two
replication streams, one history, and it works on the laptop.
An id is the entity's stable identity, never a human-facing name: a rename must
be a row somewhere, not a file move and a severed history.
`hanzoai/orm/db.Namespaces` resolves a namespace to an open handle — materialise,
open, cache, evict. That is the only other thing with an opinion about namespaces.
+3
View File
@@ -0,0 +1,3 @@
module github.com/hanzoai/namespace
go 1.26.5
+296
View File
@@ -0,0 +1,296 @@
// Package namespace names the one database an entity's data lives in.
//
// The fleet stores OLTP data as one SQLite file per entity — an org, a user, a
// repository — rather than one per tenant, so that a file stays small and
// several can be opened at once. A namespace is the name of one of those files.
//
// It is a value with constructors and not a string with a validator, because
// the string form IS a path: it becomes <dir>/<namespace>.db locally and a key
// prefix in object storage. A name that a validator lets through late is a name
// that has already been written down somewhere by then. Here there is no route
// to a Namespace that does not go through a constructor, so the illegal ones do
// not exist to be caught.
//
// This package is what a namespace IS. Resolving one to an open handle —
// materialise, open, cache, evict — is hanzoai/orm/db.Namespaces, and it is the
// only other thing allowed an opinion about namespaces.
package namespace
import (
"errors"
"fmt"
"strings"
)
// Kind is the sort of entity whose data a namespace holds.
//
// The set is closed and adding to it is a patch to this file, on purpose: which
// entities own their own data is one fleet-wide fact, and a fact with two homes
// is a fact that disagrees with itself. It is also the reason Kind cannot be
// injected — no exported function takes one — so a Kind literal that names
// nothing can never reach a Namespace.
type Kind string
const (
KindOrg Kind = "org"
KindUser Kind = "user"
KindRepo Kind = "repo"
KindSystem Kind = "system"
)
// maxSegment bounds an id or a group. A filesystem component stops at 255 bytes
// and an S3 key at 1024; 64 clears both with room for the kind, the group and
// the ".db", and still holds a UUID (36) or a ULID (26) with most of it spare.
const maxSegment = 64
// Namespace names one database: an entity kind, that entity's id, and
// optionally a group naming which of its related entity types this file holds.
//
// It holds its own canonical string rather than the three parts, so String is
// free and the map key hashes as one string — Namespaces keys every open handle
// by this value and looks one up on every request. The parts are derived on the
// rare path instead, which is the right way round.
//
// The zero Namespace is not a namespace. Go gives every struct a zero value and
// no encapsulation takes that away, so it is defined rather than left to be
// discovered: IsZero reports it, String returns "", MarshalText refuses it, and
// whatever turns a namespace into a path must refuse it too. A zero value that
// resolved to a file would be one file quietly shared by every caller who
// forgot to set one — the worst failure this package exists to prevent, so it
// is the one Go forces us to state explicitly.
type Namespace struct{ s string }
// Group names a set of related entity types kept in a file of their own, so
// that an entity's data is several small databases and not one that grows
// without bound: a repository's issues need not be opened to read its settings.
//
// It is a type rather than a string because a group is CODE where an id is
// DATA. A package declares the groups it owns once —
//
// var Issues = namespace.MustGroup("issues")
//
// — while an id arrives with a request. That is the whole reason the two are
// built differently: MustGroup fails at init or never, so there is no error to
// handle at a call site that cannot fail.
//
// The zero Group means no group, i.e. the entity's own data. That is a
// definition, not a hole: ns.WithGroup(Group{}) is ns.
type Group struct{ s string }
// Org, User and Repo name one entity's database.
//
// The id is the entity's STABLE identity, never a human-facing name. A
// namespace is a path in object storage and a replication prefix; deriving it
// from something a person can rename — a slug, an email, an owner/name pair —
// makes a rename into a file move and a broken history. Transferring a
// repository between orgs is likewise a row somewhere, not a namespace change,
// which is why a repo is repo/<id> and never org/<org>/repo/<name>.
func Org(id string) (Namespace, error) { return of(KindOrg, id) }
func User(id string) (Namespace, error) { return of(KindUser, id) }
func Repo(id string) (Namespace, error) { return of(KindRepo, id) }
// System is the instance's own namespace: what belongs to the deployment rather
// than to any org, user or repo — app state, login sources, topics, notices.
//
// It takes no id because there is one instance. Groups keep its contents in
// separate files (System().WithGroup(Notices)), so "instance-level" costs a
// namespace and not an exception to the rule that every table lives in one.
func System() Namespace { return Namespace{string(KindSystem)} }
// MustOrg, MustUser and MustRepo are for ids fixed in the source — tests,
// seeds, a constant in a migration. They panic, which is correct for a value
// that is wrong before the program runs and wrong for anything from a request.
func MustOrg(id string) Namespace { return must(Org(id)) }
func MustUser(id string) Namespace { return must(User(id)) }
func MustRepo(id string) Namespace { return must(Repo(id)) }
// NewGroup returns the group of that name.
func NewGroup(name string) (Group, error) {
c, err := ident(name)
if err != nil {
return Group{}, fmt.Errorf("namespace: group: %w", err)
}
return Group{c}, nil
}
// MustGroup is NewGroup for a group named in the source, which is all of them.
func MustGroup(name string) Group {
g, err := NewGroup(name)
if err != nil {
panic(err)
}
return g
}
// of is the one place a Namespace is built from parts, so the rule about which
// kinds exist and which of them name an entity is stated once.
func of(k Kind, id string) (Namespace, error) {
switch k {
case KindOrg, KindUser, KindRepo:
case KindSystem:
if id != "" {
return Namespace{}, fmt.Errorf("namespace: %s names the instance and takes no id, got %q", k, id)
}
return System(), nil
default:
return Namespace{}, fmt.Errorf("namespace: %q is not an entity kind", string(k))
}
c, err := ident(id)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: %s id: %w", k, err)
}
return join(k, c, ""), nil
}
func join(k Kind, id, group string) Namespace {
s := string(k)
if id != "" {
s += "/" + id
}
if group != "" {
s += "/" + group
}
return Namespace{s}
}
// parts splits the canonical form back into what built it.
func (ns Namespace) parts() (k Kind, id, group string) {
kind, rest, _ := strings.Cut(ns.s, "/")
a, b, _ := strings.Cut(rest, "/")
if Kind(kind) == KindSystem {
return KindSystem, "", a // the instance names no entity, so its one segment is a group
}
return Kind(kind), a, b
}
// WithGroup returns this entity's namespace for one group of its related types.
//
// It REPLACES any group already set instead of nesting, so a namespace is
// always kind[/id][/group] and there is never a second shape to parse. The zero
// Namespace absorbs, because there is no entity to name a group of.
func (ns Namespace) WithGroup(g Group) Namespace {
k, id, _ := ns.parts()
if k == "" {
return ns
}
return join(k, id, g.s)
}
func (ns Namespace) Kind() Kind { k, _, _ := ns.parts(); return k }
func (ns Namespace) ID() string { _, id, _ := ns.parts(); return id }
func (ns Namespace) Group() Group { _, _, g := ns.parts(); return Group{g} }
func (ns Namespace) IsZero() bool { return ns.s == "" }
func (ns Namespace) String() string { return ns.s }
func (g Group) String() string { return g.s }
// Parse reads a namespace back from its string form, for the strings that
// arrive from outside a Go program: a request, a config file, a queue message.
//
// Parse(ns.String()) == ns for every ns a constructor returns, so a namespace
// survives a round trip instead of degrading into a string with conventions
// attached to it.
//
// It folds case exactly as the constructors do and repairs nothing else.
// "Org/Acme" is org/acme because a namespace is a value and "Acme" and "acme"
// are one org — one value, one spelling, which is canonicalisation. "org//acme"
// and "org/acme/" are errors, because String never emits them and accepting
// them would make one namespace have several encodings — which is the aliasing
// this package removes, arriving through the back door.
func Parse(s string) (Namespace, error) {
kind, rest, hasRest := strings.Cut(s, "/")
k, err := ident(kind)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: %q: kind: %w", s, err)
}
id, group, hasGroup := strings.Cut(rest, "/")
if Kind(k) == KindSystem {
id, group, hasGroup = "", rest, hasRest // no entity, so all of rest is the group
}
if strings.Contains(group, "/") {
return Namespace{}, fmt.Errorf("namespace: %q has too many segments, want kind[/id][/group]", s)
}
ns, err := of(Kind(k), id)
if err != nil {
return Namespace{}, err
}
if !hasGroup {
return ns, nil
}
g, err := NewGroup(group)
if err != nil {
return Namespace{}, fmt.Errorf("namespace: %q: %w", s, err)
}
return ns.WithGroup(g), nil
}
// MarshalText and UnmarshalText put every encoder that speaks text — JSON, YAML,
// a flag, a database column — through Parse and String rather than around them.
// Without MarshalText, json.Marshal of a Namespace emits {}: the fields that
// make illegal values unrepresentable also make them invisible to reflection,
// so this is not a convenience, it is the difference between a namespace and
// silent data loss.
//
// Marshalling the zero Namespace is an error rather than "": the mistake is at
// the write, and "" would only fail later at whoever reads it back.
func (ns Namespace) MarshalText() ([]byte, error) {
if ns.IsZero() {
return nil, errors.New("namespace: the zero Namespace names no database")
}
return []byte(ns.s), nil
}
func (ns *Namespace) UnmarshalText(b []byte) error {
p, err := Parse(string(b))
if err != nil {
return err
}
*ns = p
return nil
}
// ident canonicalises one segment — an id, a group or a kind — and is the whole
// of what a namespace may be made of.
//
// Lowercase, because a namespace is a FILE and half the fleet develops on a
// case-insensitive filesystem. There "org/Acme" and "org/acme" are two
// namespaces over one file: two open handles, two replication streams, one
// history, and it works on the laptop. Folding at construction removes the
// difference rather than hoping every caller normalises first. ASCII-only, so
// the fold is the same everywhere — Unicode lowercases Turkish "I" to a dotless
// "ı", which would name a different file on a Turkish laptop than on a server.
//
// [a-z0-9][a-z0-9_-]* then settles what a segment cannot be by construction
// instead of by denylist: no separator, so no path; no dot, so no ".." and no
// hidden name; no trailing dot or space, which Windows and some object stores
// drop; no empty segment; no percent- or unicode-encoded form of any of those.
func ident(s string) (string, error) {
if s == "" {
return "", errors.New("must not be empty")
}
if len(s) > maxSegment {
return "", fmt.Errorf("%q is %d bytes, over the %d-byte limit", s, len(s), maxSegment)
}
folded := false
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
case c >= 'A' && c <= 'Z':
folded = true
case (c == '-' || c == '_') && i > 0:
default:
return "", fmt.Errorf("%q: %q is not allowed, want [a-z0-9][a-z0-9_-]*", s, string(rune(c)))
}
}
if !folded {
return s, nil
}
return strings.ToLower(s), nil // provably ASCII by the loop above, so this is the ASCII fold
}
func must(ns Namespace, err error) Namespace {
if err != nil {
panic(err)
}
return ns
}
+349
View File
@@ -0,0 +1,349 @@
package namespace
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
)
var (
issues = MustGroup("issues")
labels = MustGroup("labels")
)
// every returns one of each shape a namespace comes in, which is what the
// properties below are asserted over.
func every() []Namespace {
return []Namespace{
MustOrg("acme"),
MustOrg("acme").WithGroup(MustGroup("members")),
MustUser("01hxyz9k7m"),
MustUser("01hxyz9k7m").WithGroup(MustGroup("api_keys")),
MustRepo("42"),
MustRepo("42").WithGroup(issues),
System(),
System().WithGroup(MustGroup("notices")),
}
}
// dbPath is what orm/db.pathFor does with a namespace, reproduced so the
// escape property is asserted against the real consumer and not a paraphrase.
func dbPath(t *testing.T, dir string, ns Namespace) string {
t.Helper()
return filepath.Clean(filepath.Join(dir, filepath.FromSlash(ns.String())+".db"))
}
func TestConstructors(t *testing.T) {
for _, tc := range []struct {
name string
ns Namespace
s string
kind Kind
id string
group string
}{
{"org", MustOrg("acme"), "org/acme", KindOrg, "acme", ""},
{"org grouped", MustOrg("acme").WithGroup(MustGroup("members")), "org/acme/members", KindOrg, "acme", "members"},
{"user", MustUser("z1"), "user/z1", KindUser, "z1", ""},
{"user grouped", MustUser("z1").WithGroup(labels), "user/z1/labels", KindUser, "z1", "labels"},
{"repo", MustRepo("42"), "repo/42", KindRepo, "42", ""},
{"repo grouped", MustRepo("42").WithGroup(issues), "repo/42/issues", KindRepo, "42", "issues"},
{"system", System(), "system", KindSystem, "", ""},
{"system grouped", System().WithGroup(issues), "system/issues", KindSystem, "", "issues"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := tc.ns.String(); got != tc.s {
t.Errorf("String() = %q, want %q", got, tc.s)
}
if got := tc.ns.Kind(); got != tc.kind {
t.Errorf("Kind() = %q, want %q", got, tc.kind)
}
if got := tc.ns.ID(); got != tc.id {
t.Errorf("ID() = %q, want %q", got, tc.id)
}
if got := tc.ns.Group().String(); got != tc.group {
t.Errorf("Group() = %q, want %q", got, tc.group)
}
if tc.ns.IsZero() {
t.Error("reports zero")
}
})
}
}
// An id is runtime data, so every constructor refuses the same set at the same
// moment — before the value exists, not after it has been written down.
func TestConstructorsRejectIllegalIDs(t *testing.T) {
ctors := map[string]func(string) (Namespace, error){"Org": Org, "User": User, "Repo": Repo}
for _, id := range []string{
"", "..", ".", ".hidden", "a/b", "a\\b", "/a", "a/", "A B", "a.b", "a@b",
"-x", "_x", "café", "a\x00b", "%2e%2e", "acme ", "acme.",
strings.Repeat("a", maxSegment+1),
} {
for name, ctor := range ctors {
if got, err := ctor(id); err == nil {
t.Errorf("%s(%q) = %q, want an error", name, id, got)
}
}
if got, err := NewGroup(id); err == nil {
t.Errorf("NewGroup(%q) = %q, want an error", id, got)
}
}
if got := len(strings.Repeat("a", maxSegment)); got != maxSegment { // the limit itself is legal
t.Fatal("bad fixture")
}
if _, err := Org(strings.Repeat("a", maxSegment)); err != nil {
t.Errorf("an id of exactly %d bytes: %v", maxSegment, err)
}
}
// The instance names no entity, so there is no id that could qualify it. of is
// the only door to that error because System takes no argument at all.
func TestSystemTakesNoID(t *testing.T) {
if got, err := of(KindSystem, "acme"); err == nil {
t.Errorf("system with an id = %q, want an error", got)
}
if got, err := of(Kind("project"), "x"); err == nil {
t.Errorf("an unknown kind = %q, want an error", got)
}
}
// One logical namespace has ONE value, so equality is the whole of comparison
// and a map keyed by it cannot open one file under two entries.
func TestCanonicalisationIsTotal(t *testing.T) {
for _, spellings := range [][]string{
{"acme", "Acme", "ACME", "aCmE"},
{"01hxyz", "01HXYZ"},
{"a-b_c", "A-B_C"},
} {
want := MustOrg(spellings[0])
for _, id := range spellings[1:] {
if got := MustOrg(id); got != want {
t.Errorf("Org(%q) = %q, want %q", id, got, want)
}
}
}
if a, b := MustRepo("R7").WithGroup(MustGroup("Issues")), MustRepo("r7").WithGroup(issues); a != b {
t.Errorf("%q != %q", a, b)
}
// Distinct values name distinct files: the aliasing bug, stated as a
// property rather than trusted to the naming rules.
seen := map[string]Namespace{}
dir := t.TempDir()
for _, ns := range every() {
p := dbPath(t, dir, ns)
if other, dup := seen[p]; dup {
t.Errorf("%q and %q both name %q", ns, other, p)
}
seen[p] = ns
}
}
// A namespace survives a wire hop, a config file or a database column. If it
// did not, it would decay into a string with conventions attached and every
// consumer would re-derive them.
func TestRoundTrip(t *testing.T) {
for _, ns := range every() {
got, err := Parse(ns.String())
if err != nil {
t.Fatalf("Parse(%q): %v", ns, err)
}
if got != ns {
t.Errorf("Parse(%q) = %q, want %q", ns, got, ns)
}
b, err := json.Marshal(ns)
if err != nil {
t.Fatalf("Marshal(%q): %v", ns, err)
}
if want := `"` + ns.String() + `"`; string(b) != want {
t.Errorf("Marshal(%q) = %s, want %s", ns, b, want)
}
var back Namespace
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("Unmarshal(%s): %v", b, err)
}
if back != ns {
t.Errorf("json round trip of %q gave %q", ns, back)
}
}
var bad Namespace
if err := json.Unmarshal([]byte(`"org/../etc"`), &bad); err == nil {
t.Errorf("unmarshalled an illegal namespace as %q", bad)
}
if !bad.IsZero() {
t.Errorf("a failed unmarshal left %q behind", bad)
}
}
func TestParseFoldsCaseAndNothingElse(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"org/acme", "org/acme"},
{"Org/Acme", "org/acme"},
{"ORG/ACME/MEMBERS", "org/acme/members"},
{"SYSTEM", "system"},
{"System/Notices", "system/notices"},
{"repo/42/issues", "repo/42/issues"},
} {
got, err := Parse(tc.in)
if err != nil {
t.Fatalf("Parse(%q): %v", tc.in, err)
}
if got.String() != tc.want {
t.Errorf("Parse(%q) = %q, want %q", tc.in, got, tc.want)
}
if again, err := Parse(got.String()); err != nil || again != got {
t.Errorf("Parse(%q) = %q, %v — not idempotent", got, again, err)
}
}
}
// Every name orm's canonical() had to reject is now unconstructible, and Parse
// is the only door a raw string can come through.
func TestParseRejects(t *testing.T) {
for _, s := range append(escapeCorpus(),
"", "org", "user", "repo", "ORG", "project/x", "system/acme/x",
"org/acme/issues/extra", "org//acme", "org/acme/", "org/acme ",
"org/a b", "org/a@b", "org/café", "org/a\x00b", "org/-x", "org/_x",
"org/"+strings.Repeat("a", maxSegment+1),
) {
if got, err := Parse(s); err == nil {
t.Errorf("Parse(%q) = %q, want an error", s, got)
}
}
}
// escapeCorpus is every way a string has ever been used to leave a directory.
// None of them may become a Namespace, and TestNamespaceCannotEscape proves the
// stronger claim: nothing that parses can, whatever it is spelled.
func escapeCorpus() []string {
return []string{
"/", "//", "..", ".", "./org/acme", "/org/acme", "//org/acme",
"../org/acme", "org/../../etc", "a/../../b", "org/./acme",
"org/x/../acme", "org/..", "org/../..", "\\org\\acme",
"..\\..\\etc", "org/%2e%2e/etc", "org/%2f/etc", "-org/acme",
".hidden/acme", "org/.hidden", "c:/org/acme", "org/acme/../../..",
"\u002e\u002e/etc", "org/\u2044etc", "org/acme\x00/../etc",
}
}
// The reason the type exists: its string form is a path, and no value of it can
// name a file outside the cache directory however it is spelled.
func TestNamespaceCannotEscape(t *testing.T) {
dir := t.TempDir()
root := filepath.Clean(dir) + string(filepath.Separator)
check := func(ns Namespace) {
t.Helper()
if p := dbPath(t, dir, ns); !strings.HasPrefix(p, root) {
t.Errorf("%q resolved to %q, outside %q", ns, p, root)
}
if n := strings.Count(ns.String(), "/"); n > 2 {
t.Errorf("%q is %d deep, want at most kind/id/group", ns, n)
}
}
for _, ns := range every() {
check(ns)
}
for _, s := range escapeCorpus() {
ns, err := Parse(s)
if err != nil {
continue
}
t.Errorf("Parse(%q) = %q", s, ns)
check(ns) // and if it ever does parse, it still may not escape
}
}
// The property, over anything at all rather than a corpus somebody thought of.
func FuzzParse(f *testing.F) {
for _, s := range append(escapeCorpus(), "org/acme", "repo/42/issues", "system", "System/Notices") {
f.Add(s)
}
dir := filepath.FromSlash("/ns")
root := filepath.Clean(dir) + string(filepath.Separator)
f.Fuzz(func(t *testing.T, s string) {
ns, err := Parse(s)
if err != nil {
return
}
if ns.IsZero() {
t.Fatalf("Parse(%q) succeeded with the zero Namespace", s)
}
if p := filepath.Clean(filepath.Join(dir, filepath.FromSlash(ns.String())+".db")); !strings.HasPrefix(p, root) {
t.Fatalf("Parse(%q) = %q, which resolves to %q", s, ns, p)
}
again, err := Parse(ns.String())
if err != nil || again != ns {
t.Fatalf("Parse(%q) = %q, %v — not idempotent", ns, again, err)
}
if got := join(ns.Kind(), ns.ID(), ns.Group().String()); got != ns {
t.Fatalf("%q split into %q/%q/%q, which rejoins as %q", ns, ns.Kind(), ns.ID(), ns.Group(), got)
}
})
}
// A group replaces, never nests, so there is exactly one shape to parse.
func TestGroupReplaces(t *testing.T) {
r := MustRepo("42")
if got, want := r.WithGroup(issues).WithGroup(labels), r.WithGroup(labels); got != want {
t.Errorf("regrouped to %q, want %q", got, want)
}
if got := r.WithGroup(issues).WithGroup(Group{}); got != r {
t.Errorf("the zero Group gave %q, want the entity's own namespace %q", got, r)
}
if got := System().WithGroup(issues).WithGroup(Group{}); got != System() {
t.Errorf("the zero Group on system gave %q", got)
}
}
// The zero value is the one illegal Namespace Go permits. It is defined, and it
// can never be mistaken for a real one.
func TestZero(t *testing.T) {
var ns Namespace
if !ns.IsZero() || ns.String() != "" || ns.Kind() != "" || ns.ID() != "" {
t.Errorf("zero Namespace = %q, kind %q, id %q", ns.String(), ns.Kind(), ns.ID())
}
if ns.WithGroup(issues) != ns {
t.Error("the zero Namespace grew a group")
}
if _, err := ns.MarshalText(); err == nil {
t.Error("MarshalText of the zero Namespace succeeded, want an error")
}
if b, err := json.Marshal(struct{ NS Namespace }{}); err == nil {
t.Errorf("json.Marshal of a zero Namespace gave %s, want an error", b)
}
}
func TestMustPanics(t *testing.T) {
for name, fn := range map[string]func(){
"MustOrg": func() { MustOrg("../etc") },
"MustUser": func() { MustUser("") },
"MustRepo": func() { MustRepo("a/b") },
"MustGroup": func() { MustGroup(".hidden") },
} {
t.Run(name, func(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("did not panic")
}
}()
fn()
})
}
}
func BenchmarkOrg(b *testing.B) {
for b.Loop() {
if _, err := Org("01hxyz9k7mq4"); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkParse(b *testing.B) {
for b.Loop() {
if _, err := Parse("repo/01hxyz9k7mq4/issues"); err != nil {
b.Fatal(err)
}
}
}