diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index ef6760b..bb55f6b 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -5,6 +5,9 @@ on: branches: ["main", "master"] paths: - "docs/**" + pull_request: + paths: + - "docs/**" workflow_dispatch: jobs: diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 0000000..ffc49a3 --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,13 @@ +{ + "tabWidth": 4, + "useTabs": false, + "printWidth": 100, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/docs/Count-Records.md b/docs/Count-Records.md index fcf8e51..e1c3fe0 100644 --- a/docs/Count-Records.md +++ b/docs/Count-Records.md @@ -3,7 +3,8 @@ title: Count Records description: Count the number of records matching a set of predicates. category: CRUD tags: [count, count records] -order: 5 +categoryOrder: 2 +order: 6 --- # Count Records @@ -32,9 +33,9 @@ usersCount, err := db.User. ### Supported Builder Methods -| Method | Description | -| --- | --- | -| [`Take`](/docs/Take) | Limit the number of records included in the count. Mirrors SQL's `LIMIT`. | +| Method | Description | +| -------------------- | -------------------------------------------------------------------------- | +| [`Take`](/docs/Take) | Limit the number of records included in the count. Mirrors SQL's `LIMIT`. | | [`Skip`](/docs/Skip) | Skip a number of matching records before counting. Mirrors SQL's `OFFSET`. | -> **Note:** `Take` and `Skip` affect the result of the count. For example, if 100 records match a predicate and you call `Take(10)`, the returned count will be `10`, not `100`. \ No newline at end of file +> **Note:** `Take` and `Skip` affect the result of the count. For example, if 100 records match a predicate and you call `Take(10)`, the returned count will be `10`, not `100`. diff --git a/docs/Create-Records.md b/docs/Create-Records.md index 02abeaf..ef5619b 100644 --- a/docs/Create-Records.md +++ b/docs/Create-Records.md @@ -56,10 +56,10 @@ created, err := db.User.Create(). ### Supported Builder Methods -| Method | Description | -| --- | --- | -| [`Select`](/docs/Select) | Select specific fields and relations. Mutually exclusive with `Omit`. | -| [`Omit`](/docs/Omit) | Omit specific scalar fields. Mutually exclusive with `Select`. | +| Method | Description | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| [`Select`](/docs/Select) | Select specific fields and relations. Mutually exclusive with `Omit`. | +| [`Omit`](/docs/Omit) | Omit specific scalar fields. Mutually exclusive with `Select`. | | [`OnConflict`](/docs/Upsert-Records#onconflict) | Configure behavior when a unique constraint is violated. Supports `.Ignore()`, `.UpdateNewValues()`, and `.Update()`. | --- @@ -105,10 +105,10 @@ createdCount, err := db.User.CreateMany(usersToCreate...).Exec(ctx) ### Supported Builder Methods -| Method | Description | -| --- | --- | +| Method | Description | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | [`OnConflict`](/docs/Upsert-Records#onconflict) | Configure behavior when a unique constraint is violated. Supports `.Ignore()`, `.UpdateNewValues()`, and `.Update()`. | -| [`SkipDuplicates`](/docs/SkipDuplicates) | Shorthand for `.OnConflict().Ignore()`. | +| [`SkipDuplicates`](/docs/SkipDuplicates) | Shorthand for `.OnConflict().Ignore()`. | --- @@ -151,9 +151,9 @@ createdUsers, err := db.User.CreateManyAndReturn(usersToCreate...).Exec(ctx) ### Supported Builder Methods -| Method | Description | -| --- | --- | -| [`Select`](/docs/Select) | Select specific fields and relations. Mutually exclusive with `Omit`. | -| [`Omit`](/docs/Omit) | Omit specific scalar fields. Mutually exclusive with `Select`. | +| Method | Description | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| [`Select`](/docs/Select) | Select specific fields and relations. Mutually exclusive with `Omit`. | +| [`Omit`](/docs/Omit) | Omit specific scalar fields. Mutually exclusive with `Select`. | | [`OnConflict`](/docs/Upsert-Records#onconflict) | Configure behavior when a unique constraint is violated. Supports `.Ignore()`, `.UpdateNewValues()`, and `.Update()`. | -| [`SkipDuplicates`](/docs/SkipDuplicates) | Shorthand for `.OnConflict().Ignore()`. | \ No newline at end of file +| [`SkipDuplicates`](/docs/SkipDuplicates) | Shorthand for `.OnConflict().Ignore()`. | diff --git a/docs/Cursor.md b/docs/Cursor.md new file mode 100644 index 0000000..0ded3b4 --- /dev/null +++ b/docs/Cursor.md @@ -0,0 +1,71 @@ +--- +title: Cursor +description: Fetch pages of results relative to a known record, identified by a unique predicate. +category: Pagination & Sort +tags: [cursor, pagination, keyset] +categoryOrder: 5 +order: 4 +--- + +# Cursor + +`Cursor` implements cursor-based (keyset) pagination. Instead of skipping an arbitrary number of rows like [`Skip`](/docs/Skip), you position the query _after_ a specific record, identified by a unique predicate such as `user.Email.EQ(...)` or `user.Id.EQ(...)`. + +This approach stays stable under concurrent inserts and updates and is far more efficient on large datasets than offset pagination. + +`Cursor` can be used with `FindFirst`, `FindMany`, and nested relation query builders. It is typically combined with [`OrderBy`](/docs/OrderBy) and [`Take`](/docs/Take). + +## Fetching the Next Page + +Combine a cursor with a positive [`Take`](/docs/Take) to fetch the page following a known record: + +```go +nextPage, err := db.User. + FindMany(user.Email.Contains("@example.com")). + OrderBy(user.Email.Asc()). + Cursor(user.Email.EQ(lastEmail)). + Take(20). + Exec(ctx) +``` + +This returns the next 20 records after `lastEmail`. + +## Fetching the Previous Page + +Combine a cursor with a negative [`Take`](/docs/Take) to fetch the records that precede it: + +```go +prevPage, err := db.User. + FindMany(user.Email.Contains("@example.com")). + OrderBy(user.Email.Asc()). + Cursor(user.Email.EQ(firstEmailOfCurrentPage)). + Take(-20). + Exec(ctx) +``` + +> **Note:** The cursor value must reference a unique field, such as the record's `Id` or another unique column. + +## Usage Tips + +- Always provide a stable [`OrderBy`](/docs/OrderBy); it must be consistent from page to page. +- Pass the last record of the current page as the cursor for the next page, and the first record for the previous page. +- Use [`Skip`](/docs/Skip) together with a cursor when you want to jump a fixed number of rows past the cursor. + +## Supported By + +- [`FindMany`](/docs/Read-Records#findmany) +- [`FindFirst`](/docs/Read-Records#findfirst) +- Nested relation queries via [`Select`](/docs/Select) + +## Resulting SQL + +A cursor query compiles into a key-based comparison on the cursor's column, combined with the ordering and limit: + +```sql +SELECT * FROM "users" +WHERE "email" > 'last@example.com' +ORDER BY "email" ASC +LIMIT 20; +``` + +A negative [`Take`](/docs/Take) swaps the comparison direction to fetch preceding rows. diff --git a/docs/Delete-Records.md b/docs/Delete-Records.md new file mode 100644 index 0000000..4ec7e0c --- /dev/null +++ b/docs/Delete-Records.md @@ -0,0 +1,130 @@ +--- +title: Delete Records +description: Delete one or more records using type-safe delete builders. +category: CRUD +tags: [delete, deleteMany] +categoryOrder: 2 +order: 4 +--- + +# Delete Records + +Phi provides two methods for deleting records: + +- `Delete` +- `DeleteMany` + +> **Note:** `Delete` always returns the deleted record, regardless of the underlying database. On databases that support `DELETE ... RETURNING`, Phi performs the deletion in a single query. On databases that don't, Phi transparently executes the operation inside a transaction by fetching the record before deleting it, ensuring consistent behavior across all supported SQL dialects. + +--- + +## Delete + +Deletes a **single record**. + +The first predicate **must** uniquely identify a record (for example, `Id.EQ()` or another unique field). Additional predicates may be supplied to further constrain the deletion. + +By default, all scalar fields are returned. Use `Select` or `Omit` to customize the returned data. + +### Basic + +```go +user, err := db.User.Delete( + user.Email.EQ("x@y.com"), +).Exec(ctx) +``` + +### With Additional Predicates + +```go +user, err := db.User.Delete( + user.Email.EQ("x@y.com"), + user.Bio.Contains("golang"), +).Exec(ctx) +``` + +### Returning Selected Fields + +```go +user, err := db.User.Delete( + user.Id.EQ(id), +). + Select(user.Select{ + Id: true, + Username: true, + }). + Exec(ctx) +``` + +### Omitting Fields + +```go +user, err := db.User.Delete( + user.Id.EQ(id), +). + Omit(user.Omit{ + Password: true, + }). + Exec(ctx) +``` + +### Supported Builder Methods + +| Method | Description | +| ------------------------ | ------------------------------------------------- | +| [`Select`](/docs/Select) | Return only the selected fields and relations. | +| [`Omit`](/docs/Omit) | Return all scalar fields except the omitted ones. | + +--- + +## DeleteMany + +Deletes **all records** matching the supplied predicates. + +Unlike `Delete`, no unique predicate is required. + +`DeleteMany` returns the number of rows deleted. + +### Basic + +```go +deleted, err := db.User.DeleteMany( + user.Bio.Contains("inactive"), +).Exec(ctx) +``` + +### Multiple Predicates + +```go +deleted, err := db.User.DeleteMany( + user.Email.HasSuffix("@example.com"), + user.LoginCount.LT(5), +).Exec(ctx) +``` + +### Using Logical Predicates + +```go +deleted, err := db.User.DeleteMany( + user.Or( + user.Bio.Contains("spam"), + user.PhoneNum.HasPrefix("+999"), + ), +).Exec(ctx) +``` + +### Return Value + +```go +deleted, err := db.User.DeleteMany( + user.Bio.Contains("inactive"), +).Exec(ctx) + +fmt.Printf("Deleted %d users\n", deleted) +``` + +### Supported Builder Methods + +`DeleteMany` exposes no additional builder methods beyond its predicates. + +> **Note:** `DeleteMany` only returns the number of deleted rows. If you need the deleted records themselves, query them before deleting. diff --git a/docs/Omit.md b/docs/Omit.md new file mode 100644 index 0000000..cbc441b --- /dev/null +++ b/docs/Omit.md @@ -0,0 +1,44 @@ +--- +title: Omit +description: Omit specific scalar fields from a query result. +category: Select & Omit +tags: [omit, omit fields, scalar selection] +categoryOrder: 3 +order: 2 +--- + +# Omit + +`Omit` specifies which scalar fields should be excluded from a query result. + +Unlike `Select`, `Omit` only applies to scalar fields. Relations are never loaded unless explicitly selected with `Select`. + +## Omitting Scalar Fields + +Omit scalar fields by setting their corresponding field to `true`. Set a field to `false` (or leave it unset) to include it in the result. + +```go +users, err := db.User. + FindMany(user.Email.EQ("x@y.com")). + Omit(user.Omit{ + Id: true, + Email: true, + Bio: true, + }). + Exec(ctx) +``` + +> **Note:** `Select` and `Omit` are mutually exclusive. Attempting to use both on the same query will result in an error. + +## Supported By + +`Omit` is supported by: + +- [`Create`](/docs/Create-Records#create) +- [`CreateManyAndReturn`](/docs/Create-Records#createmanyandreturn) +- [`FindUnique`](/docs/Read-Records#findunique) +- [`FindFirst`](/docs/Read-Records#findfirst) +- [`FindMany`](/docs/Read-Records#findmany) +- [`Update`](/docs/Update-Records#update) +- [`UpdateManyAndReturn`](/docs/Update-Records#updatemanyandreturn) +- [`Delete`](/docs/Delete-Records#delete) \ No newline at end of file diff --git a/docs/OrderBy.md b/docs/OrderBy.md new file mode 100644 index 0000000..aa88a5f --- /dev/null +++ b/docs/OrderBy.md @@ -0,0 +1,85 @@ +--- +title: Order By +description: Sort query results using Asc or Desc on any scalar field. Mirrors SQL's ORDER BY. +category: Pagination & Sort +tags: [order, sort, asc, desc] +categoryOrder: 5 +order: 3 +--- + +# Order By + +`OrderBy` sorts the returned records using the generated `.Asc()` and `.Desc()` methods available on every scalar field. It mirrors SQL's `ORDER BY`. + +`OrderBy` can be used with `FindFirst`, `FindMany`, and nested relation query builders. + +## Sorting Ascending + +```go +users, err := db.User. + FindMany(user.Bio.Contains("golang")). + OrderBy(user.CreatedAt.Asc()). + Exec(ctx) +``` + +## Sorting Descending + +```go +users, err := db.User. + FindMany(user.Bio.Contains("golang")). + OrderBy(user.CreatedAt.Desc()). + Exec(ctx) +``` + +## Sort by Multiple Columns + +Pass multiple columns to `OrderBy`. The first column takes precedence, then the next, and so on: + +```go +users, err := db.User. + FindMany(user.Bio.Contains("golang")). + OrderBy( + user.Role.Asc(), + user.CreatedAt.Desc(), + ). + Exec(ctx) +``` + +## Ordering Nested Relations + +`OrderBy` is also available on nested to-many relation query builders within a [`Select`](/docs/Select): + +```go +users, err := db.User. + FindMany(user.Email.Contains("@example.com")). + Select(user.Select{ + Id: true, + Posts: post.Query(). + Where(post.Published.EQ(true)). + OrderBy(post.CreatedAt.Desc()). + Select(post.Select{ + Id: true, + Title: true, + }), + }). + Exec(ctx) +``` + +> **Note:** When using cursor-based pagination, `OrderBy` must be supplied and should be consistent across pages for stable results. + +> **Note:** On cursor queries, Phi appends the model's primary key columns to the `ORDER BY` clause when no unique column is present. If you provide no `OrderBy` at all, it orders solely by the primary key. If the provided columns aren't unique, the primary key is appended as a tie-breaker to keep pagination deterministic. Appended columns follow the same direction as the query (ascending for forward pages, descending for negative takes). + +## Supported By + +- [`FindMany`](/docs/Read-Records#findmany) +- [`FindFirst`](/docs/Read-Records#findfirst) +- Nested relation queries via [`Select`](/docs/Select) + +## Resulting SQL + +Each `.Asc()` / `.Desc()` maps to a column in the `ORDER BY` clause: + +```sql +SELECT * FROM "users" +ORDER BY "role" ASC, "createdAt" DESC; +``` diff --git a/docs/Query-Filters.md b/docs/Query-Filters.md new file mode 100644 index 0000000..af77a62 --- /dev/null +++ b/docs/Query-Filters.md @@ -0,0 +1,260 @@ +--- +title: Query Filters +description: The predicate system for filtering records with EQ, NEQ, GT, LT, In, Contains, Like, and logical operators. +category: Query Filters +tags: [query, filters, eq, neq, gt, lt, in, contains, like, and, or, not, predicates] +categoryOrder: 4 +order: 1 +--- + +# Query Filters + +Every read, update, and delete operation accepts zero or more _predicates_ that narrow down which records are affected. Predicates are generated per model and live in each model's package (for example `user.Email.EQ(...)`). + +The available operators depend on the field's scalar type: + +- Every scalar field exposes the comparison and set operators. +- String fields additionally expose the string-matching operators. +- Optional fields expose null checks via `IsNull` and `IsNotNull`. +- Array fields expose `Has`, `HasEvery`, and `HasSome`. + +The same predicate types are passed to `FindUnique`, `FindFirst`, `FindMany`, `Update`, `UpdateMany`, `Delete`, `DeleteMany`, and `Count`. + +Use the following Prisma schema as reference: + +```prisma +enum UserRole { + ADMIN + STUDENT + TEACHER +} + +model User { + id String @id @default(cuid()) + email String @unique + phoneNum String? + loginCount Int + bio String? + role UserRole? + tags String[] +} +``` + +## EQ + +Matches records where a field equals a value. On unique fields, `EQ` returns a `UniquePredicate` and can also be used as a cursor. + +```go +users, err := db.User.FindMany(user.Email.EQ("x@y.com")).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "email" = 'x@y.com'; +``` + +## NEQ + +Matches records where a field is not equal to a value. + +```go +users, err := db.User.FindMany(user.Bio.NEQ("spam")).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "bio" != 'spam'; +``` + +## GT / GTE + +Matches records where a field is greater than - or greater than or equal to - a value. + +```go +users, err := db.User.FindMany(user.LoginCount.GT(10)).Exec(ctx) +users, err := db.User.FindMany(user.LoginCount.GTE(10)).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "loginCount" > 10; +SELECT * FROM "users" WHERE "loginCount" >= 10; +``` + +## LT / LTE + +Matches records where a field is less than - or less than or equal to - a value. + +```go +users, err := db.User.FindMany(user.LoginCount.LT(10)).Exec(ctx) +users, err := db.User.FindMany(user.LoginCount.LTE(10)).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "loginCount" < 10; +SELECT * FROM "users" WHERE "loginCount" <= 10; +``` + +## In / NotIn + +Matches records where a field is (or is not) one of a list of values. + +```go +users, err := db.User.FindMany( + user.Role.In([]phi.UserRoleType{phi.UserRole_ADMIN, phi.UserRole_TEACHER}), +).Exec(ctx) +users, err := db.User.FindMany(user.Email.NotIn([]string{"a@b.com", "c@d.com"})).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "role" IN ('ADMIN', 'TEACHER'); +SELECT * FROM "users" WHERE "email" NOT IN ('a@b.com', 'c@d.com'); +``` + +## Between + +Matches records where a field falls within an inclusive range. + +```go +users, err := db.User.FindMany(user.LoginCount.Between(5, 100)).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "loginCount" BETWEEN 5 AND 100; +``` + +## Contains + +Matches string fields that contain the given substring. This maps to SQL's `LIKE '%value%'`. + +```go +users, err := db.User.FindMany(user.Bio.Contains("golang")).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "bio" LIKE '%golang%'; +``` + +## HasPrefix / HasSuffix + +Matches string fields that begin or end with a given substring. + +```go +users, err := db.User.FindMany(user.Email.HasPrefix("admin")).Exec(ctx) +users, err := db.User.FindMany(user.Email.HasSuffix("@example.com")).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "email" LIKE 'admin%'; +SELECT * FROM "users" WHERE "email" LIKE '%@example.com'; +``` + +## Like / ILike + +Provides raw `LIKE` pattern matching. Use `%` as a wildcard. `ILike` performs a case-insensitive match on the underlying database. + +```go +users, err := db.User.FindMany(user.Email.Like("%@example.com")).Exec(ctx) +users, err := db.User.FindMany(user.Bio.ILike("%golang%")).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "email" LIKE '%@example.com'; +-- Case-insensitive, e.g. uses "email" ILIKE '%golang%' on PostgreSQL +SELECT * FROM "users" WHERE "bio" ILIKE '%golang%'; +``` + +> **Note:** Dialects vary. PostgreSQL supports a native `ILIKE`, so `ILike` maps directly to `col ILIKE val`. SQLite has no `ILIKE` operator, so Phi emulates it with `LOWER(col) LIKE LOWER(val)` to get the same case-insensitive behavior. + +## IsNull / IsNotNull + +Match records where an optional field is null or not null. + +```go +users, err := db.User.FindMany(user.Bio.IsNull()).Exec(ctx) +users, err := db.User.FindMany(user.Bio.IsNotNull()).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "bio" IS NULL; +SELECT * FROM "users" WHERE "bio" IS NOT NULL; +``` + +## And + +Combines predicates so that all of them must match; it compiles to the same `AND` in SQL. + +```go +users, err := db.User.FindMany( + user.And( + user.LoginCount.GTE(10), + user.Email.HasSuffix("@example.com"), + ), +).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "loginCount" >= 10 AND "email" LIKE '%@example.com'; +``` + +## Multiple Predicates Are AND-ed + +Query methods accept zero or more predicates, so you can usually pass conditions directly without `And`. Every predicate passed to a method is combined with `AND` in the generated SQL: + +```go +users, err := db.User.FindMany( + user.LoginCount.GTE(10), + user.Email.HasSuffix("@example.com"), +).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "loginCount" >= 10 AND "email" LIKE '%@example.com'; +``` + +The two produce identical SQL. Choose based on what you are doing: + +- **Method arguments** (`FindMany(p1, p2)`) - the simplest, most readable way to flatten a set of `AND` conditions. Use this for everyday filtering. +- **`user.And(p1, p2, ...)`** - bundles several predicates into a single value you can store, reuse, or pass as one argument. Use this when a condition needs to be shared across queries, built at runtime, passed into a helper, or embedded inside a larger boolean expression such as `user.Or(user.And(a, b), user.And(c, d))`. Because it returns one predicate, you can nest it inside `And`, `Or`, or `Not`, which require individual predicate values. + +## Or + +Combines predicates so that any one of them may match. + +```go +users, err := db.User.FindMany( + user.Or( + user.Role.EQ(phi.UserRole_ADMIN), + user.LoginCount.GTE(100), + ), +).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE "role" = 'ADMIN' OR "loginCount" >= 100; +``` + +## Not + +Negates a single predicate. + +```go +users, err := db.User.FindMany( + user.Not(user.Role.EQ(phi.UserRole_ADMIN)), +).Exec(ctx) +``` + +```sql +SELECT * FROM "users" WHERE NOT ("role" = 'ADMIN'); +``` + +## Array Fields + +Array fields (such as `tags String[]`) expose containment operators: + +- `Has(value)` - the array contains the value. +- `HasEvery(values)` - the array contains every value. +- `HasSome(values)` - the array contains at least one value. + +```go +users, err := db.User.FindMany(user.Tags.HasEvery([]string{"go", "grpc"})).Exec(ctx) +``` + +> **Note:** Predicates are compiled after validation. Any predicate that fails validation produces an error instead of running an invalid query. diff --git a/docs/Quickstart.md b/docs/Quickstart.md new file mode 100644 index 0000000..9f75d01 --- /dev/null +++ b/docs/Quickstart.md @@ -0,0 +1,241 @@ +--- +title: Quickstart +description: Install the Phi CLI, create a project, define a schema, migrate, and generate the type-safe Go ORM client. +category: quickstart +tags: [quickstart, install, setup] +categoryOrder: 1 + +--- + +# Phi Documentation + +## Quickstart + +### Install the Phi CLI + +```bash +go install github.com/voidclancy/phi@latest +``` + +--- + +## Create a New Project + +Initialize a Go module if you don't already have one. + +```bash +mkdir demo +cd demo +go mod init demo +``` + +--- + +## Initialize Phi + +Generate a configuration file: + +```bash +phi init [directory] +``` + +By default, this creates a **`phi.yml`** file. + +To generate a specific format: + +**JSON** + +```bash +phi init json [directory] +``` + +**YAML** + +```bash +phi init yml [directory] +``` + +Example `phi.yml`: + +```yaml +database: + url_env: DATABASE_URL # Environment variable for the client connection. + direct_url_env: DATABASE_DIRECT_URL # Environment variable used for migrations. + +schema: ./schema.prisma # Path to your Prisma schema. + +client_name: phi # Name of the generated Go package. + +output: + client: ./phi + migrations: ./phi/migrations # Keep this inside the client directory if you plan to embed migrations. + +log: + - none # Available: none, all, query, warn, error +``` + +--- + +## Define Your Schema + +Create a `schema.prisma` file. + +```prisma +datasource db { + provider = "sqlite" +} + +model User { + id String @id @default(cuid()) + username String @unique + email String @unique + bio String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +> **Tip:** Install the Prisma Language Server extension for syntax highlighting and autocompletion. + +--- + +## Create a Migration + +Generate and apply a migration: + +```bash +phi migrate +``` + +or + +```bash +phi -m +``` + +This command will: + +* Create the database if it doesn't exist. +* Generate a migration. +* Apply the migration. + +--- + +## Generate the Client + +Generate a type-safe client from your schema: + +```bash +phi generate +``` + +or + +```bash +phi -g +``` + +The client is generated in the directory specified by your configuration file. + +Run this command whenever your schema changes. + +--- + +# Usage + +Create a `main.go` file. + +### `main.go` + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + + _ "github.com/mattn/go-sqlite3" + + "demo/phi" + "demo/phi/user" +) + +func main() { + if err := run(); err != nil { + panic(err) + } +} + +func run() error { + db, err := phi.Open( + "sqlite3", + "file:memdb1?mode=memory&cache=shared&_pragma=foreign_keys(1)&_time_format=sqlite", + ) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + db.Raw().SetMaxOpenConns(10) + + ctx := context.Background() + + // Create a user. + createdUser, err := db.User.Create(). + SetUsername("VoidClancy"). + SetEmail("x@y.com"). + SetBio("super cool bio"). + Exec(ctx) + if err != nil { + return err + } + + result, _ := json.MarshalIndent(createdUser, "", " ") + fmt.Printf("created user:\n%s\n", result) + + // Find the user. + foundUser, err := db.User.FindUnique( + user.Id.EQ(createdUser.Id), + ).Exec(ctx) + if err != nil { + return err + } + + result, _ = json.MarshalIndent(foundUser, "", " ") + fmt.Printf("retrieved user:\n%s\n", result) + + if foundUser.Bio != nil { + fmt.Printf("User bio: %s\n", *foundUser.Bio) + } + + return nil +} +``` + +### Example output + +```text +created user: +{ + "id": "cmf8z6qp40000a1b2c3d4e5f6", + "username": "VoidClancy", + "email": "x@y.com", + "bio": "super cool bio", + "createdAt": "2026-07-30T12:48:16.314Z", + "updatedAt": "2026-07-30T12:48:16.314Z" +} + +retrieved user: +{ + "id": "cmf8z6qp40000a1b2c3d4e5f6", + "username": "VoidClancy", + "email": "x@y.com", + "bio": "super cool bio", + "createdAt": "2026-07-30T12:48:16.314Z", + "updatedAt": "2026-07-30T12:48:16.314Z" +} + +User bio: super cool bio +``` diff --git a/docs/Read-Records.md b/docs/Read-Records.md index 3bc3838..b61bde7 100644 --- a/docs/Read-Records.md +++ b/docs/Read-Records.md @@ -1,8 +1,9 @@ --- title: Read Records -description: Learn how to query records using FindUnique, FindFirst, and FindMany. +description: Query records using FindUnique, FindFirst, and FindMany. category: CRUD tags: [read, query, findUnique, findFirst, findMany] +categoryOrder: 2 order: 2 --- @@ -16,9 +17,11 @@ Phi provides three methods for reading records: All three methods support the same predicate system and share the same selection API. +> **Note:** Every predicate passed to a query method is combined with `AND` in the compiled SQL. To build more complex boolean logic, bundle conditions together with [`And`](/docs/Query-Filters#and), [`Or`](/docs/Query-Filters#or), or [`Not`](/docs/Query-Filters#not). + ## FindUnique -Retrieves a single record by a required unique predicate. Additional predicates may be provided to further constrain the query, but the first argument **must** be a unique predicate. +Retrieves a single record by a required unique predicate. The additional predicates passed after the first argument are ANDed into the query: ### Examples @@ -53,10 +56,10 @@ user, err := db.User. ### Supported Builder Methods -| Method | Description | -| --- | --- | +| Method | Description | +| ------------------------ | ------------------------------------- | | [`Select`](/docs/Select) | Select specific fields and relations. | -| [`Omit`](/docs/Omit) | Omit specific scalar fields. | +| [`Omit`](/docs/Omit) | Omit specific scalar fields. | --- @@ -97,14 +100,14 @@ user, err := db.User. ### Supported Builder Methods -| Method | Description | -| --- | --- | -| [`Select`](/docs/Select) | Select specific fields and relations. | -| [`Omit`](/docs/Omit) | Omit specific scalar fields. | -| [`Skip`](/docs/Skip) | Skip a number of matching records. Mirrors SQL's `OFFSET`. | -| [`Take`](/docs/Take) | Limit the number of returned records. Mirrors SQL's `LIMIT`. | -| [`OrderBy`](/docs/OrderBy) | Specify the ordering of results. Mirrors SQL's `ORDER BY`. | -| [`Cursor`](/docs/Cursor) | Continue querying from a specific cursor. | +| Method | Description | +| -------------------------- | ------------------------------------------------------------ | +| [`Select`](/docs/Select) | Select specific fields and relations. | +| [`Omit`](/docs/Omit) | Omit specific scalar fields. | +| [`Skip`](/docs/Skip) | Skip a number of matching records. Mirrors SQL's `OFFSET`. | +| [`Take`](/docs/Take) | Limit the number of returned records. Mirrors SQL's `LIMIT`. | +| [`OrderBy`](/docs/OrderBy) | Specify the ordering of results. Mirrors SQL's `ORDER BY`. | +| [`Cursor`](/docs/Cursor) | Continue querying from a specific cursor. | --- @@ -145,11 +148,11 @@ users, err := db.User. ### Supported Builder Methods -| Method | Description | -| --- | --- | -| [`Select`](/docs/Select) | Select specific fields and relations. | -| [`Omit`](/docs/Omit) | Omit specific scalar fields. | -| [`Skip`](/docs/Skip) | Skip a number of matching records. Mirrors SQL's `OFFSET`. | -| [`Take`](/docs/Take) | Limit the number of returned records. Mirrors SQL's `LIMIT`. | -| [`OrderBy`](/docs/OrderBy) | Specify the ordering of results. Mirrors SQL's `ORDER BY`. | -| [`Cursor`](/docs/Cursor) | Continue querying from a specific cursor. | \ No newline at end of file +| Method | Description | +| -------------------------- | ------------------------------------------------------------ | +| [`Select`](/docs/Select) | Select specific fields and relations. | +| [`Omit`](/docs/Omit) | Omit specific scalar fields. | +| [`Skip`](/docs/Skip) | Skip a number of matching records. Mirrors SQL's `OFFSET`. | +| [`Take`](/docs/Take) | Limit the number of returned records. Mirrors SQL's `LIMIT`. | +| [`OrderBy`](/docs/OrderBy) | Specify the ordering of results. Mirrors SQL's `ORDER BY`. | +| [`Cursor`](/docs/Cursor) | Continue querying from a specific cursor. | diff --git a/docs/Select.md b/docs/Select.md index 3e1bfb3..9d530e3 100644 --- a/docs/Select.md +++ b/docs/Select.md @@ -2,8 +2,18 @@ title: Select description: Select specific fields and relations to return from a query. category: Select & Omit -tags: [select, omit, scalar selection, relational selection, nested selection, query selection, omit, omit fields, omit relations] -categoryOrder: 200 +tags: + [ + select, + omit, + scalar selection, + relational selection, + nested selection, + query selection, + omit fields, + omit relations, + ] +categoryOrder: 3 order: 1 --- @@ -59,6 +69,24 @@ users, err := db.User. Exec(ctx) ``` +To-many relation fields also accept the relation's `Select` struct directly. Passing a plain struct - instead of a query builder - selects **all** matching related records, without any filtering, ordering, or pagination: + +```go +users, err := db.User. + FindMany(user.Email.EQ("x@y.com")). + Select(user.Select{ + Id: true, + + Posts: &post.Select{ + Id: true, + Title: true, + }, + }). + Exec(ctx) +``` + +> **Note:** Relation fields are not scalar values. To-one relations hold a pointer to the relation's `Select` struct (for example `*profile.Select`), and to-many relations hold a select query - either the relation's `Select` struct (as shown above) or its query builder. You must assign one of these, never `true`. + Nested selections can be composed to any depth. ```go @@ -91,12 +119,13 @@ users, err := db.User. ### Selection Rules - Scalar fields are selected using boolean values. -- To-one relations are selected using their generated `Select` struct. -- To-many relations are selected using their generated query builder. +- To-one relations are selected with a pointer to their generated `Select` struct (`*profile.Select`). +- To-many relations are selected with either their generated query builder (for filtering, ordering, and pagination) or their `Select` struct directly (which selects all related records). - Selections can be nested to any depth. -> **Note:** If `Select` is omitted (or an empty `Select` struct is provided), Phi returns all scalar fields by default. Relations are never loaded unless explicitly selected. +> **Note:** `Select` and `Omit` are mutually exclusive. Attempting to use both on the same query will result in an error. +> **Note:** If `Select` is omitted (or an empty `Select` struct is provided), Phi returns all scalar fields by default. Relations are never loaded unless explicitly selected. ## Supported By @@ -109,4 +138,4 @@ users, err := db.User. - [`FindMany`](/docs/Read-Records#findmany) - [`Update`](/docs/Update-Records#update) - [`UpdateManyAndReturn`](/docs/Update-Records#updatemanyandreturn) -- [`Delete`](/docs/Delete-Records#delete) \ No newline at end of file +- [`Delete`](/docs/Delete-Records#delete) diff --git a/docs/Skip.md b/docs/Skip.md new file mode 100644 index 0000000..0b28411 --- /dev/null +++ b/docs/Skip.md @@ -0,0 +1,64 @@ +--- +title: Skip +description: Skip a number of matching records before returning results. Mirrors SQL's OFFSET. +category: Pagination & Sort +tags: [skip, offset, pagination] +categoryOrder: 5 +order: 2 +--- + +# Skip + +`Skip` discards a number of matching records before returning the remaining ones. It mirrors SQL's `OFFSET`. + +`Skip` can be used with `FindFirst`, `FindMany`, and `Count`. + +## Skipping Records + +Pass a non-negative offset to ignore the first `N` matching records: + +```go +users, err := db.User. + FindMany(user.Bio.Contains("golang")). + Skip(20). + Take(10). + Exec(ctx) +``` + +This returns records 21 through 30 of the matching set. + +## Combining With a Cursor + +When combined with a [`Cursor`](/docs/Cursor), `Skip` applies _after_ the cursor has positioned the query, discarding the next `N` records: + +```go +users, err := db.User. + FindMany(user.Email.Contains("@example.com")). + OrderBy(user.Email.Asc()). + Cursor(user.Email.EQ(lastEmail)). + Skip(10). + Take(10). + Exec(ctx) +``` + +This returns the 10 records that follow the cursor after skipping the next 10. + +## Supported By + +- [`FindMany`](/docs/Read-Records#findmany) +- [`FindFirst`](/docs/Read-Records#findfirst) +- [`Count`](/docs/Count-Records#count) + +## Resulting SQL + +`Skip(20)` maps to SQL's `OFFSET`. On databases that require a `LIMIT` alongside it, Phi emits `LIMIT -1`: + +```sql +SELECT * FROM "users" LIMIT -1 OFFSET 20; +``` + +With [`Take`](/docs/Take) it becomes a standard `LIMIT ... OFFSET` pair: + +```sql +SELECT * FROM "users" LIMIT 10 OFFSET 20; +``` diff --git a/docs/Take.md b/docs/Take.md new file mode 100644 index 0000000..3d86bfc --- /dev/null +++ b/docs/Take.md @@ -0,0 +1,66 @@ +--- +title: Take +description: Limit the number of records returned by a query. Mirrors SQL's LIMIT. +category: Pagination & Sort +tags: [take, limit, pagination] +categoryOrder: 5 +order: 1 +--- + +# Take + +`Take` limits the number of records returned by a query. It mirrors SQL's `LIMIT`. + +A positive value returns the first `N` matching records. A negative value combined with a [`Cursor`](/docs/Cursor) walks backward from that cursor, returning the `N` records that precede it. + +`Take` can be used with `FindFirst`, `FindMany`, and `Count`. + +## Limiting Results + +Pass a positive value to cap the number of rows returned: + +```go +users, err := db.User. + FindMany(user.Bio.Contains("golang")). + Take(10). + Exec(ctx) +``` + +## Walking Backward With a Negative Take + +Pass a negative value to return the records _before_ a cursor. This is useful for implementing "previous page" navigation in cursor-based pagination. + +```go +prevPage, err := db.User. + FindMany(user.Email.Contains("@example.com")). + OrderBy(user.Email.Asc()). + Cursor(user.Email.EQ(lastEmail)). + Take(-10). + Exec(ctx) +``` + +The results are returned in the configured ordering direction (ascending order in this example). + +> **Note:** For `FindFirst`, any negative `Take` behaves like `Take(-1)` - it returns the single record immediately preceding the cursor. + +## Supported By + +- [`FindMany`](/docs/Read-Records#findmany) +- [`FindFirst`](/docs/Read-Records#findfirst) +- [`Count`](/docs/Count-Records#count) + +## Resulting SQL + +A positive `Take(10)` maps to SQL's `LIMIT`: + +```sql +SELECT * FROM "users" LIMIT 10; +``` + +When combined with [`Skip`](/docs/Skip), the two become `LIMIT ... OFFSET ...`: + +```sql +SELECT * FROM "users" LIMIT 10 OFFSET 20; +``` + +Negative takes are handled internally with `LIMIT -1` so each database merely returns records preceding the cursor; Phi reverses the result to restore ordering. diff --git a/docs/Update-Records.md b/docs/Update-Records.md new file mode 100644 index 0000000..628a35c --- /dev/null +++ b/docs/Update-Records.md @@ -0,0 +1,224 @@ +--- +title: Update Records +description: Update one or more records using type-safe update builders. +category: CRUD +tags: [update, updateMany, updateManyAndReturn] +categoryOrder: 2 +order: 3 +--- + +# Update Records + +Phi provides three update methods: + +- `Update` +- `UpdateMany` +- `UpdateManyAndReturn` + +All three methods share the same predicate system and assignment API. + +--- + +## Update + +Updates a **single record**. + +The first predicate **must** uniquely identify a record (for example, `Id.EQ()` or a unique field such as `Email.EQ()`). Additional predicates may be supplied to further constrain the update. + +### Basic + +```go +user, err := db.User.Update(user.Email.EQ("x@y.com")). + SetEmail("x@y.com"). + SetPassword("secret"). + SetBio("super cool bio"). + Exec(ctx) +``` + +### With Additional Predicates + +```go +user, err := db.User.Update( + user.Email.EQ("x@y.com"), + user.Bio.Contains("the"), +). + SetPassword("secret"). + SetBio("super cool bio"). + Exec(ctx) +``` + +### Using Assignments + +```go +user, err := db.User.Update( + user.Email.EQ("x@y.com"), + user.Or( + user.Bio.Contains("the"), + user.PhoneNum.HasPrefix("+1"), + ), +). + Assignments( + user.Password.Set("secret"), + user.Bio.Set("super cool bio"), + ). + Exec(ctx) +``` + +### Supported Builder Methods + +| Method | Description | +| ----------------------------- | --------------------------------------------------- | +| [`Select`](/docs/Select) | Return only the selected fields or relations. | +| [`Omit`](/docs/Omit) | Return all scalar fields except the omitted ones. | +| `Set{Field}` | Update a single field. | +| [`Assignments`](#assignments) | Update multiple fields using generated assignments. | + +--- + +## UpdateMany + +Updates **all records** matching the supplied predicates. + +Unlike `Update`, no unique predicate is required. + +### Basic + +```go +count, err := db.User.UpdateMany( + user.Bio.Contains("golang"), +). + SetBio("Super cool bio"). + Exec(ctx) +``` + +### Multiple Predicates + +```go +count, err := db.User.UpdateMany( + user.Email.HasSuffix("@example.com"), + user.Bio.Contains("the"), +). + SetPassword("secret"). + SetBio("super cool bio"). + Exec(ctx) +``` + +### Using Assignments + +```go +count, err := db.User.UpdateMany( + user.Or( + user.Bio.Contains("the"), + user.PhoneNum.HasPrefix("+1"), + ), +). + Assignments( + user.Password.Set("secret"), + user.Bio.Set("super cool bio"), + ). + Exec(ctx) +``` + +### Supported Builder Methods + +| Method | Description | +| ----------------------------- | --------------------------------------------------- | +| `Set{Field}` | Update a single field. | +| [`Assignments`](#assignments) | Update multiple fields using generated assignments. | + +--- + +## UpdateManyAndReturn + +Updates **all matching records** and returns them. + +By default, all scalar fields are returned. You can customize the returned data with `Select` or `Omit`. + +### Basic + +```go +users, err := db.User.UpdateManyAndReturn( + user.Bio.Contains("golang"), +). + SetBio("Super cool bio"). + Exec(ctx) +``` + +### Multiple Predicates + +```go +users, err := db.User.UpdateManyAndReturn( + user.Email.HasSuffix("@example.com"), + user.Bio.Contains("the"), +). + SetPassword("secret"). + SetBio("super cool bio"). + Exec(ctx) +``` + +### Using Assignments + +```go +users, err := db.User.UpdateManyAndReturn( + user.Or( + user.Bio.Contains("the"), + user.PhoneNum.HasPrefix("+1"), + ), +). + Assignments( + user.Password.Set("secret"), + user.Bio.Set("super cool bio"), + ). + Exec(ctx) +``` + +### Supported Builder Methods + +| Method | Description | +| ----------------------------- | --------------------------------------------------- | +| [`Select`](/docs/Select) | Return only the selected fields or relations. | +| [`Omit`](/docs/Omit) | Return all scalar fields except the omitted ones. | +| `Set{Field}` | Update a single field. | +| [`Assignments`](#assignments) | Update multiple fields using generated assignments. | + +--- + +## Assignments + +`Assignments` accepts a variadic list of generated field assignments. + +For most updates, chaining `Set{Field}` methods is the most readable approach: + +```go +user, err := db.User.Update( + user.Email.EQ("x@y.com"), +). + SetUsername("JohnDoe.sh"). + SetEmail("new@example.com"). + SetBio("super cool bio"). + Exec(ctx) +``` + +`Assignments` becomes especially useful when the fields being updated are determined at runtime. Because assignments are ordinary values, they can be built, reused, filtered, and passed between functions before executing the query. + +```go +assignments := []phi.FieldAssignmentOf[phi.User]{} + +if req.Username != nil { + assignments = append(assignments, user.Username.Set(*req.Username)) +} + +if req.Bio != nil { + assignments = append(assignments, user.Bio.Set(*req.Bio)) +} + +updatedUser, err := db.User.Update( + user.Id.EQ(id), +). + Assignments(assignments...). + Exec(ctx) +``` + +This is difficult to achieve with chained `Set{Field}` methods, since the chain must be known at compile time. + +> **Note:** `Assignments` only accepts assignments generated for the same model. Passing `post.Title.Set(...)` to `db.User.Update(...).Assignments(...)` results in a compile-time type error. diff --git a/docs/Upsert-Records.md b/docs/Upsert-Records.md new file mode 100644 index 0000000..e416fc6 --- /dev/null +++ b/docs/Upsert-Records.md @@ -0,0 +1,140 @@ +--- +title: Upsert Records +description: Configure conflict handling for create operations. +category: CRUD +tags: [upsert, onConflict, conflictAction, create, createManyAndReturn] +categoryOrder: 2 +order: 5 +--- + +# Upsert Records + +Phi performs upserts through the `OnConflict` API available on create operations. + +Rather than exposing a separate `Upsert` method, Phi allows you to configure how `Create`, `CreateMany`, and `CreateManyAndReturn` behave when a unique constraint is violated. + +The examples in this guide use the following Prisma schema: + +```prisma +model User { + id String @id @default(cuid()) + name String + email String @unique + password String + bio String? + createdAt DateTime @default(now()) + + posts Post[] +} +``` + +## OnConflict + +`OnConflict` specifies which unique constraint should be handled if an insert would violate it. + +For example, the following handles conflicts on the `email` unique constraint: + +```go +created, err := db.User.Create(). + SetName("John Doe"). + SetEmail("x@y.com"). + SetPassword("secret"). + SetBio("super cool bio"). + OnConflict(user.Email).Ignore(). + Exec(ctx) +``` + +`OnConflict` supports the following actions: + +- `Ignore()` +- `UpdateNewValues()` +- `Update(func(u *phi.UserUpsert) { ... })` + +--- + +## Ignore + +Ignores records that would violate the specified unique constraint. + +```go +created, err := db.User.Create(). + SetName("John Doe"). + SetEmail("x@y.com"). + SetPassword("secret"). + SetBio("super cool bio"). + OnConflict(user.Email).Ignore(). + Exec(ctx) +``` + +This mirrors SQL's `ON CONFLICT DO NOTHING`. + +--- + +## UpdateNewValues + +Updates every writable field using the values supplied to `Create`. + +```go +created, err := db.User.Create(). + SetName("John Doe"). + SetEmail("x@y.com"). + SetPassword("secret"). + SetBio("super cool bio"). + OnConflict(user.Email). + UpdateNewValues(). + Exec(ctx) +``` + +This mirrors SQL's `ON CONFLICT DO UPDATE SET ...` using the inserted values. + +--- + +## Update + +Provides full control over the update performed when a conflict occurs. + +```go +created, err := db.User.Create(). + SetName("John Doe"). + SetEmail("x@y.com"). + SetPassword("secret"). + SetBio("super cool bio"). + OnConflict(user.Email).Update(func(u *phi.UserUpsert) { + u.Bio.Set("new bio") + u.Email.Set("new-email") + }). + Exec(ctx) +``` + +Only the fields specified inside the callback are updated. + +> **Note:** `user.Upsert` is a type alias for `phi.UserUpsert` - they are identical. The callback parameter type is generated per model, so you can also write the callback as `func(u *user.Upsert) { ... }` and it will behave exactly the same. + +--- + +## SkipDuplicates + +`SkipDuplicates` is available only on `CreateMany` and `CreateManyAndReturn`. + +It is equivalent to: + +```go +.OnConflict(user.).Ignore() +``` + +and mirrors SQL's `ON CONFLICT DO NOTHING`. + +--- + +## Supported By + +`OnConflict` is supported by: + +- [`Create`](/docs/Create-Records#create) +- [`CreateMany`](/docs/Create-Records#createmany) +- [`CreateManyAndReturn`](/docs/Create-Records#createmanyandreturn) + +`SkipDuplicates` is supported by: + +- [`CreateMany`](/docs/Create-Records#createmany) +- [`CreateManyAndReturn`](/docs/Create-Records#createmanyandreturn) diff --git a/docs/template.md b/docs/template.md new file mode 100644 index 0000000..9115876 --- /dev/null +++ b/docs/template.md @@ -0,0 +1,30 @@ +--- +title: Page Title +description: A short summary of this guide (used in search results and meta description). +category: Documentation +tags: [read, query, findUnique, findFirst, findMany, select, orm] +categoryOrder: 200 # used for sorting the category among others +order: 2 # used for sorting the guide inside it's category +--- + +# Page Title + +Write introductory paragraph here explaining what this documentation page covers. + +## First Main Section + +Use `##` (H2) for main sections. Each H2 automatically becomes a child segment link in the left sidebar and search index + +```go +fmt.Println("something") +``` + +## Second Main Section + +Add as many `##` sections as needed. + +- Bullet point 1 +- Bullet point 2 +- Bullet point 3 + +> **Note:** Blockquotes are styled with a highlighted white accent border. diff --git a/schema/schema_odyssey_test.go b/schema/schema_odyssey_test.go index 9f87baf..f046850 100644 --- a/schema/schema_odyssey_test.go +++ b/schema/schema_odyssey_test.go @@ -254,7 +254,7 @@ func TestOdyssey_AttributeChaos(t *testing.T) { }) t.Run("nested function call as attribute arg", func(t *testing.T) { - // dbgenerated("gen_random_uuid()") — function call inside a string + // dbgenerated("gen_random_uuid()") - function call inside a string // but also: @default(dbgenerated("nextval('seq')")) input := ` model Seq { @@ -288,7 +288,7 @@ func TestOdyssey_AttributeChaos(t *testing.T) { // ----------------------------------------------------------------------------- func TestOdyssey_TypeChaos(t *testing.T) { - t.Run("optional array — the cursed combo", func(t *testing.T) { + t.Run("optional array - the cursed combo", func(t *testing.T) { // String[]? is actually invalid in Prisma, but let's see if the parser panics input := ` model Cursed { @@ -443,7 +443,7 @@ func TestOdyssey_ModelStructureChaos(t *testing.T) { }) t.Run("model named with keyword-adjacent names", func(t *testing.T) { - // "models", "modeler", "datasources" — not keywords but close + // "models", "modeler", "datasources" - not keywords but close input := ` model ModelData { id Int @id @@ -530,7 +530,7 @@ func TestOdyssey_ModelStructureChaos(t *testing.T) { } ` s, _ := ParseSchema(input) - // Either it parses as a view or records an error — it must NOT panic + // Either it parses as a view or records an error - it must NOT panic _ = s }) } @@ -654,7 +654,7 @@ func TestOdyssey_ErrorRecovery(t *testing.T) { } }) - t.Run("truncated schema — file cut off mid-model", func(t *testing.T) { + t.Run("truncated schema - file cut off mid-model", func(t *testing.T) { input := ` model Complete { id Int @id @@ -664,7 +664,7 @@ func TestOdyssey_ErrorRecovery(t *testing.T) { id Int @id name String ` - // No closing brace — simulates a file truncated on disk + // No closing brace - simulates a file truncated on disk s, _ := ParseSchema(input) // Complete should be parseable found := false @@ -676,7 +676,7 @@ func TestOdyssey_ErrorRecovery(t *testing.T) { if !found { t.Errorf("truncated schema lost Complete model; models=%+v", s.Models) } - // Must not panic or infinite loop — if we're here, we're good + // Must not panic or infinite loop - if we're here, we're good }) t.Run("duplicate model names", func(t *testing.T) { @@ -691,7 +691,7 @@ func TestOdyssey_ErrorRecovery(t *testing.T) { } ` s, _ := ParseSchema(input) - // Parser may error or accept both — it must NOT panic + // Parser may error or accept both - it must NOT panic // We just want deterministic behavior _ = s.Models _ = s.Errors @@ -708,7 +708,7 @@ func TestOdyssey_ErrorRecovery(t *testing.T) { _ = s }) - t.Run("brace mismatch — extra closing brace at top level", func(t *testing.T) { + t.Run("brace mismatch - extra closing brace at top level", func(t *testing.T) { input := ` model User { id Int @id @@ -1026,7 +1026,7 @@ func TestOdyssey_WhitespaceAndComments(t *testing.T) { } }) - t.Run("no newlines — entire schema on one line", func(t *testing.T) { + t.Run("no newlines - entire schema on one line", func(t *testing.T) { input := `model Inline { id Int @id name String @unique }` s, _ := ParseSchema(input) if len(s.Models) != 1 {