diff --git a/README.md b/README.md index 89a37cc..8cee9ea 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Cross-compiling **to** darwin from anything that is not darwin is the one direct ## What you get - `context.Context` first on every call that can block. Cancelling it calls into the engine's interrupt, and the failure that comes back answers `errors.Is` against both `context.Canceled` and `zu.Interrupted`, because a caller who wrote the deadline and a caller handling the failure ask different questions of the same error. A context that can never be cancelled starts no goroutine. -- `errors.Is` and `errors.As` against `*zu.Error`, which carries `Code`, `Message`, `StandardText`, `DocURL`, `Severity`, `Retryable`, `Position` and the source line the position points into. The statuses are sentinels of their own: `errors.Is(err, zu.Conflict)` is the retry question and needs no unwrapping. +- `errors.Is` and `errors.As` against `*zu.Error`, which carries `Code`, `Message`, `StandardText`, `DocURL`, `Severity`, `Retryable`, `Position` and the source line the position points into. It also carries the rest of the diagnostic record ISO 39075 subclause 23.2 asks for: `SubjectKind` and `Subject` are what the condition is about when it is about something the statement named, and `Graph` and `Schema` are where the statement was running. A program that highlights the offending label in an editor reads the name off the record rather than out of an English sentence. The statuses are sentinels of their own: `errors.Is(err, zu.Conflict)` is the retry question and needs no unwrapping. - Range-over-func iteration: `rows.All()` is an `iter.Seq[Row]`, and `zu.Iter[T]` is an `iter.Seq2[T, error]` that streams into your own type. - Three levels of reading, in the order you reach for them: `Scan` into concrete destinations, `Collect[T]` and `Iter[T]` into a struct matched by `zu` tags or by name, and `Int64s`, `Float64s`, `NodeOffsets` and `Valid` for a whole column borrowed from the result without a copy. - The seven temporal types spelled out rather than flattened into `time.Time`. A date is a `zu.Date`, a time of day is a `zu.LocalTime`, and a year-month duration is a `zu.YearMonth`, because a `time.Time` made out of a time of day is a date somebody invented. The three that name an instant scan into a `time.Time` when you ask for one. diff --git a/api/surface.txt b/api/surface.txt index 99798ae..a51c94e 100644 --- a/api/surface.txt +++ b/api/surface.txt @@ -45,12 +45,16 @@ pkg github.com/tamnd/zu-go, field Date.Days int32 pkg github.com/tamnd/zu-go, field Error.Code string pkg github.com/tamnd/zu-go, field Error.DocURL string pkg github.com/tamnd/zu-go, field Error.Excerpt string +pkg github.com/tamnd/zu-go, field Error.Graph string pkg github.com/tamnd/zu-go, field Error.Message string pkg github.com/tamnd/zu-go, field Error.Position Position pkg github.com/tamnd/zu-go, field Error.Retryable bool +pkg github.com/tamnd/zu-go, field Error.Schema string pkg github.com/tamnd/zu-go, field Error.Severity Severity pkg github.com/tamnd/zu-go, field Error.StandardText string pkg github.com/tamnd/zu-go, field Error.Status Status +pkg github.com/tamnd/zu-go, field Error.Subject string +pkg github.com/tamnd/zu-go, field Error.SubjectKind string pkg github.com/tamnd/zu-go, field LocalDateTime.Nanos int64 pkg github.com/tamnd/zu-go, field LocalTime.Nanos int64 pkg github.com/tamnd/zu-go, field Node.Offset uint64 diff --git a/bytes_test.go b/bytes_test.go index ab025a9..1a9f09a 100644 --- a/bytes_test.go +++ b/bytes_test.go @@ -101,13 +101,13 @@ func TestACharacterStringScansIntoOctetsBecauseThatIsWhatItIs(t *testing.T) { // is whether the slice is nil. func TestAByteStringOfNoOctetsIsNotTheSameAsNoByteString(t *testing.T) { conn := memory(t) - rows := query(t, conn, `RETURN X'' AS empty, null AS nothing`) + rows := query(t, conn, `RETURN X'' AS empty, null AS missing`) if !rows.Next() { t.Fatal("no row") } - var empty, nothing []byte - if err := rows.Scan(&empty, ¬hing); err != nil { + var empty, missing []byte + if err := rows.Scan(&empty, &missing); err != nil { t.Fatalf("scanning the two: %v", err) } if empty == nil { @@ -116,8 +116,8 @@ func TestAByteStringOfNoOctetsIsNotTheSameAsNoByteString(t *testing.T) { if len(empty) != 0 { t.Errorf("X'' came back as %#v", empty) } - if nothing != nil { - t.Errorf("a null came back as %#v rather than as nil", nothing) + if missing != nil { + t.Errorf("a null came back as %#v rather than as nil", missing) } // And through the untyped read, where the two are a []byte and an diff --git a/corpus/runner_test.go b/corpus/runner_test.go index 2660727..b0fb8cb 100644 --- a/corpus/runner_test.go +++ b/corpus/runner_test.go @@ -172,8 +172,14 @@ func TestEveryWayACaseCanFailIsReportedInFull(t *testing.T) { // A case the engine has not caught up to is unsupported and not a // failure, which is what lets the corpus be the contract and the engine // catch up to it. The two classes that say so are 42 and 0A. +// +// The statement has to be one the engine really has not reached, and a +// test like this is a canary by construction: the day CREATE lands, this +// stops testing what it says it tests and has to pick another spelling. +// It was SELECT before, which the engine now parses. func TestACaseAheadOfTheEngineIsUnsupportedAndNotAFailure(t *testing.T) { - got := only(t, " - name: one\n doc: d\n query: SELECT 1\n columns:\n - n\n"+ + got := only(t, " - name: one\n doc: d\n query: CREATE NODE TABLE person(uid INT64)\n"+ + " columns:\n - n\n"+ " rows:\n") if got.Outcome != Unsupported { t.Errorf("came to %s: %s", got.Outcome.mark(), got.Detail) diff --git a/error.go b/error.go index 881fec7..044af9e 100644 --- a/error.go +++ b/error.go @@ -223,6 +223,26 @@ type Error struct { // under a caret, since a line cut to fit would put the column // somewhere it is not. Excerpt string + // SubjectKind is what kind of thing the condition is about, when + // it is about something the statement named: one lower case word + // out of graph, schema, label, property, variable, type and + // function. It is kept apart from Subject rather than glued to the + // front of it, so that asking whether a failure is about a label + // is one string compared against one word. + SubjectKind string + // Subject is the name the condition is about, written the way the + // statement wrote it and with nothing around it, which is what an + // editor underlines. It and SubjectKind are both empty or both + // set. + Subject string + // Graph is the graph the statement was running in, which ISO 39075 + // subclause 23.2 asks a diagnostic record to name. It is empty + // when the failure happened before there was one, which is the + // case for a connection that never opened. + Graph string + // Schema is the schema the statement was running in, empty on the + // same terms as Graph. + Schema string // cause is the context error behind an interruption, so that a // query stopped by a cancelled context answers to @@ -284,6 +304,10 @@ func take(status C.zu_status, handle *C.zu_error) *Error { e.StandardText = text(C.zu_error_standard_text(handle, &n), n) e.DocURL = text(C.zu_error_doc_url(handle, &n), n) e.Excerpt = text(C.zu_error_excerpt(handle, &n), n) + e.SubjectKind = text(C.zu_error_subject_kind(handle, &n), n) + e.Subject = text(C.zu_error_subject(handle, &n), n) + e.Graph = text(C.zu_error_graph(handle, &n), n) + e.Schema = text(C.zu_error_schema(handle, &n), n) e.Severity = Severity(C.zu_error_severity(handle)) e.Retryable = C.zu_error_retryable(handle) == 1 diff --git a/example_test.go b/example_test.go index d74d01c..af1097a 100644 --- a/example_test.go +++ b/example_test.go @@ -113,20 +113,20 @@ func ExampleNamed() { // Parameters are named rather than positional, so there is no order // to get wrong. The dollar may be written or left off. - rows, err := conn.Query(ctx, `RETURN $a + $b AS sum`, + rows, err := conn.Query(ctx, `RETURN $a + $b AS total`, zu.Named("a", 40), zu.Named("b", 2)) if err != nil { log.Fatal(err) } defer rows.Close() - var sum int64 + var total int64 if rows.Next() { - if err := rows.Scan(&sum); err != nil { + if err := rows.Scan(&total); err != nil { log.Fatal(err) } } - fmt.Println(sum) + fmt.Println(total) // Output: 42 } diff --git a/include/zu.h b/include/zu.h index 984126a..3d85b0c 100644 --- a/include/zu.h +++ b/include/zu.h @@ -231,6 +231,21 @@ const char *zu_version(void); * is longer than anyone would read under a caret, since a line cut to * fit would put the column somewhere it is not. * + * zu_error_subject_kind and zu_error_subject are what the condition is + * about, when it is about something the statement named: the kind is + * one lower-case word out of graph, schema, label, property, variable, + * type and function, and the subject is the name itself, written the + * way the statement wrote it and with nothing around it. They are both + * NULL or both set. A binding that highlights the offending label in + * an editor wants the name on its own, and the kind is kept in its own + * accessor rather than glued to the front of it so that asking "is + * this about a label" is one string compared against one word. + * + * zu_error_graph and zu_error_schema are where the statement was + * running, which ISO 39075 subclause 23.2 asks a diagnostic record to + * name. They are NULL when the failure happened before there was one, + * which is the case for a connection that never opened. + * * The message says all of this in words and keeps saying it, so * printing it alone is still a complete report. The fields are for the * caller that would rather underline the token than read the numbers @@ -245,6 +260,10 @@ int32_t zu_error_retryable(const zu_error *e); /* -1 for a NULL error */ zu_status zu_error_position(const zu_error *e, uint32_t *line, uint32_t *column); zu_status zu_error_offset(const zu_error *e, uint32_t *offset); const char *zu_error_excerpt(const zu_error *e, size_t *len); +const char *zu_error_subject_kind(const zu_error *e, size_t *len); +const char *zu_error_subject(const zu_error *e, size_t *len); +const char *zu_error_graph(const zu_error *e, size_t *len); +const char *zu_error_schema(const zu_error *e, size_t *len); void zu_error_free(zu_error *e); /* How a database is opened. The only struct that crosses this boundary diff --git a/lib/darwin-amd64/REVISION b/lib/darwin-amd64/REVISION index c8a42c9..0b3ca10 100644 --- a/lib/darwin-amd64/REVISION +++ b/lib/darwin-amd64/REVISION @@ -1 +1 @@ -f0ba1219a26cd735f1da63e3fddaba9cf6370248 +a92d9c1b9c511cbff8b49b426ffe04f73f0d96d4 diff --git a/lib/darwin-amd64/libzu.a b/lib/darwin-amd64/libzu.a index f3fd04a..1f577fe 100644 Binary files a/lib/darwin-amd64/libzu.a and b/lib/darwin-amd64/libzu.a differ diff --git a/lib/darwin-arm64/REVISION b/lib/darwin-arm64/REVISION index c8a42c9..0b3ca10 100644 --- a/lib/darwin-arm64/REVISION +++ b/lib/darwin-arm64/REVISION @@ -1 +1 @@ -f0ba1219a26cd735f1da63e3fddaba9cf6370248 +a92d9c1b9c511cbff8b49b426ffe04f73f0d96d4 diff --git a/lib/darwin-arm64/libzu.a b/lib/darwin-arm64/libzu.a index 9864a62..828c2bd 100644 Binary files a/lib/darwin-arm64/libzu.a and b/lib/darwin-arm64/libzu.a differ diff --git a/lib/linux-amd64/REVISION b/lib/linux-amd64/REVISION index c8a42c9..0b3ca10 100644 --- a/lib/linux-amd64/REVISION +++ b/lib/linux-amd64/REVISION @@ -1 +1 @@ -f0ba1219a26cd735f1da63e3fddaba9cf6370248 +a92d9c1b9c511cbff8b49b426ffe04f73f0d96d4 diff --git a/lib/linux-amd64/libzu.a b/lib/linux-amd64/libzu.a index 2c91cb0..01458a2 100644 Binary files a/lib/linux-amd64/libzu.a and b/lib/linux-amd64/libzu.a differ diff --git a/lib/linux-arm64/REVISION b/lib/linux-arm64/REVISION index c8a42c9..0b3ca10 100644 --- a/lib/linux-arm64/REVISION +++ b/lib/linux-arm64/REVISION @@ -1 +1 @@ -f0ba1219a26cd735f1da63e3fddaba9cf6370248 +a92d9c1b9c511cbff8b49b426ffe04f73f0d96d4 diff --git a/lib/linux-arm64/libzu.a b/lib/linux-arm64/libzu.a index 24c2bcb..2601f46 100644 Binary files a/lib/linux-arm64/libzu.a and b/lib/linux-arm64/libzu.a differ diff --git a/lib/windows-amd64/REVISION b/lib/windows-amd64/REVISION index c8a42c9..0b3ca10 100644 --- a/lib/windows-amd64/REVISION +++ b/lib/windows-amd64/REVISION @@ -1 +1 @@ -f0ba1219a26cd735f1da63e3fddaba9cf6370248 +a92d9c1b9c511cbff8b49b426ffe04f73f0d96d4 diff --git a/lib/windows-amd64/libzu.a b/lib/windows-amd64/libzu.a index 804e1e2..700a0b1 100644 Binary files a/lib/windows-amd64/libzu.a and b/lib/windows-amd64/libzu.a differ diff --git a/loader_test.go b/loader_test.go index 078d2b7..b2fc679 100644 --- a/loader_test.go +++ b/loader_test.go @@ -219,7 +219,7 @@ func TestAColumnOfEveryKindReadsBackAsWhatItWas(t *testing.T) { took := []time.Duration{86_402 * time.Second, 0} aged := []YearMonth{{Months: 14}, {Months: -1}} - if err := l.Int64s("count", []int64{1, -2}); err != nil { + if err := l.Int64s("tally", []int64{1, -2}); err != nil { t.Fatal(err) } if err := l.Float64s("ratio", []float64{1.5, -0.25}); err != nil { @@ -251,12 +251,12 @@ func TestAColumnOfEveryKindReadsBackAsWhatItWas(t *testing.T) { } conn := opened(t, path) - rows := query(t, conn, "MATCH (p:person) RETURN p.count AS count, p.ratio AS ratio, "+ + rows := query(t, conn, "MATCH (p:person) RETURN p.tally AS tally, p.ratio AS ratio, "+ "p.flag AS flag, p.name AS name, p.born AS born, p.woke AS woke, "+ "p.seen AS seen, p.took AS took, p.aged AS aged") type row struct { - Count int64 + Tally int64 Ratio float64 Flag bool Name string diff --git a/misuse_test.go b/misuse_test.go index e62412c..3ba9268 100644 --- a/misuse_test.go +++ b/misuse_test.go @@ -428,6 +428,48 @@ func TestEveryRefusalCarriesTheConditionAndThePlace(t *testing.T) { } } +// The rest of the diagnostic record ISO 39075 subclause 23.2 asks for: +// what the condition is about and where the statement was running. A +// caller that has to find the offending name inside an English sentence +// is parsing prose, which is the whole reason these are fields. +func TestARefusalAboutSomethingNamedSaysWhatItIsAbout(t *testing.T) { + conn, _ := seeded(t) + err := conn.Exec(t.Context(), "MATCH (a:person) RETURN b.uid AS uid") + + var e *Error + if !errors.As(err, &e) { + t.Fatalf("a refused statement is not a *zu.Error: %v", err) + } + // The kind is its own word rather than glued to the front of the + // name, so asking whether this is about a variable is one string + // compared against one word. + if e.SubjectKind != "variable" { + t.Errorf("the condition is about a %q", e.SubjectKind) + } + if e.Subject != "b" { + t.Errorf("the name the condition is about is %q", e.Subject) + } + if e.Graph == "" || e.Schema == "" { + t.Errorf("the statement ran in graph %q schema %q", e.Graph, e.Schema) + } +} + +// A condition about no name carries none, rather than carrying a guess +// or the empty string as though it were one. Both halves of the subject +// go together, which is what lets a caller test one of them. +func TestARefusalAboutNothingNamedCarriesNoSubject(t *testing.T) { + conn, _ := seeded(t) + err := conn.Exec(t.Context(), "RETURN 1 / 0 AS n") + + var e *Error + if !errors.As(err, &e) { + t.Fatalf("a refused statement is not a *zu.Error: %v", err) + } + if e.SubjectKind != "" || e.Subject != "" { + t.Errorf("a division by zero is about %q %q", e.SubjectKind, e.Subject) + } +} + // openFiles counts the descriptors this process holds, on the systems // that will say. A count that stands still across a thousand opens is // the assertion; the absolute number is nobody's business. diff --git a/rows_test.go b/rows_test.go index 2df5011..2cb9149 100644 --- a/rows_test.go +++ b/rows_test.go @@ -149,7 +149,7 @@ func TestACellReadAsSomethingItIsNotNamesTheColumn(t *testing.T) { func TestANullNeedsSomewhereThatHoldsOne(t *testing.T) { conn := memory(t) - rows := query(t, conn, "RETURN null AS nothing") + rows := query(t, conn, "RETURN null AS missing") if !rows.Next() { t.Fatal("no row") } diff --git a/stmt_test.go b/stmt_test.go index 5c286f8..d550905 100644 --- a/stmt_test.go +++ b/stmt_test.go @@ -133,7 +133,7 @@ func TestAValueThisEngineDoesNotTakeIsRefusedBeforeItRuns(t *testing.T) { func TestAPreparedStatementRunsAgainWithOnlyWhatChangedRebound(t *testing.T) { conn := memory(t) - stmt, err := conn.Prepare(t.Context(), "RETURN $a + $b AS sum") + stmt, err := conn.Prepare(t.Context(), "RETURN $a + $b AS total") if err != nil { t.Fatalf("preparing: %v", err) } diff --git a/zusql/bench_test.go b/zusql/bench_test.go index 561f629..4846cb1 100644 --- a/zusql/bench_test.go +++ b/zusql/bench_test.go @@ -87,7 +87,7 @@ func BenchmarkQueryRowDirect(b *testing.B) { func BenchmarkPrepared(b *testing.B) { db := benchDB(b) - stmt, err := db.Prepare("RETURN $n + 1 AS next") + stmt, err := db.Prepare("RETURN $n + 1 AS following") if err != nil { b.Fatal(err) } diff --git a/zusql/example_test.go b/zusql/example_test.go index b921fdb..f2c3aa2 100644 --- a/zusql/example_test.go +++ b/zusql/example_test.go @@ -48,12 +48,12 @@ func Example_named() { } defer db.Close() - var sum int64 - err = db.QueryRow("RETURN $a + $b AS sum", sql.Named("a", 20), sql.Named("b", 22)).Scan(&sum) + var total int64 + err = db.QueryRow("RETURN $a + $b AS total", sql.Named("a", 20), sql.Named("b", 22)).Scan(&total) if err != nil { panic(err) } - fmt.Println(sum) + fmt.Println(total) _, err = db.Query("RETURN $a AS a", 1) fmt.Println(err != nil) diff --git a/zusql/zusql_test.go b/zusql/zusql_test.go index 00bc9f8..0019942 100644 --- a/zusql/zusql_test.go +++ b/zusql/zusql_test.go @@ -102,7 +102,7 @@ func TestAResultWithNoRowsEndsTheLoopAndSaysNoRows(t *testing.T) { func TestAnArgumentIsBoundByName(t *testing.T) { db := open(t) - got := one(t, db, "RETURN $a + $b AS sum", sql.Named("a", 20), sql.Named("b", 22)) + got := one(t, db, "RETURN $a + $b AS total", sql.Named("a", 20), sql.Named("b", 22)) if got != 42 { t.Errorf("20 + 22 came back as %d", got) }