Version 4 #6

Merged
danieljsummers merged 30 commits from version-four into main 2024-08-19 23:30:39 +00:00
10 changed files with 135 additions and 119 deletions
Showing only changes of commit 35755df99a - Show all commits

View File

@ -35,6 +35,12 @@ type Op =
| NEX -> "IS NULL"
/// The dialect in which a command should be rendered
[<Struct>]
type Dialect =
| PostgreSQL
| SQLite
/// Criteria for a field WHERE clause
type Field = {
/// The name of the field
@ -89,6 +95,16 @@ type Field = {
static member NEX name =
{ Name = name; Op = NEX; Value = obj (); ParameterName = None; Qualifier = None }
/// Transform a field name (a.b.c) to a path for the given SQL dialect
static member NameToPath (name: string) dialect =
let path =
if name.Contains '.' then
match dialect with
| PostgreSQL -> "#>>'{" + String.concat "," (name.Split '.') + "}'"
| SQLite -> "->>'" + String.concat "'->>'" (name.Split '.') + "'"
else $"->>'{name}'"
$"data{path}"
/// Specify the name of the parameter for this field
member this.WithParameterName name =
{ this with ParameterName = Some name }
@ -97,17 +113,9 @@ type Field = {
member this.WithQualifier alias =
{ this with Qualifier = Some alias }
/// Get the path for this field in PostgreSQL's format
member this.PgSqlPath =
(this.Qualifier |> Option.map (fun q -> $"{q}.data") |> Option.defaultValue "data")
+ if this.Name.Contains '.' then "#>>'{" + String.concat "," (this.Name.Split '.') + "}'"
else $"->>'{this.Name}'"
/// Get the path for this field in SQLite's format
member this.SqlitePath =
(this.Qualifier |> Option.map (fun q -> $"{q}.data") |> Option.defaultValue "data")
+ if this.Name.Contains '.' then "->>'" + String.concat "'->>'" (this.Name.Split '.') + "'"
else $"->>'{this.Name}'"
/// Get the qualified path to the field
member this.Path dialect =
(this.Qualifier |> Option.map (fun q -> $"{q}.") |> Option.defaultValue "") + Field.NameToPath this.Name dialect
/// How fields should be matched
@ -221,7 +229,7 @@ module Query =
/// SQL statement to create an index on one or more fields in a JSON document
[<CompiledName "EnsureIndexOn">]
let ensureIndexOn tableName indexName (fields: string seq) =
let ensureIndexOn tableName indexName (fields: string seq) dialect =
let _, tbl = splitSchemaAndTable tableName
let jsonFields =
fields
@ -229,14 +237,14 @@ module Query =
let parts = it.Split ' '
let fieldName = if Array.length parts = 1 then it else parts[0]
let direction = if Array.length parts < 2 then "" else $" {parts[1]}"
$"(data ->> '{fieldName}'){direction}")
$"({Field.NameToPath fieldName dialect}){direction}")
|> String.concat ", "
$"CREATE INDEX IF NOT EXISTS idx_{tbl}_%s{indexName} ON {tableName} ({jsonFields})"
/// SQL statement to create a key index for a document table
[<CompiledName "EnsureKey">]
let ensureKey tableName =
(ensureIndexOn tableName "key" [ Configuration.idField () ]).Replace("INDEX", "UNIQUE INDEX")
let ensureKey tableName dialect =
(ensureIndexOn tableName "key" [ Configuration.idField () ] dialect).Replace("INDEX", "UNIQUE INDEX")
/// Query to insert a document
[<CompiledName "Insert">]
@ -250,3 +258,15 @@ module Query =
"INSERT INTO %s VALUES (@data) ON CONFLICT ((data->>'%s')) DO UPDATE SET data = EXCLUDED.data"
tableName (Configuration.idField ())
/// Queries for counting documents
module Count =
/// Query to count all documents in a table
[<CompiledName "All">]
let all tableName =
$"SELECT COUNT(*) AS it FROM %s{tableName}"
/// Query to count matching documents using a text comparison on JSON fields
[<CompiledName "ByFields">]
let byFields (whereByFields: FieldMatch -> Field seq -> string) tableName howMatched fields =
$"SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereByFields howMatched fields}"

View File

@ -120,18 +120,18 @@ module Query =
fields
|> Seq.map (fun it ->
match it.Op with
| EX | NEX -> $"{it.PgSqlPath} {it.Op}"
| _ ->
| EX | NEX -> $"{it.Path PostgreSQL} {it.Op}"
| BT ->
let p = name.Derive it.ParameterName
let path, value =
match it.Op with
| BT -> $"{p}min AND {p}max", (it.Value :?> obj list)[0]
| _ -> p, it.Value
printfn $"%A{value}"
match value with
| :? int8 | :? uint8 | :? int16 | :? uint16 | :? int | :? uint32 | :? int64 | :? uint64
| :? decimal | :? single | :? double -> $"({it.PgSqlPath})::numeric {it.Op} {path}"
| _ -> $"{it.PgSqlPath} {it.Op} {path}")
| :? decimal | :? single | :? double -> $"({it.Path PostgreSQL})::numeric {it.Op} {path}"
| _ -> $"{it.Path PostgreSQL} {it.Op} {path}"
| _ -> $"{it.Path PostgreSQL} {it.Op} {name.Derive it.ParameterName}")
|> String.concat (match howMatched with Any -> " OR " | All -> " AND ")
/// Create a WHERE clause fragment to implement a comparison on a field in a JSON document
@ -178,15 +178,10 @@ module Query =
/// Queries for counting documents
module Count =
/// Query to count all documents in a table
[<CompiledName "All">]
let all tableName =
$"SELECT COUNT(*) AS it FROM %s{tableName}"
/// Query to count matching documents using a text comparison on JSON fields
[<CompiledName "ByFields">]
let byFields tableName howMatched fields =
$"SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereByFields howMatched fields}"
Query.Count.byFields whereByFields tableName howMatched fields
/// Query to count matching documents using a text comparison on a JSON field
[<CompiledName "ByField">]
@ -197,12 +192,12 @@ module Query =
/// Query to count matching documents using a JSON containment query (@>)
[<CompiledName "ByContains">]
let byContains tableName =
$"""SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereDataContains "@criteria"}"""
$"""{Query.Count.all tableName} WHERE {whereDataContains "@criteria"}"""
/// Query to count matching documents using a JSON Path match (@?)
[<CompiledName "ByJsonPath">]
let byJsonPath tableName =
$"""SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereJsonPathMatches "@path"}"""
$"""{Query.Count.all tableName} WHERE {whereJsonPathMatches "@path"}"""
/// Queries for determining document existence
module Exists =
@ -445,7 +440,7 @@ module WithProps =
[<CompiledName "EnsureTable">]
let ensureTable name sqlProps = backgroundTask {
do! Custom.nonQuery (Query.Definition.ensureTable name) [] sqlProps
do! Custom.nonQuery (Query.Definition.ensureKey name) [] sqlProps
do! Custom.nonQuery (Query.Definition.ensureKey name PostgreSQL) [] sqlProps
}
/// Create an index on documents in the specified table
@ -456,7 +451,7 @@ module WithProps =
/// Create an index on field(s) within documents in the specified table
[<CompiledName "EnsureFieldIndex">]
let ensureFieldIndex tableName indexName fields sqlProps =
Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields) [] sqlProps
Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields PostgreSQL) [] sqlProps
/// Commands to add documents
[<AutoOpen>]

