mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
* refine the plan
* Fix all staticcheck lint findings (uncapped)
golangci-lint's default max-same-issues=3 was hiding most of the backlog:
the true pre-fix count was 69 staticcheck findings, not 34. This fixes all
of them, driving staticcheck to zero:
- ST1023/QF1011 (37): omit explicit types inferred from the RHS
- S1009/S1031 (15): drop redundant nil checks before len()/range
- SA9003 (9): remove comment-only empty branches, keeping the comments
- QF1007 (3): merge conditional assignment into declaration
- QF1006 (3): lift break conditions into loop conditions
- QF1001 (3): apply De Morgan's law / name the negated predicate
Also updates plans/lintfixes.md with the cap discovery and the corrected
errcheck picture (1202 uncapped, ~949 of them fmt.Fprint*).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drive errcheck to zero: config for bulk categories, propagate real errors
Adds .golangci.yml with errcheck exclude-functions for fmt.Fprint* (usage
printers), (*bufio.Writer).Write/WriteString (sticky errors, surfaced at the
now-checked final Flush), and (*strings.Builder).WriteString; pins
max-issues-per-linter/max-same-issues to 0 so CI reports true counts.
Real error paths now propagate instead of being dropped:
- Finalize{Reader,Writer}Options in join/put/filter/split/tee and the
repl/script entry points: 'mlr join -i badformat' now errors instead of
silently using wrong separators
- final output-stream Flush in pkg/stream: write failure no longer exits 0
- DSL emit/print/dump redirect writes, matching their sibling branches
- CSV writer WriteCSVRecordMaybeColorized, close-time Flush in file output
handlers, ENV[...] Setenv, REPL record-write and redirect-close errors
- termcvt write-side Close before rename (had "TODO: check return status")
The rest are deliberate ignores, marked with _ = and a comment where the
reason isn't obvious: unset-of-missing-path no-ops, read-side closes,
mid-stream FlushOnEveryRecord, init-time strftime registrations, in-memory
usage-capture pipes, and regtest-harness env/temp-file teardown.
golangci-lint now reports 0 issues on ./cmd/mlr ./pkg/... with all caps off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
289 lines
9.4 KiB
Go
289 lines
9.4 KiB
Go
// FLATTEN/UNFLATTEN
|
|
//
|
|
// These are used by the flatten/unflatten verbs and DSL functions. They are
|
|
// crucial to the operation of Miller 6 wherein records have full Mlrval
|
|
// values, i.e. they can be arrays/maps as well as int/float/string.
|
|
//
|
|
// When we read JSON and write (say) CSV, we have two choices for handling the
|
|
// fact that JSON handles multi-level data and CSV does not:
|
|
//
|
|
// (1) JSON-stringify values, using the json-stringify verb or json_stringify
|
|
// DSL function. For example, the array of ints [1,2,3] becomes the string
|
|
// "[1,2,3]" which works fine as a CSV field.
|
|
//
|
|
// (2) Flatten them by key-spreading. For example, the single field with key
|
|
// "x" with value {"a":1,"b":2} flattens to the *pair* of fields x:a=1 and
|
|
// x:b=2.
|
|
//
|
|
// The former are used implicitly (i.e. unless the user explicitly requests
|
|
// otherwise) when we convert to/from JSON.
|
|
|
|
package mlrval
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/johnkerl/miller/v6/pkg/lib"
|
|
)
|
|
|
|
// warnedUnflattenFieldNames tracks field names for which we've already emitted
|
|
// an unflatten warning, to avoid flooding stderr on multi-record input.
|
|
var warnedUnflattenFieldNames sync.Map
|
|
|
|
// Flattens all field values in the record. This is a special case of
|
|
// FlattenFields but it's worth its own special case (to avoid iffing on the
|
|
// nullity of the fieldNameSet) since the flatten/unflatten check is done by
|
|
// default on ALL Miller records whenever we convert to/from JSON. So, the
|
|
// default path should be fast.
|
|
//
|
|
// Examples:
|
|
// * The single field x = {"a": 7, "b": 8, "c": 9} becomes the three fields
|
|
// x.a = 7, x.b = 8, x.c = 9.
|
|
// * The single field x = [7,8,9] becomes the three fields
|
|
// x.1 = 7, x.2 = 8, x.3 = 9.
|
|
|
|
func (mlrmap *Mlrmap) Flatten(separator string) {
|
|
if !mlrmap.isFlattenable() { // fast path: don't modify the record at all
|
|
return
|
|
}
|
|
|
|
other := NewMlrmapAsRecord()
|
|
|
|
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
|
if pe.Value.IsArrayOrMap() {
|
|
pieces := pe.Value.FlattenToMap(pe.Key, separator)
|
|
for pf := pieces.GetMap().Head; pf != nil; pf = pf.Next {
|
|
other.PutReference(pf.Key, pf.Value)
|
|
}
|
|
} else {
|
|
other.PutReference(pe.Key, pe.Value)
|
|
}
|
|
}
|
|
|
|
*mlrmap = *other
|
|
}
|
|
|
|
// For mlr flatten -f.
|
|
|
|
func (mlrmap *Mlrmap) FlattenFields(
|
|
fieldNameSet map[string]bool,
|
|
separator string,
|
|
) {
|
|
if !mlrmap.isFlattenable() { // fast path
|
|
return
|
|
}
|
|
|
|
other := NewMlrmapAsRecord()
|
|
|
|
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
|
if pe.Value.IsArrayOrMap() && fieldNameSet[pe.Key] {
|
|
pieces := pe.Value.FlattenToMap(pe.Key, separator)
|
|
for pf := pieces.GetMap().Head; pf != nil; pf = pf.Next {
|
|
other.PutReference(pf.Key, pf.Value)
|
|
}
|
|
} else {
|
|
other.PutReference(pe.Key, pe.Value)
|
|
}
|
|
}
|
|
|
|
*mlrmap = *other
|
|
}
|
|
|
|
// Optimization for Flatten, to avoid needless data motion in the case
|
|
// where all field values are non-collections.
|
|
|
|
func (mlrmap *Mlrmap) isFlattenable() bool {
|
|
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
|
if pe.Value.IsArrayOrMap() {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// For mlr unflatten without -f. This undoes Unflatten. This is for conversion
|
|
// from non-JSON to JSON. If there are fields x.a, x.b, x.c, etc. they're put
|
|
// into a single field x with map-valued value keyed by "a", "b", "c".
|
|
//
|
|
// There is a heurtistic here though. Miller is (wildly) multi-format and needs
|
|
// to accommodate all manner of data. In the JSON world, "." is the default
|
|
// delimiter for nested data, and we're here to handle that. But in the R world,
|
|
// "." is just like "_" in other languages: witness "data.frame" rather than
|
|
// "data_frame". If the "." was intended as punctuation, in a say a field named
|
|
// "a.b" with value 3, then unflatten-to-JSON will make `{"a": {"b": 3}}`. This
|
|
// is just our default behavior; users can use --no-auto-unflatten. Weirder
|
|
// are field names like ".", ".x", "x.", "x..y", etc. The heuristic here
|
|
// is that when we split on "." and any of the pieces around/between the dots
|
|
// are empty string, we don't try to unflatten that field.
|
|
//
|
|
// Special case: if the resulting string keys are string representations of 1,
|
|
// 2, 3, etc -- without gaps -- then the map is converted to an array.
|
|
//
|
|
// Examples:
|
|
//
|
|
// - The three fields x.a = 7, x.b = 8, x.c = 9 become
|
|
// the single field x = {"a": 7, "b": 8, "c": 9}.
|
|
//
|
|
// - The three fields x.1 = 7, x.2 = 8, x.3 = 9 become
|
|
// the single field x = [7,8,9].
|
|
//
|
|
// - The two fields x.1 = 7, x.3 = 9 become
|
|
// the single field x = {"1": 7, "3": 9}
|
|
func (mlrmap *Mlrmap) Unflatten(
|
|
separator string,
|
|
) {
|
|
*mlrmap = *(mlrmap.CopyUnflattened(separator))
|
|
}
|
|
|
|
func (mlrmap *Mlrmap) CopyUnflattened(
|
|
separator string,
|
|
) *Mlrmap {
|
|
other := NewMlrmapAsRecord()
|
|
affectedBaseIndices := make(map[string]bool)
|
|
|
|
// We'll come through this loop once for x.a, another for x.b, etc.
|
|
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
|
// If there are no dots in the field name, treat it as a terminal.
|
|
if !strings.Contains(pe.Key, separator) {
|
|
other.PutReference(pe.Key, unflattenTerminal(pe.Value))
|
|
continue
|
|
}
|
|
|
|
arrayOfIndices := SplitAXHelper(pe.Key, separator)
|
|
arrayval := arrayOfIndices.intf.([]*Mlrval)
|
|
lib.InternalCodingErrorIf(len(arrayval) < 1)
|
|
|
|
// Check for "" in any of the split pieces; treat the field as terminal if so.
|
|
legitDots := true
|
|
for i := range arrayval {
|
|
piece := arrayval[i].String()
|
|
if piece == "" {
|
|
legitDots = false
|
|
break
|
|
}
|
|
}
|
|
if !legitDots {
|
|
if _, alreadyWarned := warnedUnflattenFieldNames.LoadOrStore(pe.Key, true); !alreadyWarned {
|
|
fmt.Fprintf(os.Stderr,
|
|
"mlr: field name %q contains separator %q but cannot be auto-unflattened; treating as a literal string. Use --no-auto-unflatten to suppress this warning.\n",
|
|
pe.Key, separator)
|
|
}
|
|
other.PutReference(pe.Key, unflattenTerminal(pe.Value))
|
|
continue
|
|
}
|
|
|
|
// If the input field name was "x.a" then remember the "x".
|
|
baseIndex := arrayval[0].String()
|
|
affectedBaseIndices[baseIndex] = true
|
|
// Use PutIndexed to assign $x["a"] = 7, or $x["b"] = 8, etc.
|
|
// Unflattening is best-effort: this API has no error return.
|
|
_ = other.PutIndexed(
|
|
CopyMlrvalArray(arrayval),
|
|
unflattenTerminal(pe.Value).Copy(),
|
|
)
|
|
}
|
|
|
|
// Go through all the field names which were turned into maps -- e.g. "x"
|
|
// in the example above -- and see if the keys were like "1", "2", etc and
|
|
// if so then convert to array. This undoes how Flatten flattens arrays.
|
|
for baseIndex := range affectedBaseIndices {
|
|
oldValue := other.Get(baseIndex)
|
|
lib.InternalCodingErrorIf(oldValue == nil)
|
|
newValue := oldValue.Arrayify()
|
|
other.PutReference(baseIndex, newValue)
|
|
}
|
|
|
|
return other
|
|
}
|
|
|
|
// For mlr unflatten -f. See comments on Unflatten. Largely copypasta of
|
|
// Unflatten, but split out separately since Flatten needn't check a
|
|
// fieldNameSet.
|
|
func (mlrmap *Mlrmap) UnflattenFields(
|
|
fieldNameSet map[string]bool,
|
|
separator string,
|
|
) {
|
|
*mlrmap = *(mlrmap.CopyUnflattenFields(fieldNameSet, separator))
|
|
}
|
|
|
|
func (mlrmap *Mlrmap) CopyUnflattenFields(
|
|
fieldNameSet map[string]bool,
|
|
separator string,
|
|
) *Mlrmap {
|
|
other := NewMlrmapAsRecord()
|
|
affectedBaseIndices := make(map[string]bool)
|
|
|
|
// We'll come through this loop once for x.a, another for x.b, etc.
|
|
for pe := mlrmap.Head; pe != nil; pe = pe.Next {
|
|
// Is the field name something dot something?
|
|
if strings.Contains(pe.Key, separator) {
|
|
arrayOfIndices := SplitAXHelper(pe.Key, separator)
|
|
arrayval := arrayOfIndices.intf.([]*Mlrval)
|
|
lib.InternalCodingErrorIf(len(arrayval) < 1)
|
|
// If the input field name was "x.a" then remember the "x".
|
|
baseIndex := arrayval[0].String()
|
|
if fieldNameSet[baseIndex] {
|
|
// Use PutIndexed to assign $x["a"] = 7, or $x["b"] = 8, etc.
|
|
// Unflattening is best-effort: this API has no error return.
|
|
_ = other.PutIndexed(
|
|
CopyMlrvalArray(arrayval),
|
|
unflattenTerminal(pe.Value).Copy(),
|
|
)
|
|
affectedBaseIndices[baseIndex] = true
|
|
} else {
|
|
other.PutReference(pe.Key, unflattenTerminal(pe.Value))
|
|
}
|
|
} else {
|
|
other.PutReference(pe.Key, unflattenTerminal(pe.Value))
|
|
}
|
|
}
|
|
|
|
// Go through all the field names which were turned into maps -- e.g. "x"
|
|
// in the example above -- and see if the keys were like "1", "2", etc and
|
|
// if so then convert to array. This undoes how Flatten flattens arrays.
|
|
for baseIndex := range affectedBaseIndices {
|
|
oldValue := other.Get(baseIndex)
|
|
lib.InternalCodingErrorIf(oldValue == nil)
|
|
newValue := oldValue.Arrayify()
|
|
other.PutReference(baseIndex, newValue)
|
|
}
|
|
|
|
return other
|
|
}
|
|
|
|
// Flatten of empty map and empty array produce "{}" and "[]" as special cases.
|
|
// (Without this, key-spreading would cause such fields to disappear entirely:
|
|
// the field "x" -> {"a": 1, "b": 2} would spread to the pair of fields "x:a"
|
|
// -> 1 and "x:b" -> 2, and the field "x" -> {"a": 1} would spread to the
|
|
// single field "x:a" -> 1, so the field "x" -> {} would spread to zero
|
|
// fields.) Here we reverse that special case of the flatten operation.
|
|
|
|
func unflattenTerminal(input *Mlrval) *Mlrval {
|
|
if !input.IsString() {
|
|
return input
|
|
}
|
|
if input.printrep == "{}" {
|
|
return FromMap(NewMlrmap())
|
|
}
|
|
if input.printrep == "[]" {
|
|
return FromArray([]*Mlrval{})
|
|
}
|
|
return input
|
|
}
|
|
|
|
// SplitAXHelper is split out for the benefit of BIF_splitax and
|
|
// BIF_unflatten.
|
|
func SplitAXHelper(input string, separator string) *Mlrval {
|
|
fields := lib.SplitString(input, separator)
|
|
|
|
output := FromArray(make([]*Mlrval, len(fields)))
|
|
|
|
for i, field := range fields {
|
|
output.intf.([]*Mlrval)[i] = FromString(field)
|
|
}
|
|
|
|
return output
|
|
}
|