mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 15:05:00 +00:00
feat(jsontypes): add two-pass coalescer for raw path output
Coalesce merges streamer output by deduplicating types with identical
key sets, collapsing {null} into nullable annotations ({string?}),
cleaning up type names (removing unnecessary numbers, deriving names
from keys when many types share a shape), and eliding duplicate type
definitions. Preserves {undefined} distinct from {null} per the
three-stage pipeline design.
Pokemon fixture: 241 raw lines → 140 coalesced lines.
This commit is contained in:
parent
2b7f3f137b
commit
6d43b07f3f
38
tools/jsontypes/cmd/jsoncoalesce/main.go
Normal file
38
tools/jsontypes/cmd/jsoncoalesce/main.go
Normal file
@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/therootcompany/golib/tools/jsontypes"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var input *os.File
|
||||
if len(os.Args) > 1 && os.Args[1] != "-" {
|
||||
f, err := os.Open(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
input = f
|
||||
} else {
|
||||
input = os.Stdin
|
||||
}
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(input)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, line := range jsontypes.Coalesce(lines) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
341
tools/jsontypes/coalesce.go
Normal file
341
tools/jsontypes/coalesce.go
Normal file
@ -0,0 +1,341 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Coalesce takes streamer output lines and returns coalesced lines with:
|
||||
// - Types deduplicated by key set (same keys = same type)
|
||||
// - Nullable annotation ({string?} when {null} + concrete type at same path)
|
||||
// - Cleaner type names (no number when unique, per-base-name numbering for conflicts)
|
||||
// - Duplicate type definitions elided (referenced by name only)
|
||||
func Coalesce(lines []string) []string {
|
||||
parsed := parseRawLines(lines)
|
||||
types := buildCoalesceTypes(parsed)
|
||||
groups := groupTypesByKeySet(types)
|
||||
nameMap := assignCanonicalNames(groups)
|
||||
nullables := detectNullablePaths(parsed)
|
||||
return emitCoalesced(parsed, nameMap, nullables)
|
||||
}
|
||||
|
||||
// rawLine is a parsed streamer output line.
|
||||
type rawLine struct {
|
||||
path string // everything before the last {
|
||||
typeVal string // everything inside the last {}
|
||||
}
|
||||
|
||||
func parseRawLines(lines []string) []rawLine {
|
||||
result := make([]rawLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, parseRawLine(line))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseRawLine(line string) rawLine {
|
||||
idx := strings.LastIndex(line, "{")
|
||||
if idx < 0 || !strings.HasSuffix(line, "}") {
|
||||
return rawLine{path: line}
|
||||
}
|
||||
return rawLine{
|
||||
path: line[:idx],
|
||||
typeVal: line[idx+1 : len(line)-1],
|
||||
}
|
||||
}
|
||||
|
||||
var namedTypePattern = regexp.MustCompile(`^[A-Z][a-zA-Z0-9-]*\d+$`)
|
||||
|
||||
func isNamedType(typeVal string) bool {
|
||||
return namedTypePattern.MatchString(typeVal)
|
||||
}
|
||||
|
||||
// coalesceType is a named type from the streamer output.
|
||||
type coalesceType struct {
|
||||
name string // original name, e.g., "Ability2"
|
||||
path string // path where introduced
|
||||
keys []string // sorted direct field names
|
||||
keysSig string // comma-joined keys for comparison
|
||||
}
|
||||
|
||||
func buildCoalesceTypes(lines []rawLine) []*coalesceType {
|
||||
var types []*coalesceType
|
||||
|
||||
for i, line := range lines {
|
||||
if !isNamedType(line.typeVal) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the extent: until next type intro at the same path.
|
||||
end := len(lines)
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
if isNamedType(lines[j].typeVal) && lines[j].path == line.path {
|
||||
end = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract direct field names from lines[i+1:end].
|
||||
prefix := coalesceFieldPrefix(line.path)
|
||||
fieldNames := make(map[string]bool)
|
||||
for j := i + 1; j < end; j++ {
|
||||
p := lines[j].path
|
||||
if !strings.HasPrefix(p, prefix) {
|
||||
continue
|
||||
}
|
||||
remainder := p[len(prefix):]
|
||||
// Direct field: no "." in the remainder.
|
||||
if strings.Contains(remainder, ".") {
|
||||
continue
|
||||
}
|
||||
// Strip array brackets to get field name.
|
||||
bracketIdx := strings.Index(remainder, "[")
|
||||
var fieldName string
|
||||
if bracketIdx < 0 {
|
||||
fieldName = remainder
|
||||
} else {
|
||||
fieldName = remainder[:bracketIdx]
|
||||
}
|
||||
if fieldName != "" {
|
||||
fieldNames[fieldName] = true
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(fieldNames))
|
||||
for k := range fieldNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
types = append(types, &coalesceType{
|
||||
name: line.typeVal,
|
||||
path: line.path,
|
||||
keys: keys,
|
||||
keysSig: strings.Join(keys, ","),
|
||||
})
|
||||
}
|
||||
|
||||
return types
|
||||
}
|
||||
|
||||
// keySetGroup holds all types that share the same key set.
|
||||
type keySetGroup struct {
|
||||
keysSig string
|
||||
keys []string
|
||||
members []*coalesceType
|
||||
}
|
||||
|
||||
func groupTypesByKeySet(types []*coalesceType) []*keySetGroup {
|
||||
groupMap := make(map[string]*keySetGroup)
|
||||
var groups []*keySetGroup
|
||||
|
||||
for _, t := range types {
|
||||
if g, ok := groupMap[t.keysSig]; ok {
|
||||
g.members = append(g.members, t)
|
||||
} else {
|
||||
g = &keySetGroup{
|
||||
keysSig: t.keysSig,
|
||||
keys: t.keys,
|
||||
members: []*coalesceType{t},
|
||||
}
|
||||
groupMap[t.keysSig] = g
|
||||
groups = append(groups, g)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func assignCanonicalNames(groups []*keySetGroup) map[string]string {
|
||||
nameMap := make(map[string]string) // old name → new name
|
||||
|
||||
// Pick a base name for each group.
|
||||
type groupName struct {
|
||||
group *keySetGroup
|
||||
baseName string
|
||||
}
|
||||
var gns []groupName
|
||||
baseNameCount := make(map[string]int)
|
||||
for _, g := range groups {
|
||||
name := pickGroupName(g)
|
||||
gns = append(gns, groupName{g, name})
|
||||
baseNameCount[name]++
|
||||
}
|
||||
|
||||
// Assign final names, numbering when base names conflict.
|
||||
baseNameCounter := make(map[string]int)
|
||||
for _, gn := range gns {
|
||||
var finalName string
|
||||
if baseNameCount[gn.baseName] == 1 {
|
||||
finalName = gn.baseName
|
||||
} else {
|
||||
idx := baseNameCounter[gn.baseName]
|
||||
baseNameCounter[gn.baseName]++
|
||||
finalName = fmt.Sprintf("%s%d", gn.baseName, idx)
|
||||
}
|
||||
for _, t := range gn.group.members {
|
||||
nameMap[t.name] = finalName
|
||||
}
|
||||
}
|
||||
|
||||
return nameMap
|
||||
}
|
||||
|
||||
// pickGroupName selects the best canonical name for a key-set group.
|
||||
func pickGroupName(g *keySetGroup) string {
|
||||
// Count distinct base names among members.
|
||||
baseNames := make(map[string]int)
|
||||
var firstBase string
|
||||
for _, t := range g.members {
|
||||
base := stripTrailingDigits(t.name)
|
||||
baseNames[base]++
|
||||
if firstBase == "" {
|
||||
firstBase = base
|
||||
}
|
||||
}
|
||||
|
||||
// If many distinct names share this shape, derive from keys
|
||||
// (e.g., {name, url} → "NameUrl").
|
||||
if len(baseNames) > 2 {
|
||||
return deriveNameFromKeys(g.keys)
|
||||
}
|
||||
|
||||
// If one base name dominates, use it.
|
||||
if len(baseNames) == 1 {
|
||||
return firstBase
|
||||
}
|
||||
|
||||
// Two distinct names — pick the most common, then shortest.
|
||||
var best string
|
||||
var bestCount int
|
||||
for name, count := range baseNames {
|
||||
if count > bestCount {
|
||||
best = name
|
||||
bestCount = count
|
||||
} else if count == bestCount && len(name) < len(best) {
|
||||
best = name
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func deriveNameFromKeys(keys []string) string {
|
||||
if len(keys) == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
// Use all keys (up to 3) to form the name.
|
||||
n := len(keys)
|
||||
if n > 3 {
|
||||
n = 3
|
||||
}
|
||||
var parts []string
|
||||
for _, k := range keys[:n] {
|
||||
parts = append(parts, snakeToPascal(k))
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
// stripTrailingDigits removes trailing digits from a type name.
|
||||
func stripTrailingDigits(name string) string {
|
||||
i := len(name) - 1
|
||||
for i >= 0 && name[i] >= '0' && name[i] <= '9' {
|
||||
i--
|
||||
}
|
||||
if i < 0 {
|
||||
return name
|
||||
}
|
||||
return name[:i+1]
|
||||
}
|
||||
|
||||
// detectNullablePaths returns the set of paths where {null} appears
|
||||
// alongside at least one concrete type.
|
||||
func detectNullablePaths(lines []rawLine) map[string]bool {
|
||||
pathTypes := make(map[string][]string)
|
||||
for _, line := range lines {
|
||||
pathTypes[line.path] = append(pathTypes[line.path], line.typeVal)
|
||||
}
|
||||
|
||||
nullables := make(map[string]bool)
|
||||
for path, typeVals := range pathTypes {
|
||||
hasNull := false
|
||||
hasOther := false
|
||||
for _, tv := range typeVals {
|
||||
if tv == "null" {
|
||||
hasNull = true
|
||||
} else if tv != "undefined" && tv != "empty" {
|
||||
hasOther = true
|
||||
}
|
||||
}
|
||||
if hasNull && hasOther {
|
||||
nullables[path] = true
|
||||
}
|
||||
}
|
||||
|
||||
return nullables
|
||||
}
|
||||
|
||||
func emitCoalesced(lines []rawLine, nameMap map[string]string, nullables map[string]bool) []string {
|
||||
emittedTypes := make(map[string]bool) // canonical name → already expanded
|
||||
var result []string
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
// Skip standalone {null} lines at nullable paths (merged into ?).
|
||||
if line.typeVal == "null" && nullables[line.path] {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if isNamedType(line.typeVal) {
|
||||
canonicalName := nameMap[line.typeVal]
|
||||
nullable := ""
|
||||
if nullables[line.path] {
|
||||
nullable = "?"
|
||||
}
|
||||
|
||||
if emittedTypes[canonicalName] {
|
||||
// Emit reference only, skip field lines.
|
||||
result = append(result, line.path+"{"+canonicalName+nullable+"}")
|
||||
i++
|
||||
prefix := coalesceFieldPrefix(line.path)
|
||||
for i < len(lines) && strings.HasPrefix(lines[i].path, prefix) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// First occurrence — emit type intro and let fields be emitted normally.
|
||||
emittedTypes[canonicalName] = true
|
||||
result = append(result, line.path+"{"+canonicalName+nullable+"}")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular line (primitive, null, undefined, empty, etc.).
|
||||
tv := line.typeVal
|
||||
nullable := ""
|
||||
if nullables[line.path] && tv != "undefined" && tv != "empty" {
|
||||
nullable = "?"
|
||||
}
|
||||
|
||||
result = append(result, line.path+"{"+tv+nullable+"}")
|
||||
i++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func coalesceFieldPrefix(path string) string {
|
||||
if path == "" {
|
||||
return "."
|
||||
}
|
||||
return path + "."
|
||||
}
|
||||
248
tools/jsontypes/coalesce_test.go
Normal file
248
tools/jsontypes/coalesce_test.go
Normal file
@ -0,0 +1,248 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoalesceBasic(t *testing.T) {
|
||||
input := []string{
|
||||
"{Root0}",
|
||||
".name{string}",
|
||||
".email{string}",
|
||||
".email{null}",
|
||||
".friends[]{FriendsItem1}",
|
||||
".friends[].name{string}",
|
||||
".friends[].age{int}",
|
||||
}
|
||||
|
||||
got := Coalesce(input)
|
||||
|
||||
// Root0 → Root (unique)
|
||||
assertLineContains(t, got, "{Root}")
|
||||
// email should be {string?} (nullable)
|
||||
assertLineContains(t, got, ".email{string?}")
|
||||
// standalone {null} should be removed
|
||||
assertLineNotContains(t, got, ".email{null}")
|
||||
// FriendsItem1 → FriendsItem (unique)
|
||||
assertLineContains(t, got, ".friends[]{FriendsItem}")
|
||||
|
||||
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalesceDedup(t *testing.T) {
|
||||
// Two types with the same key set at different paths.
|
||||
input := []string{
|
||||
"{Root0}",
|
||||
".author{Author1}",
|
||||
".author.name{string}",
|
||||
".author.url{string}",
|
||||
".reviewer{Reviewer2}",
|
||||
".reviewer.name{string}",
|
||||
".reviewer.url{string}",
|
||||
}
|
||||
|
||||
got := Coalesce(input)
|
||||
|
||||
// Both Author and Reviewer have {name, url} → merged into one type.
|
||||
// Only one should have fields expanded.
|
||||
nameUrlCount := 0
|
||||
fieldCount := 0
|
||||
for _, line := range got {
|
||||
if strings.Contains(line, ".name{string}") {
|
||||
fieldCount++
|
||||
}
|
||||
if strings.Contains(line, "{Author}") || strings.Contains(line, "{Reviewer}") {
|
||||
nameUrlCount++
|
||||
}
|
||||
}
|
||||
|
||||
// The canonical name should appear at both paths.
|
||||
if nameUrlCount != 2 {
|
||||
t.Errorf("expected 2 references to canonical type, got %d", nameUrlCount)
|
||||
}
|
||||
|
||||
// But fields should only appear once (for the first occurrence).
|
||||
if fieldCount != 1 {
|
||||
t.Errorf("expected 1 field expansion, got %d", fieldCount)
|
||||
}
|
||||
|
||||
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalesceDedupManyNames(t *testing.T) {
|
||||
// >2 distinct base names with same key set → derive name from keys.
|
||||
input := []string{
|
||||
"{Root0}",
|
||||
".ability{Ability1}",
|
||||
".ability.name{string}",
|
||||
".ability.url{string}",
|
||||
".version{Version2}",
|
||||
".version.name{string}",
|
||||
".version.url{string}",
|
||||
".species{Species3}",
|
||||
".species.name{string}",
|
||||
".species.url{string}",
|
||||
}
|
||||
|
||||
got := Coalesce(input)
|
||||
|
||||
// 3 distinct base names → derived name "NameUrl".
|
||||
assertLineContains(t, got, "{NameUrl}")
|
||||
// Fields expanded only once.
|
||||
nameCount := 0
|
||||
for _, line := range got {
|
||||
if strings.HasSuffix(line, ".name{string}") {
|
||||
nameCount++
|
||||
}
|
||||
}
|
||||
if nameCount != 1 {
|
||||
t.Errorf("expected 1 .name{string} (deduped), got %d", nameCount)
|
||||
}
|
||||
|
||||
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalesceUndefinedPreserved(t *testing.T) {
|
||||
input := []string{
|
||||
"[]{RootItem0}",
|
||||
"[].name{string}",
|
||||
"[].email{undefined}",
|
||||
"[].email{string}",
|
||||
}
|
||||
|
||||
got := Coalesce(input)
|
||||
|
||||
// {undefined} should be preserved (not collapsed into ?).
|
||||
assertLineContains(t, got, "[].email{undefined}")
|
||||
// {string} should NOT be nullable (undefined ≠ null).
|
||||
assertLineContains(t, got, "[].email{string}")
|
||||
assertLineNotContains(t, got, "[].email{string?}")
|
||||
|
||||
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalesceNullAndUndefined(t *testing.T) {
|
||||
input := []string{
|
||||
"[]{RootItem0}",
|
||||
"[].email{undefined}",
|
||||
"[].email{null}",
|
||||
"[].email{string}",
|
||||
}
|
||||
|
||||
got := Coalesce(input)
|
||||
|
||||
// {undefined} preserved
|
||||
assertLineContains(t, got, "[].email{undefined}")
|
||||
// {null} collapsed into {string?}
|
||||
assertLineContains(t, got, "[].email{string?}")
|
||||
assertLineNotContains(t, got, "[].email{null}")
|
||||
|
||||
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalescePokemonFixture(t *testing.T) {
|
||||
lines := rawPathsFromFixture(t, "testdata/pokemon-pikachu.json")
|
||||
got := Coalesce(lines)
|
||||
|
||||
// Should be significantly fewer lines than the raw output.
|
||||
if len(got) >= len(lines) {
|
||||
t.Errorf("coalesced output (%d lines) should be fewer than raw (%d lines)",
|
||||
len(got), len(lines))
|
||||
}
|
||||
|
||||
// {name, url} types should be merged (many types share this shape).
|
||||
assertLineContains(t, got, "{NameUrl}")
|
||||
|
||||
// Root should lose its number.
|
||||
assertLineContains(t, got, "{Root}")
|
||||
|
||||
// Core structure preserved.
|
||||
assertLineContains(t, got, ".abilities[]{AbilitiesItem}")
|
||||
assertLineContains(t, got, ".stats[]{StatsItem}")
|
||||
assertLineContains(t, got, ".types[]{TypesItem}")
|
||||
|
||||
// Nullable fields: dream_world.front_female is null in some contexts.
|
||||
// (In single-document mode, null-only fields stay as {null}.)
|
||||
|
||||
t.Logf("raw: %d lines → coalesced: %d lines", len(lines), len(got))
|
||||
t.Logf("output:\n%s", strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalesceSWAPIPlanets(t *testing.T) {
|
||||
lines := rawPathsFromFixture(t, "testdata/swapi-planets.json")
|
||||
got := Coalesce(lines)
|
||||
|
||||
// Root should be clean.
|
||||
assertLineContains(t, got, "{Root}")
|
||||
assertLineContains(t, got, ".results[]{ResultsItem}")
|
||||
|
||||
// No nullable fields in this fixture (previous is null but standalone).
|
||||
assertLineContains(t, got, ".previous{null}")
|
||||
|
||||
t.Logf("raw: %d lines → coalesced: %d lines", len(lines), len(got))
|
||||
t.Logf("output:\n%s", strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
func TestCoalescePerBaseNameNumbering(t *testing.T) {
|
||||
// Two types with the same base name but different key sets.
|
||||
input := []string{
|
||||
"{Root0}",
|
||||
".config{Settings1}",
|
||||
".config.host{string}",
|
||||
".config.port{int}",
|
||||
".prefs{Settings2}",
|
||||
".prefs.theme{string}",
|
||||
".prefs.lang{string}",
|
||||
".prefs.debug{bool}",
|
||||
}
|
||||
|
||||
got := Coalesce(input)
|
||||
|
||||
// Both are "Settings" base but different keys → numbered.
|
||||
assertLineContains(t, got, ".config{Settings0}")
|
||||
assertLineContains(t, got, ".prefs{Settings1}")
|
||||
|
||||
t.Logf("output (%d lines):\n%s", len(got), strings.Join(got, "\n"))
|
||||
}
|
||||
|
||||
// Helper: run RawPaths on a fixture file and return the lines.
|
||||
func rawPathsFromFixture(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var data any
|
||||
dec := json.NewDecoder(f)
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
return RawPaths(data)
|
||||
}
|
||||
|
||||
func assertLineContains(t *testing.T, lines []string, want string) {
|
||||
t.Helper()
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("missing expected line containing %q", want)
|
||||
}
|
||||
|
||||
func assertLineNotContains(t *testing.T, lines []string, want string) {
|
||||
t.Helper()
|
||||
for _, line := range lines {
|
||||
if line == want {
|
||||
t.Errorf("unexpected line %q", want)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user