View File

@ -38,11 +38,11 @@ module Query =
fields
|> Seq.map (fun it ->
match it.Op with
| EX | NEX -> $"{it.SqlitePath} {it.Op}"
| EX | NEX -> $"{it.Path SQLite} {it.Op}"
| BT ->
let p = name.Derive it.ParameterName
$"{it.SqlitePath} {it.Op} {p}min AND {p}max"
| _ -> $"{it.SqlitePath} {it.Op} {name.Derive it.ParameterName}")
$"{it.Path SQLite} {it.Op} {p}min AND {p}max"
| _ -> $"{it.Path SQLite} {it.Op} {name.Derive it.ParameterName}")
|> String.concat (match howMatched with Any -> " OR " | All -> " AND ")
/// Create a WHERE clause fragment to implement a comparison on a field in a JSON document
@ -72,15 +72,10 @@ module Query =
/// Queries for counting documents
module Count =
/// Query to count all documents in a table
[<CompiledName "All">]
let all tableName =
$"SELECT COUNT(*) AS it FROM %s{tableName}"
/// Query to count matching documents using a text comparison on JSON fields
[<CompiledName "ByFields">]
let byFields tableName howMatched fields =
$"SELECT COUNT(*) AS it FROM %s{tableName} WHERE {whereByFields howMatched fields}"
Query.Count.byFields whereByFields tableName howMatched fields
/// Query to count matching documents using a text comparison on a JSON field
[<CompiledName "ByField">]
@ -361,13 +356,13 @@ module WithConn =
[<CompiledName "EnsureTable">]
let ensureTable name conn = backgroundTask {
do! Custom.nonQuery (Query.Definition.ensureTable name) [] conn
do! Custom.nonQuery (Query.Definition.ensureKey name) [] conn
do! Custom.nonQuery (Query.Definition.ensureKey name SQLite) [] conn
}
/// Create an index on a document table
[<CompiledName "EnsureFieldIndex">]
let ensureFieldIndex tableName indexName fields conn =
Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields) [] conn
Custom.nonQuery (Query.Definition.ensureIndexOn tableName indexName fields SQLite) [] conn
/// Insert a new document
[<CompiledName "Insert">]

