diff --git a/assets/search-index.json b/assets/search-index.json index d8d20699..91791d93 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -9654,7 +9654,7 @@ "title": "ZQL", "searchTitle": "ZQL", "url": "/docs/zql", - "content": "Inspired by SQL, ZQL is expressed in TypeScript with heavy use of the builder pattern. If you have used Drizzle or Kysely, ZQL will feel familiar. ZQL queries are composed of one or more clauses that are chained together into a query. Create a Builder To get started, use createBuilder. If you use drizzle-zero or prisma-zero, this happens automatically and an instance is stored in the zql constant exported from schema.ts: import {zql} from 'schema.ts' // zql.myTable.where(...) Otherwise, create an instance manually: // schema.ts // ... export const zql = createBuilder(schema) Select ZQL queries start by selecting a table. There is no way to select a subset of columns; ZQL queries always return the entire row, if permissions allow it. import {zql} from 'zero.ts' // Returns a query that selects all rows and columns from the // issue table. zql.issue This is a design tradeoff that allows Zero to better reuse the row locally for future queries. This also makes it easier to share types between different parts of the code. Do not directly modify JavaScript values returned from ZQL queries. ZQL caches values across queries to improve performance and reduce re-renders in frameworks like React and Solid. If you modify a value returned from ZQL, you will modify it everywhere it is used. This can lead to subtle bugs. JavaScript and TypeScript lack true immutable types so we use readonly to help enforce it. But it's easy to cast away the readonly accidentally. Ordering You can sort query results by adding an orderBy clause: zql.issue.orderBy('created', 'desc') Multiple orderBy clauses can be present, in which case the data is sorted by those clauses in order: // Order by priority descending. For any rows with same priority, // then order by created desc. zql.issue .orderBy('priority', 'desc') .orderBy('created', 'desc') All queries in ZQL have a default final order of their primary key. Assuming the issue table has a primary key on the id column, then: // Actually means: zql.issue.orderBy('id', 'asc'); zql.issue // Actually means: zql.issue.orderBy('priority', 'desc').orderBy('id', 'asc'); zql.issue.orderBy('priority', 'desc') Limit You can limit the number of rows to return with limit(): zql.issue.orderBy('created', 'desc').limit(100) Paging You can start the results at or after a particular row with start(): let start: IssueRow | undefined while (true) { let q = zql.issue .orderBy('created', 'desc') .limit(100) if (start) { q = q.start(start) } const batch = await q.run() console.log('got batch', batch) if (batch.length < 100) { break } start = batch[batch.length - 1] } By default start() is exclusive - it returns rows starting after the supplied reference row. This is what you usually want for paging. If you want inclusive results, you can do: zql.issue.start(row, {inclusive: true}) Getting a Single Result If you want exactly zero or one results, use the one() clause. This causes ZQL to return Row|undefined rather than Row[]. const result = await zql.issue .where('id', 42) .one() .run() if (!result) { console.error('not found') } one() overrides any limit() clause that is also present. Relationships You can query related rows using relationships that are defined in your Zero schema. // Get all issues and their related comments zql.issue.related('comments') Relationships are returned as hierarchical data. In the above example, each row will have a comments field, which is an array of the corresponding comments rows. You can fetch multiple relationships in a single query: zql.issue .related('comments') .related('reactions') .related('assignees') Refining Relationships By default all matching relationship rows are returned, but this can be refined. The related method accepts an optional second function which is itself a query. zql.issue.related( 'comments', // It is common to use the 'q' shorthand variable for this parameter, // but it is a _comment_ query in particular here, exactly as if you // had done zql.comment. q => q .orderBy('modified', 'desc') .limit(100) .start(lastSeenComment) ) This relationship query can have all the same clauses that top-level queries can have. Using orderBy or limit in a relationship that goes through a junction table (i.e., a many-to-many relationship) is not currently supported and will throw a runtime error. See bug 3527. You can sometimes work around this by making the junction relationship explicit, depending on your schema and usage. Nested Relationships You can nest relationships arbitrarily: // Get all issues, first 100 comments for each (ordered by modified,desc), // and for each comment all of its reactions. zql.issue.related('comments', q => q .orderBy('modified', 'desc') .limit(100) .related('reactions') ) Where You can filter a query with where(): zql.issue.where('priority', '=', 'high') The first parameter is always a column name from the table being queried. TypeScript completion will offer available options (sourced from your Zero Schema). Comparison Operators where() supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. Zero can evaluate an ILIKE filter in three places: in-memory on the client, in ZQLite against the replica, or as SQL in Postgres. ZQLite and the in-memory query engine use Unicode-aware lowercasing before matching. This means common non-ASCII case variants, such as MÜLLER and müller, match in both. If you need non-ASCII text in PG to work with ILIKE, use a Unicode-aware collation for those text columns, such as the database's default UTF-8 locale. Equals is the Default Comparison Operator Because comparing by = is so common, you can leave it out and where defaults to =. zql.issue.where('priority', 'high') Comparing to null As in SQL, ZQL’s null cannot be compared with =, !=, <, or any other normal comparison operator. Comparing any value to null with such operators is always false: These semantics feel a bit weird, but they are consistent with SQL. The reason SQL does it this way is to make join semantics work: if you’re joining employee.orgID on org.id you do not want an employee in no organization to match an org that hasn’t yet been assigned an ID. For when you purposely do want to compare to null ZQL supports IS and IS NOT operators that also work just like in SQL: // Find employees not in any org. zql.employee.where('orgID', 'IS', null) // Find employees in an org other than 42 OR employees in NO org zql.employee.where('orgID', 'IS NOT', 42) TypeScript will prevent you from comparing to null with other operators. Comparing to undefined As a convenience, you can pass undefined to where: zql.issue.where('priority', issue?.priority) This comparison is always false, so the above query always returns no results. Compound Filters The argument to where can also be a callback that returns a complex expression: // Get all issues that have priority 'critical' or else have both // priority 'medium' and not more than 100 votes. zql.issue.where(({cmp, and, or, not}) => or( cmp('priority', 'critical'), and( cmp('priority', 'medium'), not(cmp('numVotes', '>', 100)) ) ) ) cmp is short for compare and works the same as where at the top-level except that it can’t be chained and it only accepts comparison operators (no relationship filters – see below). Note that chaining where() is also a one-level and: // Find issues with priority 3 or higher, owned by aa zql.issue .where('priority', '>=', 3) .where('owner', 'aa') Comparing Literal Values The where clause always expects its first parameter to be a column name as a string. Same with the cmp helper: // \"foo\" is a column name, not a string: zql.issue.where('foo', 'bar') // \"foo\" is a column name, not a string: zql.issue.where(({cmp}) => cmp('foo', 'bar')) To compare to a literal value, use the cmpLit helper: zql.issue.where(cmpLit('foobar', 'foo' + 'bar')) This is particularly useful for implementing permissions, because the first parameter can be a field of your context: zql.issue.where(cmpLit(ctx.role, 'admin')) Relationship Filters Your filter can also test properties of relationships. Currently the only supported test is existence: // Find all orgs that have at least one employee zql.organization.whereExists('employees') The argument to whereExists is a relationship, so just like other relationships, it can be refined with a query: // Find all orgs that have at least one cool employee zql.organization.whereExists('employees', q => q.where('location', 'Hawaii') ) As with querying relationships, relationship filters can be arbitrarily nested: // Get all issues that have comments that have reactions zql.issue.whereExists('comments', q => q.whereExists('reactions') ) The exists helper is also provided which can be used with and, or, cmp, and not to build compound filters that check relationship existence: // Find issues that have at least one comment or are high priority zql.issue.where({cmp, or, exists} => or( cmp('priority', 'high'), exists('comments'), ), ) Type Helpers You can get the TypeScript type of the result of a query using the QueryResultType helper: import type {QueryResultType} from '@rocicorp/zero' const complexQuery = zql.issue.related( 'comments', q => q.related('author') ) type MyComplexResult = QueryResultType // MyComplexResult is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // }[] You can get the type of a single row with QueryRowType: import type {QueryRowType} from '@rocicorp/zero' type MySingleRow = QueryRowType // MySingleRow is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // } Planning Zero automatically plans queries, selecting the best indexes and join orders in most cases. Inspecting Query Plans You can inspect the plan that Zero generates for any ZQL query using the inspector. Manually Flipping Joins The process Zero uses to optimize joins is called \"join flipping\", because it involves \"flipping\" the order of joins to minimize the number of rows processed. Typically the Zero planner will pick the joins to flip automatically. But in some rare cases, you may want to manually specify the join order. This can be done by passing the flip:true option to whereExists: // Find the first 100 documents that user 42 can edit, // ordered by created desc. Because each user is an editor // of only a few documents, flip:true is much faster than // flip:false. zql.documents.whereExists('editors', e => e.where('userID', 42), {flip: true} ), .orderBy('created', 'desc') .limit(100) Or with exists: // Find issues created by user 42 or that have a comment // by user 42. Because user 42 has commented on only a // few issues, flip:true is much faster than flip:false. zql.issue.where({cmp, or, exists} => or( cmp('creatorID', 42), exists('comments', c => c.where('creatorID', 42), {flip: true}), ), ) You can manually flip just one or a few of the whereExists clauses in a query, leaving the rest to be planned automatically. Scalar Subqueries Scalar subqueries are an optimization for exists queries. Instead of doing a join at query time, Zero pre-resolves the subquery and rewrites it as a simple equality check. To use scalar subqueries, add a unique index in Postgres and declare it as unique in the Zero schema, then add {scalar: true} to your whereExists call: // Instead of joining to find issues where project.name = 'zero' // Zero resolves this server-side to: where('projectId', '123') zql.issue.whereExists( 'project', q => q.where('name', 'zero'), {scalar: true} ) Or with exists: zql.issue.where({cmp, exists} => exists('project', q => q.where('name', 'zero'), {scalar: true}, ), ) Why It Matters Joins are expensive. Sometimes they are needed, but for something like \"give me all issues where the owner's name is Alice\", you don't need a full join — you just need Alice's ID. The scalar optimization pre-fetches that ID and rewrites your query as where('ownerId', aliceId). This can improve query performance significantly. It also allows planning to work better. Since the ID is known at planning time, Zero/SQLite can choose better indexes. Trade-offs The query needs to be \"rehydrated\" (re-run) whenever the scalar subquery result changes. This is fine for relatively stable lookup data like user IDs or project IDs, but you probably wouldn't want it for rapidly-changing data. Scalar subqueries only work when the subquery is guaranteed to return at most one row. The callback must constrain every column of a primary key or a key declared with .unique(...) using equality. Compound keys require an equality constraint for every column. They also only support one-hop relationships, not junctions. Future Work Scalar subqueries are not currently integrated with Zero's planner. You need to manually choose when to use them.", + "content": "Inspired by SQL, ZQL is expressed in TypeScript with heavy use of the builder pattern. If you have used Drizzle or Kysely, ZQL will feel familiar. ZQL queries are composed of one or more clauses that are chained together into a query. Create a Builder To get started, use createBuilder. If you use drizzle-zero or prisma-zero, this happens automatically and an instance is stored in the zql constant exported from schema.ts: import {zql} from 'schema.ts' // zql.myTable.where(...) Otherwise, create an instance manually: // schema.ts // ... export const zql = createBuilder(schema) Select ZQL queries start by selecting a table. There is no way to select a subset of columns; ZQL queries always return the entire row, if permissions allow it. import {zql} from 'zero.ts' // Returns a query that selects all rows and columns from the // issue table. zql.issue This is a design tradeoff that allows Zero to better reuse the row locally for future queries. This also makes it easier to share types between different parts of the code. Do not directly modify JavaScript values returned from ZQL queries. ZQL caches values across queries to improve performance and reduce re-renders in frameworks like React and Solid. If you modify a value returned from ZQL, you will modify it everywhere it is used. This can lead to subtle bugs. JavaScript and TypeScript lack true immutable types so we use readonly to help enforce it. But it's easy to cast away the readonly accidentally. Ordering You can sort query results by adding an orderBy clause: zql.issue.orderBy('created', 'desc') Multiple orderBy clauses can be present, in which case the data is sorted by those clauses in order: // Order by priority descending. For any rows with same priority, // then order by created desc. zql.issue .orderBy('priority', 'desc') .orderBy('created', 'desc') All queries in ZQL have a default final order of their primary key. Assuming the issue table has a primary key on the id column, then: // Actually means: zql.issue.orderBy('id', 'asc'); zql.issue // Actually means: zql.issue.orderBy('priority', 'desc').orderBy('id', 'asc'); zql.issue.orderBy('priority', 'desc') Limit You can limit the number of rows to return with limit(): zql.issue.orderBy('created', 'desc').limit(100) Paging You can start the results at or after a particular row with start(): let start: IssueRow | undefined while (true) { let q = zql.issue .orderBy('created', 'desc') .limit(100) if (start) { q = q.start(start) } const batch = await q.run() console.log('got batch', batch) if (batch.length < 100) { break } start = batch[batch.length - 1] } By default start() is exclusive - it returns rows starting after the supplied reference row. This is what you usually want for paging. If you want inclusive results, you can do: zql.issue.start(row, {inclusive: true}) Getting a Single Result If you want exactly zero or one results, use the one() clause. This causes ZQL to return Row|undefined rather than Row[]. const result = await zql.issue .where('id', 42) .one() .run() if (!result) { console.error('not found') } one() overrides any limit() clause that is also present. Relationships You can query related rows using relationships that are defined in your Zero schema. // Get all issues and their related comments zql.issue.related('comments') Relationships are returned as hierarchical data. In the above example, each row will have a comments field, which is an array of the corresponding comments rows. You can fetch multiple relationships in a single query: zql.issue .related('comments') .related('reactions') .related('assignees') Refining Relationships By default all matching relationship rows are returned, but this can be refined. The related method accepts an optional second function which is itself a query. zql.issue.related( 'comments', // It is common to use the 'q' shorthand variable for this parameter, // but it is a _comment_ query in particular here, exactly as if you // had done zql.comment. q => q .orderBy('modified', 'desc') .limit(100) .start(lastSeenComment) ) This relationship query can have all the same clauses that top-level queries can have. Using orderBy or limit in a relationship that goes through a junction table (i.e., a many-to-many relationship) is not currently supported and will throw a runtime error. See bug 3527. You can sometimes work around this by making the junction relationship explicit, depending on your schema and usage. Nested Relationships You can nest relationships arbitrarily: // Get all issues, first 100 comments for each (ordered by modified,desc), // and for each comment all of its reactions. zql.issue.related('comments', q => q .orderBy('modified', 'desc') .limit(100) .related('reactions') ) Where You can filter a query with where(): zql.issue.where('priority', '=', 'high') The first parameter is always a column name from the table being queried. TypeScript completion will offer available options (sourced from your Zero Schema). Comparison Operators where() supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. Zero can evaluate an ILIKE filter in three places: in-memory on the client, in ZQLite against the replica, or as SQL in Postgres. ZQLite and the in-memory query engine use Unicode-aware lowercasing before matching. This means common non-ASCII case variants, such as MÜLLER and müller, match in both. If you need non-ASCII text in PG to work with ILIKE, use a Unicode-aware collation for those text columns, such as the database's default UTF-8 locale. Equals is the Default Comparison Operator Because comparing by = is so common, you can leave it out and where defaults to =. zql.issue.where('priority', 'high') Comparing to null As in SQL, ZQL’s null cannot be compared with =, !=, <, or any other normal comparison operator. Comparing any value to null with such operators is always false: These semantics feel a bit weird, but they are consistent with SQL. The reason SQL does it this way is to make join semantics work: if you’re joining employee.orgID on org.id you do not want an employee in no organization to match an org that hasn’t yet been assigned an ID. For when you purposely do want to compare to null ZQL supports IS and IS NOT operators that also work just like in SQL: // Find employees not in any org. zql.employee.where('orgID', 'IS', null) // Find employees in an org other than 42 OR employees in NO org zql.employee.where('orgID', 'IS NOT', 42) TypeScript will prevent you from comparing to null with other operators. Comparing to undefined As a convenience, you can pass undefined to where: zql.issue.where('priority', issue?.priority) This comparison is always false, so the above query always returns no results. Compound Filters The argument to where can also be a callback that returns a complex expression: // Get all issues that have priority 'critical' or else have both // priority 'medium' and not more than 100 votes. zql.issue.where(({cmp, and, or, not}) => or( cmp('priority', 'critical'), and( cmp('priority', 'medium'), not(cmp('numVotes', '>', 100)) ) ) ) cmp is short for compare and works the same as where at the top-level except that it can’t be chained and it only accepts comparison operators (no relationship filters – see below). Note that chaining where() is also a one-level and: // Find issues with priority 3 or higher, owned by aa zql.issue .where('priority', '>=', 3) .where('owner', 'aa') Comparing Literal Values The where clause always expects its first parameter to be a column name as a string. Same with the cmp helper: // \"foo\" is a column name, not a string: zql.issue.where('foo', 'bar') // \"foo\" is a column name, not a string: zql.issue.where(({cmp}) => cmp('foo', 'bar')) To compare to a literal value, use the cmpLit helper: zql.issue.where(cmpLit('foobar', 'foo' + 'bar')) This is particularly useful for implementing permissions, because the first parameter can be a field of your context: zql.issue.where(cmpLit(ctx.role, 'admin')) Relationship Filters Your filter can also test properties of relationships. Currently the only supported test is existence: // Find all orgs that have at least one employee zql.organization.whereExists('employees') The argument to whereExists is a relationship, so just like other relationships, it can be refined with a query: // Find all orgs that have at least one cool employee zql.organization.whereExists('employees', q => q.where('location', 'Hawaii') ) As with querying relationships, relationship filters can be arbitrarily nested: // Get all issues that have comments that have reactions zql.issue.whereExists('comments', q => q.whereExists('reactions') ) The exists helper is also provided which can be used with and, or, cmp, and not to build compound filters that check relationship existence: // Find issues that have at least one comment or are high priority zql.issue.where({cmp, or, exists} => or( cmp('priority', 'high'), exists('comments'), ), ) Aggregates You can compute count(), sum(), avg(), min(), or max() over a query. Instead of rows, the query returns a single value: // The number of high priority issues zql.issue.where('priority', 'high').count() // The most votes any issue has zql.issue.max('numVotes') Aggregates also work on relationships. Each row then gets the aggregate value in place of the array of related rows: // Each issue has a `comments` field holding its number of comments zql.issue.related('comments', q => q.count()) Zero computes aggregates on the server and syncs only the result, so counting thousands of comments doesn't download them. The value stays up to date as rows change. where(), start(), and limit() apply before the aggregate, just like in SQL, so they have to come before it: zql.issue.orderBy('created', 'desc').limit(10).avg('numVotes') averages the ten newest issues. sum() and avg() take numeric columns, while min() and max() also accept string columns. Over no rows, count() returns 0 and the others return null. To aggregate a query defined with defineQuery(), put the aggregate in its definition. Adding one to the query it returns, as in queries.issues().count(), throws: export const queries = defineQueries({ highPriorityCount: defineQuery(() => zql.issue.where('priority', 'high').count() ) }) When a mutator adds or removes related rows, a relationship's count, sum, or average updates right away, as long as the relationship doesn't go through a junction table or use limit(), start(), or whereExists(). Other aggregates update once the server has run the mutation. Inside a mutator, tx.run() reads an aggregate's synced value if the app also syncs that query, and otherwise computes it from the rows on the client, which can be fewer than the server has. Distinct distinct() returns one row for each distinct combination of the columns you name, as SQL's SELECT DISTINCT does. The rows have only those columns. Following it with count() counts the combinations instead: // The people who created a high priority issue: [{creatorID: 'u1'}, …] zql.issue.where('priority', 'high').distinct('creatorID') // How many of them there are zql.issue .where('priority', 'high') .distinct('creatorID') .count() null is a value like any other: issues with no creator give one row with creatorID: null, and count() counts it as one value. That differs from SQL's COUNT(DISTINCT creatorID), which skips NULL, but it always matches the number of rows distinct() returns. The rows are ordered by the distinct columns, ascending unless orderBy() says otherwise, and orderBy() can only name those columns. Group By groupBy() computes an aggregate for each group of rows that share the values of the columns you name, as SQL's GROUP BY does. It has to be followed by count(), sum(), avg(), min(), or max(), and each row holds the group's columns and the aggregate's value under the function's name: // [{creatorID: 'u1', count: 12}, {creatorID: null, count: 3}, …] zql.issue .where('priority', 'high') .groupBy('creatorID') .count() // [{creatorID: 'u1', priority: 'high', sum: 40}, …] zql.issue.groupBy('creatorID', 'priority').sum('numVotes') The aggregates work as they do without groupBy(), and a group whose values are all null gets null from sum(), avg(), min(), and max(). As with distinct(), rows with a null value form a group of their own, and the rows can only be ordered by the grouped columns. Ordering groups by their aggregate, such as the creators with the most issues first, isn't supported yet. Neither distinct() nor groupBy() can be combined with limit(), start(), one(), related(), or another aggregate yet, and neither can group by a JSON column. They can't be used inside related() or whereExists() either, so counting each issue's distinct commenters isn't possible yet. As with other aggregates, a query defined with defineQuery() has to include them in its definition. When a mutator changes rows, the rows of a distinct() and the values of a groupBy() with count(), sum(), or avg() update right away, as long as the query's where() doesn't use whereExists(). A groupBy() with min() or max(), and distinct().count(), update once the server has run the mutation. Type Helpers You can get the TypeScript type of the result of a query using the QueryResultType helper: import type {QueryResultType} from '@rocicorp/zero' const complexQuery = zql.issue.related( 'comments', q => q.related('author') ) type MyComplexResult = QueryResultType // MyComplexResult is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // }[] You can get the type of a single row with QueryRowType: import type {QueryRowType} from '@rocicorp/zero' type MySingleRow = QueryRowType // MySingleRow is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // } Planning Zero automatically plans queries, selecting the best indexes and join orders in most cases. Inspecting Query Plans You can inspect the plan that Zero generates for any ZQL query using the inspector. Manually Flipping Joins The process Zero uses to optimize joins is called \"join flipping\", because it involves \"flipping\" the order of joins to minimize the number of rows processed. Typically the Zero planner will pick the joins to flip automatically. But in some rare cases, you may want to manually specify the join order. This can be done by passing the flip:true option to whereExists: // Find the first 100 documents that user 42 can edit, // ordered by created desc. Because each user is an editor // of only a few documents, flip:true is much faster than // flip:false. zql.documents.whereExists('editors', e => e.where('userID', 42), {flip: true} ), .orderBy('created', 'desc') .limit(100) Or with exists: // Find issues created by user 42 or that have a comment // by user 42. Because user 42 has commented on only a // few issues, flip:true is much faster than flip:false. zql.issue.where({cmp, or, exists} => or( cmp('creatorID', 42), exists('comments', c => c.where('creatorID', 42), {flip: true}), ), ) You can manually flip just one or a few of the whereExists clauses in a query, leaving the rest to be planned automatically. Scalar Subqueries Scalar subqueries are an optimization for exists queries. Instead of doing a join at query time, Zero pre-resolves the subquery and rewrites it as a simple equality check. To use scalar subqueries, add a unique index in Postgres and declare it as unique in the Zero schema, then add {scalar: true} to your whereExists call: // Instead of joining to find issues where project.name = 'zero' // Zero resolves this server-side to: where('projectId', '123') zql.issue.whereExists( 'project', q => q.where('name', 'zero'), {scalar: true} ) Or with exists: zql.issue.where({cmp, exists} => exists('project', q => q.where('name', 'zero'), {scalar: true}, ), ) Why It Matters Joins are expensive. Sometimes they are needed, but for something like \"give me all issues where the owner's name is Alice\", you don't need a full join — you just need Alice's ID. The scalar optimization pre-fetches that ID and rewrites your query as where('ownerId', aliceId). This can improve query performance significantly. It also allows planning to work better. Since the ID is known at planning time, Zero/SQLite can choose better indexes. Trade-offs The query needs to be \"rehydrated\" (re-run) whenever the scalar subquery result changes. This is fine for relatively stable lookup data like user IDs or project IDs, but you probably wouldn't want it for rapidly-changing data. Scalar subqueries only work when the subquery is guaranteed to return at most one row. The callback must constrain every column of a primary key or a key declared with .unique(...) using equality. Compound keys require an equality constraint for every column. They also only support one-hop relationships, not junctions. Future Work Scalar subqueries are not currently integrated with Zero's planner. You need to manually choose when to use them.", "headings": [ { "text": "Create a Builder", @@ -9724,6 +9724,18 @@ "text": "Relationship Filters", "id": "relationship-filters" }, + { + "text": "Aggregates", + "id": "aggregates" + }, + { + "text": "Distinct", + "id": "distinct" + }, + { + "text": "Group By", + "id": "group-by" + }, { "text": "Type Helpers", "id": "type-helpers" @@ -9930,7 +9942,37 @@ "kind": "section" }, { - "id": "730-zql#type-helpers", + "id": "730-zql#aggregates", + "title": "ZQL", + "searchTitle": "Aggregates", + "sectionTitle": "Aggregates", + "sectionId": "aggregates", + "url": "/docs/zql", + "content": "You can compute count(), sum(), avg(), min(), or max() over a query. Instead of rows, the query returns a single value: // The number of high priority issues zql.issue.where('priority', 'high').count() // The most votes any issue has zql.issue.max('numVotes') Aggregates also work on relationships. Each row then gets the aggregate value in place of the array of related rows: // Each issue has a `comments` field holding its number of comments zql.issue.related('comments', q => q.count()) Zero computes aggregates on the server and syncs only the result, so counting thousands of comments doesn't download them. The value stays up to date as rows change. where(), start(), and limit() apply before the aggregate, just like in SQL, so they have to come before it: zql.issue.orderBy('created', 'desc').limit(10).avg('numVotes') averages the ten newest issues. sum() and avg() take numeric columns, while min() and max() also accept string columns. Over no rows, count() returns 0 and the others return null. To aggregate a query defined with defineQuery(), put the aggregate in its definition. Adding one to the query it returns, as in queries.issues().count(), throws: export const queries = defineQueries({ highPriorityCount: defineQuery(() => zql.issue.where('priority', 'high').count() ) }) When a mutator adds or removes related rows, a relationship's count, sum, or average updates right away, as long as the relationship doesn't go through a junction table or use limit(), start(), or whereExists(). Other aggregates update once the server has run the mutation. Inside a mutator, tx.run() reads an aggregate's synced value if the app also syncs that query, and otherwise computes it from the rows on the client, which can be fewer than the server has. Distinct distinct() returns one row for each distinct combination of the columns you name, as SQL's SELECT DISTINCT does. The rows have only those columns. Following it with count() counts the combinations instead: // The people who created a high priority issue: [{creatorID: 'u1'}, …] zql.issue.where('priority', 'high').distinct('creatorID') // How many of them there are zql.issue .where('priority', 'high') .distinct('creatorID') .count() null is a value like any other: issues with no creator give one row with creatorID: null, and count() counts it as one value. That differs from SQL's COUNT(DISTINCT creatorID), which skips NULL, but it always matches the number of rows distinct() returns. The rows are ordered by the distinct columns, ascending unless orderBy() says otherwise, and orderBy() can only name those columns. Group By groupBy() computes an aggregate for each group of rows that share the values of the columns you name, as SQL's GROUP BY does. It has to be followed by count(), sum(), avg(), min(), or max(), and each row holds the group's columns and the aggregate's value under the function's name: // [{creatorID: 'u1', count: 12}, {creatorID: null, count: 3}, …] zql.issue .where('priority', 'high') .groupBy('creatorID') .count() // [{creatorID: 'u1', priority: 'high', sum: 40}, …] zql.issue.groupBy('creatorID', 'priority').sum('numVotes') The aggregates work as they do without groupBy(), and a group whose values are all null gets null from sum(), avg(), min(), and max(). As with distinct(), rows with a null value form a group of their own, and the rows can only be ordered by the grouped columns. Ordering groups by their aggregate, such as the creators with the most issues first, isn't supported yet. Neither distinct() nor groupBy() can be combined with limit(), start(), one(), related(), or another aggregate yet, and neither can group by a JSON column. They can't be used inside related() or whereExists() either, so counting each issue's distinct commenters isn't possible yet. As with other aggregates, a query defined with defineQuery() has to include them in its definition. When a mutator changes rows, the rows of a distinct() and the values of a groupBy() with count(), sum(), or avg() update right away, as long as the query's where() doesn't use whereExists(). A groupBy() with min() or max(), and distinct().count(), update once the server has run the mutation.", + "kind": "section" + }, + { + "id": "731-zql#distinct", + "title": "ZQL", + "searchTitle": "Distinct", + "sectionTitle": "Distinct", + "sectionId": "distinct", + "url": "/docs/zql", + "content": "distinct() returns one row for each distinct combination of the columns you name, as SQL's SELECT DISTINCT does. The rows have only those columns. Following it with count() counts the combinations instead: // The people who created a high priority issue: [{creatorID: 'u1'}, …] zql.issue.where('priority', 'high').distinct('creatorID') // How many of them there are zql.issue .where('priority', 'high') .distinct('creatorID') .count() null is a value like any other: issues with no creator give one row with creatorID: null, and count() counts it as one value. That differs from SQL's COUNT(DISTINCT creatorID), which skips NULL, but it always matches the number of rows distinct() returns. The rows are ordered by the distinct columns, ascending unless orderBy() says otherwise, and orderBy() can only name those columns.", + "kind": "section" + }, + { + "id": "732-zql#group-by", + "title": "ZQL", + "searchTitle": "Group By", + "sectionTitle": "Group By", + "sectionId": "group-by", + "url": "/docs/zql", + "content": "groupBy() computes an aggregate for each group of rows that share the values of the columns you name, as SQL's GROUP BY does. It has to be followed by count(), sum(), avg(), min(), or max(), and each row holds the group's columns and the aggregate's value under the function's name: // [{creatorID: 'u1', count: 12}, {creatorID: null, count: 3}, …] zql.issue .where('priority', 'high') .groupBy('creatorID') .count() // [{creatorID: 'u1', priority: 'high', sum: 40}, …] zql.issue.groupBy('creatorID', 'priority').sum('numVotes') The aggregates work as they do without groupBy(), and a group whose values are all null gets null from sum(), avg(), min(), and max(). As with distinct(), rows with a null value form a group of their own, and the rows can only be ordered by the grouped columns. Ordering groups by their aggregate, such as the creators with the most issues first, isn't supported yet. Neither distinct() nor groupBy() can be combined with limit(), start(), one(), related(), or another aggregate yet, and neither can group by a JSON column. They can't be used inside related() or whereExists() either, so counting each issue's distinct commenters isn't possible yet. As with other aggregates, a query defined with defineQuery() has to include them in its definition. When a mutator changes rows, the rows of a distinct() and the values of a groupBy() with count(), sum(), or avg() update right away, as long as the query's where() doesn't use whereExists(). A groupBy() with min() or max(), and distinct().count(), update once the server has run the mutation.", + "kind": "section" + }, + { + "id": "733-zql#type-helpers", "title": "ZQL", "searchTitle": "Type Helpers", "sectionTitle": "Type Helpers", @@ -9940,7 +9982,7 @@ "kind": "section" }, { - "id": "731-zql#planning", + "id": "734-zql#planning", "title": "ZQL", "searchTitle": "Planning", "sectionTitle": "Planning", @@ -9950,7 +9992,7 @@ "kind": "section" }, { - "id": "732-zql#inspecting-query-plans", + "id": "735-zql#inspecting-query-plans", "title": "ZQL", "searchTitle": "Inspecting Query Plans", "sectionTitle": "Inspecting Query Plans", @@ -9960,7 +10002,7 @@ "kind": "section" }, { - "id": "733-zql#manually-flipping-joins", + "id": "736-zql#manually-flipping-joins", "title": "ZQL", "searchTitle": "Manually Flipping Joins", "sectionTitle": "Manually Flipping Joins", @@ -9970,7 +10012,7 @@ "kind": "section" }, { - "id": "734-zql#scalar-subqueries", + "id": "737-zql#scalar-subqueries", "title": "ZQL", "searchTitle": "Scalar Subqueries", "sectionTitle": "Scalar Subqueries", @@ -9980,7 +10022,7 @@ "kind": "section" }, { - "id": "735-zql#why-it-matters", + "id": "738-zql#why-it-matters", "title": "ZQL", "searchTitle": "Why It Matters", "sectionTitle": "Why It Matters", @@ -9990,7 +10032,7 @@ "kind": "section" }, { - "id": "736-zql#trade-offs", + "id": "739-zql#trade-offs", "title": "ZQL", "searchTitle": "Trade-offs", "sectionTitle": "Trade-offs", @@ -10000,7 +10042,7 @@ "kind": "section" }, { - "id": "737-zql#future-work", + "id": "740-zql#future-work", "title": "ZQL", "searchTitle": "Future Work", "sectionTitle": "Future Work", diff --git a/contents/docs/zql.mdx b/contents/docs/zql.mdx index 6bce3748..69c0a4f4 100644 --- a/contents/docs/zql.mdx +++ b/contents/docs/zql.mdx @@ -372,6 +372,79 @@ zql.issue.where({cmp, or, exists} => ) ``` +## Aggregates + +You can compute `count()`, `sum()`, `avg()`, `min()`, or `max()` over a query. Instead of rows, the query returns a single value: + +```tsx +// The number of high priority issues +zql.issue.where('priority', 'high').count() + +// The most votes any issue has +zql.issue.max('numVotes') +``` + +Aggregates also work on relationships. Each row then gets the aggregate value in place of the array of related rows: + +```tsx +// Each issue has a `comments` field holding its number of comments +zql.issue.related('comments', q => q.count()) +``` + +Zero computes aggregates on the server and syncs only the result, so counting thousands of comments doesn't download them. The value stays up to date as rows change. `where()`, `start()`, and `limit()` apply before the aggregate, just like in SQL, so they have to come before it: `zql.issue.orderBy('created', 'desc').limit(10).avg('numVotes')` averages the ten newest issues. + +`sum()` and `avg()` take numeric columns, while `min()` and `max()` also accept string columns. Over no rows, `count()` returns `0` and the others return `null`. + +To aggregate a query defined with [`defineQuery()`](./queries), put the aggregate in its definition. Adding one to the query it returns, as in `queries.issues().count()`, throws: + +```ts +export const queries = defineQueries({ + highPriorityCount: defineQuery(() => + zql.issue.where('priority', 'high').count() + ) +}) +``` + +When a mutator adds or removes related rows, a relationship's count, sum, or average updates right away, as long as the relationship doesn't go through a junction table or use `limit()`, `start()`, or `whereExists()`. Other aggregates update once the server has run the mutation. Inside a mutator, `tx.run()` reads an aggregate's synced value if the app also syncs that query, and otherwise computes it from the rows on the client, which can be fewer than the server has. + +### Distinct + +`distinct()` returns one row for each distinct combination of the columns you name, as SQL's `SELECT DISTINCT` does. The rows have only those columns. Following it with `count()` counts the combinations instead: + +```tsx +// The people who created a high priority issue: [{creatorID: 'u1'}, …] +zql.issue.where('priority', 'high').distinct('creatorID') + +// How many of them there are +zql.issue + .where('priority', 'high') + .distinct('creatorID') + .count() +``` + +`null` is a value like any other: issues with no creator give one row with `creatorID: null`, and `count()` counts it as one value. That differs from SQL's `COUNT(DISTINCT creatorID)`, which skips `NULL`, but it always matches the number of rows `distinct()` returns. The rows are ordered by the distinct columns, ascending unless `orderBy()` says otherwise, and `orderBy()` can only name those columns. + +### Group By + +`groupBy()` computes an aggregate for each group of rows that share the values of the columns you name, as SQL's `GROUP BY` does. It has to be followed by `count()`, `sum()`, `avg()`, `min()`, or `max()`, and each row holds the group's columns and the aggregate's value under the function's name: + +```tsx +// [{creatorID: 'u1', count: 12}, {creatorID: null, count: 3}, …] +zql.issue + .where('priority', 'high') + .groupBy('creatorID') + .count() + +// [{creatorID: 'u1', priority: 'high', sum: 40}, …] +zql.issue.groupBy('creatorID', 'priority').sum('numVotes') +``` + +The aggregates work as they do without `groupBy()`, and a group whose values are all `null` gets `null` from `sum()`, `avg()`, `min()`, and `max()`. As with `distinct()`, rows with a `null` value form a group of their own, and the rows can only be ordered by the grouped columns. Ordering groups by their aggregate, such as the creators with the most issues first, isn't supported yet. + +Neither `distinct()` nor `groupBy()` can be combined with `limit()`, `start()`, `one()`, `related()`, or another aggregate yet, and neither can group by a JSON column. They can't be used inside `related()` or `whereExists()` either, so counting each issue's distinct commenters isn't possible yet. As with other aggregates, a query defined with `defineQuery()` has to include them in its definition. + +When a mutator changes rows, the rows of a `distinct()` and the values of a `groupBy()` with `count()`, `sum()`, or `avg()` update right away, as long as the query's `where()` doesn't use `whereExists()`. A `groupBy()` with `min()` or `max()`, and `distinct().count()`, update once the server has run the mutation. + ## Type Helpers You can get the TypeScript type of the result of a query using the `QueryResultType` helper: