Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes/unreleased/query-fanout-failures.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- `Project`/`WhereFeature` on a feature several element rows lack report every such element in the QVTS record (one path failure each) and name the others in the message, instead of stopping at the first.
- The `ambiguousName` finding of `Named` names the namespace that owns the duplicated members, as the QVTS reference semantics do, not the duplicated name.
4 changes: 2 additions & 2 deletions docs/internals/design/query-outcomes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ to `∅` is the ambiguity QVTS exists to remove.

| Step | Value | Path error | Model error |
|:--|:--|:--|:--|
| `Named` | the element | `unknown-element`: the longest prefix that resolves, the segment it lacks, the nearest spellings; an alias that denotes nothing | `ambiguous-element`: the elements, by qualified name |
| `Named` | the element | `unknown-element`: the longest prefix that resolves, the segment it lacks, the nearest spellings; an alias that denotes nothing | `ambiguous-element`: the elements, by qualified name; the finding names the owning namespace |
| `WhereType`, `WhereMetadata` | the selected rows | `unknown-classification` | — |
| `RelatedElements` | the neighbours, possibly none | `unknown-relationship` | — |
| `Project`, `WhereFeature` on a feature the element lacks | an empty cell, with `tolerate = true` | `missing-feature`: the element (`Target`), the feature (`Property`), the features it does have (`Candidates`); `unknown-property` when no row carries it | — |
Expand All @@ -39,7 +39,7 @@ QVTS record itself (`internal/doc/queryexec/outcome.go`), field for field with
`lean/QVTS/Outcome.lean`: a `PathError` is a list of `Failure{Step, Expected, Frontier}`, a
`ModelError` a list of `Finding{Step, Element, Constraint, Observed}` plus the path failures of the
same fan-out; `Frontier` carries `At`, `Members (name, metaclass)`, `Features (name, type)`, `Kinds`
and `Nearest`. `Named` and the `missing-feature` outcome of `Project`/`WhereFeature` fill it today. The surfaces that show it: `-run-query` prints it under the
and `Nearest`. `Named` and the `missing-feature` outcome of `Project`/`WhereFeature` fill it today, the latter with one failure per element lacking the feature (the fan-out keeps every failing element, as `fanOut_pathError_all` in qvts requires). The surfaces that show it: `-run-query` prints it under the
error line and `-json` writes it as `checks[].outcome` in the shape `qvts/sparql/expected/*.json`
records (`outcome`, `step`, `failures` | `findings` + `pathFailures`); `%run-query` prints the same
lines; the gRPC service attaches it to the failed call as a `QueryOutcome` error detail. The
Expand Down
82 changes: 41 additions & 41 deletions docs/project/api-json-differential.md

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions internal/doc/queryexec/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ type Error struct {
// Outcome is the QVTS record behind a path or model error (outcome.go); nil for
// the failures that are neither, the flat fields above being its rendering.
Outcome *Outcome
// Also names the other elements the same fan-out failed on (missing-feature).
Also []string
// Cause is the evaluator's reason an unevaluable feature could not be read.
Cause error
}
Expand Down Expand Up @@ -153,20 +155,22 @@ func (e *Error) missingFeatureMessage() string {
if e.Actual != "" {
msg = fmt.Sprintf("query %s reads feature %s of %s, but the path stops at %s", e.Query, e.Property, e.Target, e.Actual)
}
if len(e.Candidates) == 0 {
return msg
if len(e.Candidates) > 0 {
msg += ": its features are " + strings.Join(e.Candidates, ", ")
if len(e.Candidates) > 3 {
msg = suggest.With(msg, e.missingSegment(), e.Candidates)
}
}
msg += ": its features are " + strings.Join(e.Candidates, ", ")
if len(e.Candidates) <= 3 {
return msg
if len(e.Also) > 0 {
msg += fmt.Sprintf(" (also absent on %s)", strings.Join(e.Also, ", "))
}
return suggest.With(msg, e.missingSegment(), e.Candidates)
return msg
}

// missingSegment is the segment the record says names nothing: the failed step's
// argument, so a path that repeats a name cannot misdirect the suggestion.
func (e *Error) missingSegment() string {
if e.Outcome != nil && e.Outcome.Path != nil && len(e.Outcome.Path.Failures) == 1 {
if e.Outcome != nil && e.Outcome.Path != nil && len(e.Outcome.Path.Failures) > 0 {
return e.Outcome.Path.Failures[0].Step.Argument
}
return e.Property
Expand Down
14 changes: 9 additions & 5 deletions internal/doc/queryexec/missing_feature_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ part def Heavy { attribute mass : ScalarValues::Real; }
part root {
part a : Heavy { attribute :>> mass = 1.0; }
part b { attribute volume = 2.0; attribute mas = 3.0; }
part c { attribute volume = 4.0; }
}
calc def Masses :> Query {
in source : Element;
Expand Down Expand Up @@ -50,12 +51,15 @@ calc def ToleratedHeavyOnes :> Query {
if got := strings.Join(executionError.Candidates, ","); got != "mas,volume" {
t.Fatalf("%s: frontier = %q, want the features of root::b", query, got)
}
if msg := executionError.Error(); !strings.HasSuffix(msg, "of Observatory::root::b, which has no such feature: its features are mas, volume") {
if msg := executionError.Error(); !strings.HasSuffix(msg, "of Observatory::root::b, which has no such feature: its features are mas, volume (also absent on Observatory::root::c)") {
t.Fatalf("%s: message = %q", query, msg)
}
outcome := executionError.Outcome
if outcome == nil || outcome.Kind() != "pathError" || len(outcome.Path.Failures) != 1 {
t.Fatalf("%s: outcome = %+v, want one path failure", query, outcome)
if outcome == nil || outcome.Kind() != "pathError" || len(outcome.Path.Failures) != 2 {
t.Fatalf("%s: outcome = %+v, want one path failure per element lacking mass", query, outcome)
}
if second := outcome.Path.Failures[1]; second.Frontier.At != "Observatory::root::c" || len(second.Frontier.Features) != 1 || second.Frontier.Features[0].Name != "volume" {
t.Fatalf("%s: second failure = %+v", query, second)
}
failure := outcome.Path.Failures[0]
if failure.Step.Label() != "Feature(mass)" || failure.Expected != "a feature named mass on the element" || failure.Frontier.At != "Observatory::root::b" || failure.Frontier.Nearest != "mas" {
Expand All @@ -69,8 +73,8 @@ calc def ToleratedHeavyOnes :> Query {
if err != nil {
t.Fatalf("ToleratedMasses: %v", err)
}
if len(tolerated.Rows()) != 2 || len(tolerated.Rows()[0].Cells()[0].Values()) != 1 || len(tolerated.Rows()[1].Cells()[0].Values()) != 0 {
t.Fatalf("ToleratedMasses: rows = %+v, want a's mass and an empty cell for b", tolerated.Rows())
if len(tolerated.Rows()) != 3 || len(tolerated.Rows()[0].Cells()[0].Values()) != 1 || len(tolerated.Rows()[1].Cells()[0].Values()) != 0 || len(tolerated.Rows()[2].Cells()[0].Values()) != 0 {
t.Fatalf("ToleratedMasses: rows = %+v, want a's mass and empty cells for b and c", tolerated.Rows())
}
kept, err := fixture.execute(t, "ToleratedHeavyOnes", bindings, Options{})
if err != nil || len(kept.Rows()) != 1 {
Expand Down
74 changes: 57 additions & 17 deletions internal/doc/queryexec/named.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ func (e *executor) evaluateNamed(expression queryplan.Expression) (sequence, err
}
var result sequence
for _, name := range names {
elements, dangling := e.denoted(name)
found, dangling := e.denoted(name)
elements := elementsOf(found)
switch {
case len(elements) > 1:
return sequence{}, e.ambiguousElement(expression, name, elements)
return sequence{}, e.ambiguousElement(expression, name, found)
case len(elements) == 0:
return sequence{}, e.unknownElement(expression, name, dangling)
}
Expand All @@ -33,43 +34,81 @@ func (e *executor) evaluateNamed(expression queryplan.Expression) (sequence, err

// denoted resolves name to the distinct elements it denotes, aliases followed
// and deduplicated; dangling reports a candidate alias that denotes nothing.
func (e *executor) denoted(name string) (elements []*symbols.Symbol, dangling bool) {
// denotation is a registration the name matched — a declaration or an alias — and
// the element it denotes; every registration is kept, elementsOf dedups the targets.
type denotation struct {
registration *symbols.Symbol
element *symbols.Symbol
}

func (e *executor) denoted(name string) (found []denotation, dangling bool) {
for _, candidate := range e.context.Index.LookupUniqueCandidates(name) {
element, ok := e.context.Resolver.ResolveAliasTarget(candidate)
if !ok || element == nil {
dangling = true
continue
}
if !slices.ContainsFunc(elements, func(o *symbols.Symbol) bool { return symbols.SameElement(o, element) }) {
elements = append(elements, element)
found = append(found, denotation{registration: candidate, element: element})
}
return found, dangling
}

// elementsOf is the distinct elements the registrations denote.
func elementsOf(found []denotation) []*symbols.Symbol {
out := make([]*symbols.Symbol, 0, len(found))
for _, d := range found {
if !slices.ContainsFunc(out, func(o *symbols.Symbol) bool { return symbols.SameElement(o, d.element) }) {
out = append(out, d.element)
}
}
return elements, dangling
return out
}

// ambiguousElement reports a name several elements carry, each by its
// fully-qualified name, in name order.
func (e *executor) ambiguousElement(expression queryplan.Expression, name string, candidates []*symbols.Symbol) error {
func (e *executor) ambiguousElement(expression queryplan.Expression, name string, candidates []denotation) error {
fqns := make([]string, 0, len(candidates))
for _, candidate := range candidates {
fqns = append(fqns, symbols.FQNOf(candidate))
for _, element := range elementsOf(candidates) {
fqns = append(fqns, symbols.FQNOf(element))
}
sort.Strings(fqns)
// One finding per namespace declaring the duplicate names (an alias counts where it
// is declared, not where its target is), each listing every element denoted there.
byOwner := map[string][]string{}
for _, candidate := range candidates {
owner := ownerOf(symbols.FQNOf(candidate.registration))
byOwner[owner] = append(byOwner[owner], symbols.FQNOf(candidate.element))
}
for owner := range byOwner {
sort.Strings(byOwner[owner])
}
step := Step{Kind: StepNamed, Argument: name}
owners := make([]string, 0, len(byOwner))
for owner := range byOwner {
owners = append(owners, owner)
}
sort.Strings(owners)
findings := make([]Finding, 0, len(owners))
for _, owner := range owners {
findings = append(findings, Finding{Step: step, Element: owner, Constraint: ConstraintAmbiguousName, Observed: declaredAs(byOwner[owner])})
}
return &Error{
Kind: ErrorAmbiguousElement,
Query: e.definition.Name(),
Operation: expression.Operation(),
Actual: name,
Candidates: fqns,
Origin: expression.Origin(),
Outcome: &Outcome{Model: &ModelError{Findings: []Finding{{
Step: step,
Element: name,
Constraint: ConstraintAmbiguousName,
Observed: declaredAs(fqns),
}}}},
Outcome: &Outcome{Model: &ModelError{Findings: findings}},
}
}

// ownerOf is the namespace a qualified name is declared in; a root name owns itself.
func ownerOf(fqn string) string {
if i := strings.LastIndex(fqn, "::"); i >= 0 {
return fqn[:i]
}
return fqn
}

// unknownElement reports a name no element carries with its frontier: the longest
Expand Down Expand Up @@ -98,7 +137,8 @@ func (e *executor) unknownElement(expression queryplan.Expression, name string,
segments := strings.Split(name, "::")
for i := len(segments) - 1; i >= 1; i-- {
prefix := strings.Join(segments[:i], "::")
owners, dangling := e.denoted(prefix)
found, dangling := e.denoted(prefix)
owners := elementsOf(found)
switch len(owners) {
case 0:
if dangling {
Expand All @@ -119,7 +159,7 @@ func (e *executor) unknownElement(expression queryplan.Expression, name string,
default:
// The first segment that does not resolve is one several elements carry:
// the frontier is that ambiguity, a finding about the model.
return e.ambiguousElement(expression, prefix, owners)
return e.ambiguousElement(expression, prefix, found)
}
}
err.Expected = segments[0]
Expand Down
42 changes: 31 additions & 11 deletions internal/doc/queryexec/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -407,15 +407,17 @@ func (e *executor) evaluateWhereFeature(expression queryplan.Expression) (sequen
}
result := filtered(source)
known := false
var missing error
var missing []*Error
columnIndex := projectedColumn(source, property)
for i := range source.values {
values, present, valueErr := e.featureValues(expression, source, i, property, columnIndex)
if valueErr != nil {
return sequence{}, valueErr
}
if !present && !tolerate && missing == nil {
missing = e.missingFeature(expression, property, source.values[i])
if !present && !tolerate {
if m := e.missingFeature(expression, property, source.values[i]); m != nil {
missing = append(missing, m)
}
}
known = known || present
for _, actual := range values {
Expand All @@ -435,8 +437,8 @@ func (e *executor) evaluateWhereFeature(expression queryplan.Expression) (sequen
if !known {
return sequence{}, e.unknownProperty(expression, property)
}
if missing != nil {
return sequence{}, missing
if err := joinMissing(missing); err != nil {
return sequence{}, err
}
return result, nil
}
Expand Down Expand Up @@ -650,16 +652,18 @@ func (e *executor) evaluateProject(expression queryplan.Expression) (sequence, e
result.columns[len(properties)+i] = Column{name: column.name, origin: column.origin.Origin()}
}
tracker := newPropertyTracker()
missing := make([]error, len(properties))
missing := make([][]*Error, len(properties))
for row, value := range source.values {
result.cells[row] = make([]Cell, total)
for column, property := range properties {
values, present, valueErr := e.propertyValues(value, property)
if valueErr != nil {
return sequence{}, e.unevaluable(expression, property, value, valueErr)
}
if !present && !tolerate && missing[column] == nil {
missing[column] = e.missingFeature(expression, property, value)
if !present && !tolerate {
if m := e.missingFeature(expression, property, value); m != nil {
missing[column] = append(missing[column], m)
}
}
known[column] = known[column] || present
result.cells[row][column] = Cell{
Expand All @@ -686,8 +690,8 @@ func (e *executor) evaluateProject(expression queryplan.Expression) (sequence, e
}
// A property some rows carry and an element row lacks is a path error
// about that element, unless tolerated; one no row carries is unknown.
for _, err := range missing {
if err != nil {
for _, column := range missing {
if err := joinMissing(column); err != nil {
return sequence{}, err
}
}
Expand Down Expand Up @@ -1208,7 +1212,7 @@ func (e *executor) tolerateArgument(expression queryplan.Expression) (bool, erro

// missingFeature reports a feature the element row lacks, its own features as
// the frontier; nil for a non-element row or a metamodel property every element answers.
func (e *executor) missingFeature(expression queryplan.Expression, property string, row Value) error {
func (e *executor) missingFeature(expression queryplan.Expression, property string, row Value) *Error {
sym, isElement := row.Element()
if !isElement || sym == nil || isQueryableProperty(property) || strings.Contains(property, "::") {
// A metadata path names an annotation the element may simply not carry.
Expand Down Expand Up @@ -1243,6 +1247,22 @@ func (e *executor) missingFeature(expression queryplan.Expression, property stri
}
}

// joinMissing is the fan-out of a missing feature: one error whose record carries
// every element's failure, the message naming the first element and counting the rest.
func joinMissing(errs []*Error) error {
if len(errs) == 0 {
return nil
}
head := *errs[0]
failures := append([]Failure{}, head.Outcome.Path.Failures...)
for _, other := range errs[1:] {
failures = append(failures, other.Outcome.Path.Failures...)
head.Also = append(head.Also, other.Target)
}
head.Outcome = &Outcome{Path: &PathError{Failures: failures}}
return &head
}

// features lists, in name order, the features an element has from the model's own
// declarations (inherited ones included; library ones are noise) with their declared type.
func (e *executor) features(sym *symbols.Symbol) []FeatureRef {
Expand Down
30 changes: 29 additions & 1 deletion internal/doc/queryexec/outcome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ calc def Dangling :> Query { Named(qualifiedName = "Observatory::Broken::x") }
package Kinds { part a; part def a; }
calc def TwoKinds :> Query { Named(qualifiedName = "Observatory::Kinds::missing") }
calc def RootMiss :> Query { Named(qualifiedName = "Observatry") }
calc def Partial :> Query { Named(qualifiedName = "Duplicates::a") }
calc def Spread :> Query { Named(qualifiedName = "a") }
package Targets { part t1; part t2; }
package Choices { alias Pick for Targets::t1; alias Pick for Targets::t2; }
calc def Aliased :> Query { Named(qualifiedName = "Observatory::Choices::Pick") }
package A { alias Pick for Targets::t1; }
package B { alias Pick for Targets::t1; alias Pick for Targets::t2; }
calc def Shared :> Query { Named(qualifiedName = "Pick") }
`)
outcome := func(query string) Outcome {
t.Helper()
Expand Down Expand Up @@ -67,9 +75,29 @@ calc def RootMiss :> Query { Named(qualifiedName = "Observatry") }
t.Fatalf("Twice: outcome = %+v, want one finding", twice)
}
finding := twice.Model.Findings[0]
if finding.Constraint != ConstraintAmbiguousName || finding.Element != "Observatory::Duplicates::a" || finding.Observed != "Observatory::Duplicates::a (declared 2 times)" {
if finding.Constraint != ConstraintAmbiguousName || finding.Element != "Observatory::Duplicates" || finding.Observed != "Observatory::Duplicates::a (declared 2 times)" {
t.Errorf("Twice: finding = %+v", finding)
}
if partial := outcome("Partial"); len(partial.Model.Findings) != 1 || partial.Model.Findings[0].Element != "Observatory::Duplicates" {
t.Errorf("Partial: findings = %+v, want the owner of the resolved candidates, not a slice of the partial name", partial.Model.Findings)
}
spread := outcome("Spread")
if len(spread.Model.Findings) != 2 || spread.Model.Findings[0].Element != "Observatory::Duplicates" || spread.Model.Findings[1].Element != "Observatory::Kinds" ||
spread.Model.Findings[0].Observed != "Observatory::Duplicates::a (declared 2 times)" || spread.Model.Findings[1].Observed != "Observatory::Kinds::a (declared 2 times)" {
t.Errorf("Spread: findings = %+v, want one per owning namespace", spread.Model.Findings)
}
if aliased := outcome("Aliased"); len(aliased.Model.Findings) != 1 || aliased.Model.Findings[0].Element != "Observatory::Choices" ||
aliased.Model.Findings[0].Observed != "Observatory::Targets::t1, Observatory::Targets::t2" {
t.Errorf("Aliased: findings = %+v, want the namespace declaring both aliases, listing their targets", aliased.Model.Findings)
}
shared := outcome("Shared")
var owners []string
for _, f := range shared.Model.Findings {
owners = append(owners, f.Element+"="+f.Observed)
}
if got := strings.Join(owners, ";"); !strings.Contains(got, "Observatory::A=Observatory::Targets::t1;") || !strings.Contains(got, "Observatory::B=Observatory::Targets::t1, Observatory::Targets::t2") {
t.Errorf("Shared: findings = %s, want B's two aliases both listed", got)
}
encoded, _ = json.Marshal(twice)
if !strings.Contains(string(encoded), `"outcome":"modelError"`) || !strings.Contains(string(encoded), `"pathFailures":[]`) {
t.Errorf("Twice JSON = %s", encoded)
Expand Down
Loading