View File

@ -185,50 +185,54 @@ public static class CommonCSharpTests
Expect.isSome(field.Qualifier, "The table qualifier should have been filled");
Expect.equal("joe", field.Qualifier.Value, "The table qualifier is incorrect");
}),
TestList("PgSqlPath",
TestList("Path",
[
TestCase("succeeds for a single field with no qualifier", () =>
TestCase("succeeds for a PostgreSQL single field with no qualifier", () =>
{
var field = Field.GE("SomethingCool", 18);
Expect.equal("data->>'SomethingCool'", field.PgSqlPath, "The PostgreSQL path is incorrect");
Expect.equal("data->>'SomethingCool'", field.Path(Dialect.PostgreSQL),
"The PostgreSQL path is incorrect");
}),
TestCase("succeeds for a single field with a qualifier", () =>
TestCase("succeeds for a PostgreSQL single field with a qualifier", () =>
{
var field = Field.LT("SomethingElse", 9).WithQualifier("this");
Expect.equal("this.data->>'SomethingElse'", field.PgSqlPath, "The PostgreSQL path is incorrect");
Expect.equal("this.data->>'SomethingElse'", field.Path(Dialect.PostgreSQL),
"The PostgreSQL path is incorrect");
}),
TestCase("succeeds for a nested field with no qualifier", () =>
TestCase("succeeds for a PostgreSQL nested field with no qualifier", () =>
{
var field = Field.EQ("My.Nested.Field", "howdy");
Expect.equal("data#>>'{My,Nested,Field}'", field.PgSqlPath, "The PostgreSQL path is incorrect");
Expect.equal("data#>>'{My,Nested,Field}'", field.Path(Dialect.PostgreSQL),
"The PostgreSQL path is incorrect");
}),
TestCase("succeeds for a nested field with a qualifier", () =>
TestCase("succeeds for a PostgreSQL nested field with a qualifier", () =>
{
var field = Field.EQ("Nest.Away", "doc").WithQualifier("bird");
Expect.equal("bird.data#>>'{Nest,Away}'", field.PgSqlPath, "The PostgreSQL path is incorrect");
})
]),
TestList("SqlitePath",
[
TestCase("succeeds for a single field with no qualifier", () =>
Expect.equal("bird.data#>>'{Nest,Away}'", field.Path(Dialect.PostgreSQL),
"The PostgreSQL path is incorrect");
}),
TestCase("succeeds for a SQLite single field with no qualifier", () =>
{
var field = Field.GE("SomethingCool", 18);
Expect.equal("data->>'SomethingCool'", field.SqlitePath, "The SQLite path is incorrect");
Expect.equal("data->>'SomethingCool'", field.Path(Dialect.SQLite), "The SQLite path is incorrect");
}),
TestCase("succeeds for a single field with a qualifier", () =>
TestCase("succeeds for a SQLite single field with a qualifier", () =>
{
var field = Field.LT("SomethingElse", 9).WithQualifier("this");
Expect.equal("this.data->>'SomethingElse'", field.SqlitePath, "The SQLite path is incorrect");
Expect.equal("this.data->>'SomethingElse'", field.Path(Dialect.SQLite),
"The SQLite path is incorrect");
}),
TestCase("succeeds for a nested field with no qualifier", () =>
TestCase("succeeds for a SQLite nested field with no qualifier", () =>
{
var field = Field.EQ("My.Nested.Field", "howdy");
Expect.equal("data->>'My'->>'Nested'->>'Field'", field.SqlitePath, "The SQLite path is incorrect");
Expect.equal("data->>'My'->>'Nested'->>'Field'", field.Path(Dialect.SQLite),
"The SQLite path is incorrect");
}),
TestCase("succeeds for a nested field with a qualifier", () =>
TestCase("succeeds for a SQLite nested field with a qualifier", () =>
{
var field = Field.EQ("Nest.Away", "doc").WithQualifier("bird");
Expect.equal("bird.data->>'Nest'->>'Away'", field.SqlitePath, "The SQLite path is incorrect");
Expect.equal("bird.data->>'Nest'->>'Away'", field.Path(Dialect.SQLite),
"The SQLite path is incorrect");
})
])
]),
@ -251,13 +255,13 @@ public static class CommonCSharpTests
[
TestCase("succeeds when a schema is present", () =>
{
Expect.equal(Query.Definition.EnsureKey("test.table"),
Expect.equal(Query.Definition.EnsureKey("test.table", Dialect.SQLite),
"CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON test.table ((data->>'Id'))",
"CREATE INDEX for key statement with schema not constructed correctly");
}),
TestCase("succeeds when a schema is not present", () =>
{
Expect.equal(Query.Definition.EnsureKey("table"),
Expect.equal(Query.Definition.EnsureKey("table", Dialect.PostgreSQL),
"CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON table ((data->>'Id'))",
"CREATE INDEX for key statement without schema not constructed correctly");
})
@ -266,7 +270,7 @@ public static class CommonCSharpTests
{
Expect.equal(
Query.Definition.EnsureIndexOn("test.table", "gibberish",
new[] { "taco", "guac DESC", "salsa ASC" }),
new[] { "taco", "guac DESC", "salsa ASC" }, Dialect.SQLite),
"CREATE INDEX IF NOT EXISTS idx_table_gibberish ON test.table "
+ "((data->>'taco'), (data->>'guac') DESC, (data->>'salsa') ASC)",
"CREATE INDEX for multiple field statement incorrect");
@ -281,7 +285,15 @@ public static class CommonCSharpTests
Expect.equal(Query.Save("tbl"),
"INSERT INTO tbl VALUES (@data) ON CONFLICT ((data->>'Id')) DO UPDATE SET data = EXCLUDED.data",
"INSERT ON CONFLICT UPDATE statement not correct");
})
}),
TestList("Count",
[
TestCase("All succeeds", () =>
{
Expect.equal(Query.Count.All("a_table"), $"SELECT COUNT(*) AS it FROM a_table",
"Count query not correct");
}),
])
])
]);
}

View File

@ -287,11 +287,6 @@ public static class PostgresCSharpTests
}),
TestList("Count",
[
TestCase("All succeeds", () =>
{
Expect.equal(Postgres.Query.Count.All(PostgresDb.TableName),
$"SELECT COUNT(*) AS it FROM {PostgresDb.TableName}", "Count query not correct");
}),
TestCase("ByFields succeeds", () =>
{
Expect.equal(

View File

@ -1,3 +1,4 @@
using BitBadger.Documents.Postgres;
using Npgsql;
using Npgsql.FSharp;
using ThrowawayDb.Postgres;
@ -131,7 +132,7 @@ public static class PostgresDb
var sqlProps = Sql.connect(database.ConnectionString);
Sql.executeNonQuery(Sql.query(Postgres.Query.Definition.EnsureTable(TableName), sqlProps));
Sql.executeNonQuery(Sql.query(Query.Definition.EnsureKey(TableName), sqlProps));
Sql.executeNonQuery(Sql.query(Query.Definition.EnsureKey(TableName, Dialect.PostgreSQL), sqlProps));
Postgres.Configuration.UseDataSource(MkDataSource(database.ConnectionString));

View File

@ -99,11 +99,6 @@ public static class SqliteCSharpTests
}),
TestList("Count",
[
TestCase("All succeeds", () =>
{
Expect.equal(Sqlite.Query.Count.All("tbl"), "SELECT COUNT(*) AS it FROM tbl",
"Count query not correct");
}),
#pragma warning disable CS0618
TestCase("ByField succeeds", () =>
{

View File

@ -119,40 +119,39 @@ let all =
Expect.isSome field.Qualifier "The table qualifier should have been filled"
Expect.equal "joe" field.Qualifier.Value "The table qualifier is incorrect"
}
testList "PgSqlPath" [
test "succeeds for a single field with no qualifier" {
testList "Path" [
test "succeeds for a PostgreSQL single field with no qualifier" {
let field = Field.GE "SomethingCool" 18
Expect.equal "data->>'SomethingCool'" field.PgSqlPath "The PostgreSQL path is incorrect"
Expect.equal "data->>'SomethingCool'" (field.Path PostgreSQL) "The PostgreSQL path is incorrect"
}
test "succeeds for a single field with a qualifier" {
test "succeeds for a PostgreSQL single field with a qualifier" {
let field = { Field.LT "SomethingElse" 9 with Qualifier = Some "this" }
Expect.equal "this.data->>'SomethingElse'" field.PgSqlPath "The PostgreSQL path is incorrect"
Expect.equal
"this.data->>'SomethingElse'" (field.Path PostgreSQL) "The PostgreSQL path is incorrect"
}
test "succeeds for a nested field with no qualifier" {
test "succeeds for a PostgreSQL nested field with no qualifier" {
let field = Field.EQ "My.Nested.Field" "howdy"
Expect.equal "data#>>'{My,Nested,Field}'" field.PgSqlPath "The PostgreSQL path is incorrect"
Expect.equal "data#>>'{My,Nested,Field}'" (field.Path PostgreSQL) "The PostgreSQL path is incorrect"
}
test "succeeds for a nested field with a qualifier" {
test "succeeds for a PostgreSQL nested field with a qualifier" {
let field = { Field.EQ "Nest.Away" "doc" with Qualifier = Some "bird" }
Expect.equal "bird.data#>>'{Nest,Away}'" field.PgSqlPath "The PostgreSQL path is incorrect"
Expect.equal "bird.data#>>'{Nest,Away}'" (field.Path PostgreSQL) "The PostgreSQL path is incorrect"
}
]
testList "SqlitePath" [
test "succeeds for a single field with no qualifier" {
test "succeeds for a SQLite single field with no qualifier" {
let field = Field.GE "SomethingCool" 18
Expect.equal "data->>'SomethingCool'" field.SqlitePath "The SQLite path is incorrect"
Expect.equal "data->>'SomethingCool'" (field.Path SQLite) "The SQLite path is incorrect"
}
test "succeeds for a single field with a qualifier" {
test "succeeds for a SQLite single field with a qualifier" {
let field = { Field.LT "SomethingElse" 9 with Qualifier = Some "this" }
Expect.equal "this.data->>'SomethingElse'" field.SqlitePath "The SQLite path is incorrect"
Expect.equal "this.data->>'SomethingElse'" (field.Path SQLite) "The SQLite path is incorrect"
}
test "succeeds for a nested field with no qualifier" {
test "succeeds for a SQLite nested field with no qualifier" {
let field = Field.EQ "My.Nested.Field" "howdy"
Expect.equal "data->>'My'->>'Nested'->>'Field'" field.SqlitePath "The SQLite path is incorrect"
Expect.equal "data->>'My'->>'Nested'->>'Field'" (field.Path SQLite) "The SQLite path is incorrect"
}
test "succeeds for a nested field with a qualifier" {
test "succeeds for a SQLite nested field with a qualifier" {
let field = { Field.EQ "Nest.Away" "doc" with Qualifier = Some "bird" }
Expect.equal "bird.data->>'Nest'->>'Away'" field.SqlitePath "The SQLite path is incorrect"
Expect.equal "bird.data->>'Nest'->>'Away'" (field.Path SQLite) "The SQLite path is incorrect"
}
]
]
@ -184,20 +183,21 @@ let all =
testList "ensureKey" [
test "succeeds when a schema is present" {
Expect.equal
(Query.Definition.ensureKey "test.table")
(Query.Definition.ensureKey "test.table" PostgreSQL)
"CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON test.table ((data->>'Id'))"
"CREATE INDEX for key statement with schema not constructed correctly"
}
test "succeeds when a schema is not present" {
Expect.equal
(Query.Definition.ensureKey "table")
(Query.Definition.ensureKey "table" SQLite)
"CREATE UNIQUE INDEX IF NOT EXISTS idx_table_key ON table ((data->>'Id'))"
"CREATE INDEX for key statement without schema not constructed correctly"
}
]
test "ensureIndexOn succeeds for multiple fields and directions" {
Expect.equal
(Query.Definition.ensureIndexOn "test.table" "gibberish" [ "taco"; "guac DESC"; "salsa ASC" ])
(Query.Definition.ensureIndexOn
"test.table" "gibberish" [ "taco"; "guac DESC"; "salsa ASC" ] PostgreSQL)
([ "CREATE INDEX IF NOT EXISTS idx_table_gibberish ON test.table "
"((data->>'taco'), (data->>'guac') DESC, (data->>'salsa') ASC)" ]
|> String.concat "")
@ -213,6 +213,18 @@ let all =
$"INSERT INTO {tbl} VALUES (@data) ON CONFLICT ((data->>'Id')) DO UPDATE SET data = EXCLUDED.data"
"INSERT ON CONFLICT UPDATE statement not correct"
}
testList "Count" [
test "all succeeds" {
Expect.equal (Query.Count.all "a_table") "SELECT COUNT(*) AS it FROM a_table"
"Count query not correct"
}
test "byFields succeeds" {
let test = fun _ _ -> "howdy"
Expect.equal
(Query.Count.byFields test "over_here" Any [])
"SELECT COUNT(*) AS it FROM over_here WHERE howdy"
"Count by fields query not correct"
}
]
]
]

View File

@ -259,12 +259,6 @@ let unitTests =
Expect.equal (Query.whereJsonPathMatches "@path") "data @? @path::jsonpath" "WHERE clause not correct"
}
testList "Count" [
test "all succeeds" {
Expect.equal
(Query.Count.all PostgresDb.TableName)
$"SELECT COUNT(*) AS it FROM {PostgresDb.TableName}"
"Count query not correct"
}
test "byFields succeeds" {
Expect.equal
(Query.Count.byFields "tbl" All [ Field.EQ "thatField" 0; Field.EQ "anotherField" 8])
@ -664,7 +658,7 @@ let integrationTests =
let! theCount = Count.all PostgresDb.TableName
Expect.equal theCount 5 "There should have been 5 matching documents"
}
testList "byFields" [
ptestList "byFields" [
testTask "succeeds when items are found" {
use db = PostgresDb.BuildDb()
do! loadDocs ()
@ -818,7 +812,7 @@ let integrationTests =
Expect.equal results [] "There should have been no documents returned"
}
]
ftestList "byId" [
testList "byId" [
testTask "succeeds when a document is found" {
use db = PostgresDb.BuildDb()
do! loadDocs ()
@ -845,7 +839,7 @@ let integrationTests =
PostgresDb.TableName All [ Field.EQ "Value" "purple"; Field.EX "Sub" ]
Expect.equal (List.length docs) 1 "There should have been one document returned"
}
testTask "succeeds when documents are not found" {
ptestTask "succeeds when documents are not found" {
use db = PostgresDb.BuildDb()
do! loadDocs ()

View File

@ -88,9 +88,6 @@ let unitTests =
"UPDATE full statement not correct"
}
testList "Count" [
test "all succeeds" {
Expect.equal (Query.Count.all "tbl") $"SELECT COUNT(*) AS it FROM tbl" "Count query not correct"
}
test "byField succeeds" {
Expect.equal
(Query.Count.byField "tbl" (Field.EQ "thatField" 0))