api-database-edgedb
Graph-relational database with EdgeQL query language, code-first schema, link-based relations, computed properties, and fully typed TypeScript query builder
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-database-edgedb/skills/api-database-edgedb
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Gel (formerly EdgeDB) Patterns
Quick Guide: Gel (formerly EdgeDB) is a graph-relational database built on PostgreSQL. Define schemas in
.gelfiles using SDL with types, links, and computed properties. Usegel migration create+gel migratefor schema changes. Query with EdgeQL (set-based, deeply nested shapes) or the TypeScript query builder (e.select,e.insert). Everything in EdgeQL is a set -- empty sets need explicit casts, and operations on sets produce Cartesian products. Useglobalvariables with access policies for row-level security. The query builder requires a running database for code generation (npx @gel/generate edgeql-js).Naming: EdgeDB was rebranded to Gel in February 2025. The
edgedbnpm package, CLI, and.esdlextension still work via compatibility shims, but new projects should usegel,@gel/generate, and.gelfiles.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST run npx @gel/generate edgeql-js after every gel migrate -- the generated query builder is based on the database schema and becomes stale after migrations)
(You MUST cast empty sets explicitly (<str>{}, <int64>{}) -- bare {} is a syntax error because EdgeQL is strongly typed and cannot infer the type of an empty set)
(You MUST understand that all EdgeQL values are sets -- operations on multi-valued expressions produce Cartesian products, not element-wise results)
(You MUST pass the transaction object tx (not client) to ALL query .run() calls inside client.transaction() -- using client inside a transaction runs queries outside the transaction)
(You MUST NOT use volatile functions like datetime_current() in schema-defined computed properties -- use datetime_of_transaction() or datetime_of_statement() instead)
</critical_requirements>
Auto-detection: Gel, gel, EdgeDB, edgedb, EdgeQL, edgeql, .gel, .esdl, dbschema, edgeql-js, createClient, e.select, e.insert, e.update, e.delete, e.params, gel migrate, gel migration, edgedb migrate, SDL schema, backlink, access policy, gel.toml, edgedb.toml
When to use:
- Defining graph-relational schemas with types, links, and computed properties
- Writing type-safe queries with EdgeQL or the TypeScript query builder
- Managing schema migrations with the built-in migration system
- Modeling complex relationships (multi links, backlinks, polymorphism)
- Implementing row-level security with access policies and globals
Key patterns covered:
- Client setup and connection (
createClient, DSN, environment variables) - Schema definition in SDL (types, properties, links, constraints, computed)
- EdgeQL query language (SELECT shapes, INSERT, UPDATE, DELETE)
- TypeScript query builder (
e.select,e.insert,e.update,e.delete) - Migrations workflow (
gel migration create,gel migrate)
When NOT to use:
- Simple key-value storage (use a dedicated key-value store)
- Projects that need raw SQL as the primary interface (Gel uses EdgeQL; Gel 6+ adds native SQL support but EdgeQL is the primary interface)
- Environments where you cannot run the Gel server (it is not an embedded database)
Detailed Resources:
- For decision frameworks and quick reference, see reference.md
Core Patterns:
- examples/core.md - Client setup, schema definition, EdgeQL basics, migration workflow
Query Builder:
- examples/query-builder.md - TypeScript query builder (e.select, e.insert, e.update, e.delete, e.params)
Advanced Schema:
- examples/advanced-schema.md - Access policies, backlinks, abstract types, polymorphism, triggers
<red_flags>
RED FLAGS
High Priority Issues:
- Using
clientinstead oftxinsideclient.transaction()-- queries run outside the transaction and cannot be rolled back - Forgetting to run
npx @gel/generate edgeql-jsaftergel migrate-- query builder types are stale and TypeScript won't catch schema mismatches - Using raw
uuidproperties instead oflink-- defeats Gel's graph traversal and referential integrity - Using
datetime_current()in schema-defined computed properties -- volatile functions are forbidden in schema computeds; usedatetime_of_statement()ordatetime_of_transaction()
Medium Priority Issues:
- Bare
{}for empty sets -- EdgeQL requires explicit type cast (<str>{},<array<int64>>[]) because the type cannot be inferred from an empty literal - Using
:=when you mean+=on multi links in UPDATE --:=replaces the entire set,+=adds to it,-=removes from it - Not specifying
filteron UPDATE/DELETE -- without a filter, the operation applies to ALL objects of that type - Editing the database with DDL directly instead of through
.gelfiles + migrations -- causes schema drift between files and database
Common Mistakes:
- Expecting element-wise behavior from set operations --
{1, 2} + {10, 20}produces{11, 21, 12, 22}(Cartesian product), not{11, 22} - Forgetting that
selecton a single link returns an object (not an ID) -- you do not need to JOIN; just traverse with. - Using
select count(MyType)and expectingquerySingleto work --count()always returns exactly one value, so usequeryRequiredSingle - Defining a computed backlink but forgetting the type filter --
.<authorwithout[is Post]returns all types that have anauthorlink
Gotchas & Edge Cases:
- Computed properties are not stored -- they are re-evaluated on every query, which can be expensive for complex expressions
requiredon a multi link means "at least one" -- an empty set violates the constraint, which can be surprising- String concatenation uses
++not+-- the+operator is for arithmetic only LIMIT 1does NOT make a query return a singleton for cardinality purposes -- usefilter .id = <uuid>$id(exclusive constraint) for the query builder to infer singleton cardinality- Multi links are unordered sets -- if you need ordering, add an
order byin your query or use an intermediate type with anorderproperty - Backlinks (
.<link_name) default tomulticardinality -- usesinglekeyword explicitly if you know the relationship is one-to-one - EdgeDB branches (v5+) are separate database copies, not lightweight references -- branching a large database takes time and disk space proportional to the data size
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST run npx @gel/generate edgeql-js after every gel migrate -- the generated query builder is based on the database schema and becomes stale after migrations)
(You MUST cast empty sets explicitly (<str>{}, <int64>{}) -- bare {} is a syntax error because EdgeQL is strongly typed and cannot infer the type of an empty set)
(You MUST understand that all EdgeQL values are sets -- operations on multi-valued expressions produce Cartesian products, not element-wise results)
(You MUST pass the transaction object tx (not client) to ALL query .run() calls inside client.transaction() -- using client inside a transaction runs queries outside the transaction)
(You MUST NOT use volatile functions like datetime_current() in schema-defined computed properties -- use datetime_of_transaction() or datetime_of_statement() instead)
Failure to follow these rules will cause stale types, silent data bugs, or transaction isolation failures.
</critical_reminders>
Files (skills)
-
examples
-
advanced-schema.md 9.8 KB
# Gel (formerly EdgeDB) Advanced Schema Examples > Access policies, backlinks, abstract types, polymorphism, and triggers. See [SKILL.md](../SKILL.md) for core concepts. **Core patterns:** See [core.md](core.md). **Query builder:** See [query-builder.md](query-builder.md). --- ## Pattern 1: Backlinks ### Good Example -- Computed Backlinks for Bidirectional Traversal ``` # Backlinks let you traverse relationships in reverse without storing data twice module default { type Author { required name: str; required email: str { constraint exclusive; }; # Computed backlink: all posts where this Author is the author multi posts := .<author[is Post]; # Count of published posts (computed from backlink) published_count := count( (select .posts filter .status = Status.published) ); } type Post { required title: str; required author: Author; # forward link required status: Status; } scalar type Status extending enum<draft, published, archived>; } ``` **Why good:** `.<author[is Post]` traverses the `author` link backwards to find all Posts, `[is Post]` type filter ensures only Post objects are returned (other types with an `author` link are excluded), `published_count` composes on the backlink ``` # BAD: Storing the reverse link manually (data duplication) type Author { required name: str; multi posts: Post; # Forward multi link stored on Author } type Post { required title: str; required author: Author; } # Now posts exist in TWO places -- Author.posts and Post.author # They can get out of sync! ``` **Why bad:** Forward multi link on Author duplicates the relationship data, `Author.posts` and `Post.author` can become inconsistent, use computed backlinks (`.<author[is Post]`) instead ### Backlink Syntax Reference ``` # Syntax: .<link_name[is TargetType] # All objects linking to this object via 'author' multi written_items := .<author; # Returns mixed types # Only Posts (not Comments or other types with 'author' link) multi posts := .<author[is Post]; # Only Comments multi comments := .<author[is Comment]; # Single backlink (one-to-one reverse) single profile := .<user[is Profile]; ``` --- ## Pattern 2: Abstract Types and Polymorphism ### Good Example -- Abstract Type Hierarchy ``` module default { # Abstract type -- cannot be instantiated directly abstract type Timestamped { created_at: datetime { default := datetime_of_statement(); readonly := true; }; updated_at: datetime { default := datetime_of_statement(); rewrite insert, update using (datetime_of_statement()); }; } abstract type Auditable extending Timestamped { required created_by: User; modified_by: User; } # Concrete types extend abstract types type Post extending Auditable { required title: str; required body: str; required status: Status; } type Comment extending Auditable { required body: str; required post: Post; } type User extending Timestamped { required name: str; required email: str { constraint exclusive; }; } scalar type Status extending enum<draft, published, archived>; } ``` **Why good:** `Timestamped` adds created/updated timestamps to any type, `Auditable` extends it with audit fields, `rewrite` trigger auto-updates `updated_at`, multiple inheritance via `extending`, DRY schema definition ### Good Example -- Polymorphic Queries ```edgeql # Select all Auditable objects (polymorphic query) select Auditable { created_at, created_by: { name }, # Type-specific fields via type intersection [is Post].title, [is Post].status, [is Comment].body, [is Comment].post: { title }, } order by .created_at desc limit 50; ``` **Why good:** Querying abstract type returns all concrete subtypes, `[is Post].title` extracts type-specific fields without separate queries, single query for activity feed across types --- ## Pattern 3: Access Policies ### Good Example -- Row-Level Security with Globals ``` module default { # Global variable -- set at connection time via client.withGlobals() global current_user_id: uuid; # Computed global for convenience global current_user := ( select User filter .id = global current_user_id ); type User { required name: str; required email: str { constraint exclusive; }; required role: Role; required organization: Organization; } type Organization { required name: str; multi members := .<organization[is User]; } type Document { required title: str; required body: str; required owner: User; required organization: Organization; is_public: bool { default := false; }; # Access policies -- enforced at the database level access policy owner_full_access allow all using (.owner = global current_user); access policy org_members_can_read allow select using (.organization = global current_user.organization); access policy public_documents_readable allow select using (.is_public = true); } scalar type Role extending enum<admin, user, viewer>; } ``` **Why good:** `global current_user_id` set via `client.withGlobals()`, computed global resolves the full user object, policies are declarative and enforced by the database, multiple policies combine (any matching policy grants access), policies distinguish between `select`, `insert`, `update`, `delete` ### Using Access Policies from TypeScript ```typescript import { createClient } from "gel"; const baseClient = createClient(); // Set the global for the current request function getClientForUser(userId: string) { return baseClient.withGlobals({ current_user_id: userId, }); } // All queries through this client are filtered by access policies async function getDocuments(userId: string) { const client = getClientForUser(userId); // This only returns documents the user is allowed to see const docs = await client.query( `select Document { title, body, owner: { name } }`, ); return docs; } export { getClientForUser, getDocuments }; ``` **Why good:** `withGlobals` creates a derived client (immutable, does not modify base), all queries automatically filtered by policies, no application-level authorization checks needed for basic access control --- ## Pattern 4: Custom Scalar Types ### Good Example -- Constrained Scalars ``` module default { # Enum scalar scalar type Priority extending enum<low, medium, high, critical>; # Constrained string scalars scalar type EmailAddress extending str { constraint regexp(r'^[^@]+@[^@]+\.[^@]+$'); constraint min_len_value(5); constraint max_len_value(254); } scalar type SlugString extending str { constraint regexp(r'^[a-z0-9]+(-[a-z0-9]+)*$'); constraint max_len_value(100); } # Constrained numeric scalar type PositiveInt extending int64 { constraint min_value(1); } # Use the custom scalars in types type User { required email: EmailAddress; required name: str; } type Post { required title: str; required slug: SlugString { constraint exclusive; }; required priority: Priority { default := Priority.medium; }; } } ``` **Why good:** Custom scalars centralize validation logic, constraints enforced everywhere the scalar is used, DRY -- no need to repeat regex on every property --- ## Pattern 5: Triggers (v4+) ### Good Example -- Automatic Side Effects ``` module default { type Post { required title: str; required body: str; required author: User; required status: Status; published_at: datetime; # Trigger: auto-set published_at when status changes to published trigger set_published_at after update for each when (__new__.status = Status.published and __old__.status != Status.published) do ( update Post filter .id = __new__.id set { published_at := datetime_of_statement() } ); } type AuditLog { required action: str; required target_type: str; required target_id: uuid; required performed_by: User; created_at: datetime { default := datetime_of_statement(); }; } type User { required name: str; required email: str { constraint exclusive; }; # Trigger: log deletion trigger log_deletion after delete for each do ( insert AuditLog { action := 'deleted', target_type := 'User', target_id := __old__.id, performed_by := global current_user, } ); } global current_user: User; scalar type Status extending enum<draft, published, archived>; } ``` **Why good:** Triggers run inside the same transaction as the triggering operation, `__new__` and `__old__` reference the object before/after the change, `when` clause prevents unnecessary execution, audit logging as a database concern (not application concern) --- ## Pattern 6: Rewrite Rules ### Good Example -- Automatic Field Updates ``` module default { type Post { required title: str; required body: str; required status: Status; created_at: datetime { default := datetime_of_statement(); readonly := true; }; # Rewrite rule: auto-update on every insert and update updated_at: datetime { rewrite insert, update using (datetime_of_statement()); }; # Rewrite rule: normalize slug on insert required slug: str { constraint exclusive; rewrite insert using ( str_lower(str_replace(__subject__.title, ' ', '-')) ); }; } scalar type Status extending enum<draft, published, archived>; } ``` **Why good:** `rewrite` is more concise than triggers for simple field transformations, `__subject__` references the object being inserted/updated, slug auto-generated from title on insert, `updated_at` always reflects the latest modification --- _For core patterns, see [core.md](core.md). For query builder, see [query-builder.md](query-builder.md)._ -
core.md 12.1 KB
# Gel (formerly EdgeDB) Core Examples > Client setup, schema definition, EdgeQL queries, and migration workflow. See [SKILL.md](../SKILL.md) for core concepts. > > **Package names:** New projects use `gel` and `@gel/generate`. Legacy `edgedb` and `@edgedb/generate` packages still work via compatibility shims. **Query builder:** See [query-builder.md](query-builder.md). **Advanced schema:** See [advanced-schema.md](advanced-schema.md). --- ## Pattern 1: Client Setup ### Good Example -- Auto-Discovery Connection ```typescript import { createClient } from "gel"; // Reads connection info from gel.toml (or edgedb.toml) in the project root // This is the standard development workflow const client = createClient(); export { client }; ``` **Why good:** Zero-config for development, `createClient()` auto-discovers from project directory, named export ### Good Example -- Production with Environment Variable ```typescript import { createClient } from "gel"; // In production, set GEL_DSN (or EDGEDB_DSN) environment variable: // gel://user:password@hostname:5656/dbname const client = createClient(); // Or with explicit concurrency control const MAX_CONCURRENCY = 10; const clientWithPool = createClient({ concurrency: MAX_CONCURRENCY, }); export { client, clientWithPool }; ``` **Why good:** `createClient()` automatically reads `GEL_DSN` (or `EDGEDB_DSN`) env var, named constant for concurrency, no credentials in code ### Good Example -- Client with Globals ```typescript import { createClient } from "gel"; const baseClient = createClient(); // Derive a client with global variables set (for access policies) function getAuthenticatedClient(userId: string) { return baseClient.withGlobals({ current_user_id: userId, }); } export { baseClient, getAuthenticatedClient }; ``` **Why good:** `withGlobals` creates a derived client with global variables set, used with access policies for row-level security, does not modify the original client ### Bad Example -- Hardcoded Credentials ```typescript import { createClient } from "gel"; // BAD: Credentials in source code, no auto-discovery const client = createClient({ dsn: "gel://admin:p@ssw0rd@db.example.com:5656/production", }); export default client; ``` **Why bad:** Hardcoded credentials, DSN will be committed to version control, default export --- ## Pattern 2: Query Methods ### Good Example -- Choosing the Right Method ```typescript import { createClient } from "gel"; const client = createClient(); // .query() -- returns an array (zero or more results) const users = await client.query<{ name: string; email: string }>( `select User { name, email } filter .is_active = true`, ); // .querySingle() -- returns one result or null const user = await client.querySingle<{ name: string; email: string }>( `select User { name, email } filter .id = <uuid>$id`, { id: "a1b2c3d4-..." }, ); // .queryRequiredSingle() -- returns exactly one or throws NoDataError const totalUsers = await client.queryRequiredSingle<number>(`select count(User)`); // .execute() -- runs a statement, returns nothing await client.execute( `delete User filter .is_deactivated = true and .deactivated_at < <datetime>$cutoff`, { cutoff: cutoffDate }, ); export { users, user, totalUsers }; ``` **Why good:** Method matches expected cardinality, type parameter for result shape, parameterized queries prevent injection ### Bad Example -- Wrong Cardinality Method ```typescript // BAD: Using query() when you expect exactly one result const allUsers = await client.query(`select count(User)`); const count = allUsers[0]; // Awkward -- count() always returns exactly one value // BAD: Using queryRequiredSingle() when result might not exist const maybeUser = await client.queryRequiredSingle( `select User filter .email = <str>$email`, { email: "unknown@example.com" }, ); // Throws NoDataError if user doesn't exist! ``` **Why bad:** `query()` for a guaranteed-single result adds unnecessary array unwrapping, `queryRequiredSingle()` throws when result is absent -- use `querySingle()` for optional results --- ## Pattern 3: Parameterized Queries ### Good Example -- Type-Safe Parameters ```edgeql # Parameters are declared with <type>$name syntax select User { name, email, posts: { title, status, } filter .status = <Status>$status, } filter .email = <str>$email; ``` ```typescript const result = await client.querySingle( `select User { name, email, posts: { title, status, } filter .status = <Status>$status, } filter .email = <str>$email`, { email: "alice@example.com", status: "published" }, ); ``` **Why good:** Parameters are type-annotated (`<str>`, `<uuid>`, `<int64>`), preventing injection and ensuring type safety at the database level ### Bad Example -- String Interpolation ```typescript // BAD: SQL injection vulnerability const email = userInput; const result = await client.query( `select User filter .email = '${email}'`, // NEVER DO THIS ); ``` **Why bad:** String interpolation allows injection attacks, bypasses type checking, no parameterization --- ## Pattern 4: Schema Definition ### Good Example -- Complete Type with Constraints ``` # dbschema/default.gel module default { scalar type Role extending enum<admin, moderator, user>; type Organization { required name: str { constraint exclusive; constraint min_len_value(1); constraint max_len_value(100); }; description: str; created_at: datetime { default := datetime_of_statement(); readonly := true; }; multi members := .<organization[is User]; # computed backlink index on (.name); } type User { required name: str { constraint min_len_value(1); }; required email: str { constraint exclusive; constraint regexp(r'^[^@]+@[^@]+\.[^@]+$'); }; required role: Role { default := Role.user; }; required organization: Organization; is_active: bool { default := true; }; last_login_at: datetime; # Computed properties display_name := .name ++ ' (' ++ <str>.role ++ ')'; index on (.email); } } ``` **Why good:** `required` enforces non-null, `constraint exclusive` for uniqueness, enum as scalar type, `readonly` for immutable timestamps, computed backlinks for bidirectional traversal, computed property for derived data, indexes on frequently filtered fields, `default` values for optional fields ### Good Example -- Multi Links and Constraints ``` type Course { required title: str { constraint exclusive; }; required instructor: User; multi students: User; multi prerequisites: Course; max_enrollment: int32 { constraint min_value(1); }; # Compound constraint -- same student can't enroll twice # (handled automatically by multi link set semantics) # Cross-field constraint constraint expression on ( count(.students) <= .max_enrollment ?? 999 ); } ``` **Why good:** Multi links for many-to-many (students) and self-referential (prerequisites), cross-field constraint using expression, coalesce operator `??` handles null max_enrollment ### Bad Example -- No Constraints or Links ``` # BAD: No constraints, no links, storing IDs manually type User { name: str; email: str; org_id: uuid; # Should be a link! } ``` **Why bad:** No `required` makes everything nullable, no uniqueness on email, raw `uuid` instead of `link` -- no referential integrity, no traversal, no cascading deletes --- ## Pattern 5: EdgeQL SELECT Patterns ### Good Example -- Nested Shapes with Filtering ```edgeql select Post { title, status, excerpt := .body[0:200], # inline computed author: { name, email, }, tags: { name, } order by .name, comment_count := count(.comments), } filter .status = Status.published order by .published_at desc limit 20 offset 40; ``` **Why good:** Nested shapes for related objects, inline computed field (excerpt), aggregation in shape (comment_count), filtering/ordering/pagination at top level, ordering on nested link ### Good Example -- Conditional Selection ```edgeql # Select with conditional computed field select User { name, email, status := 'admin' if .role = Role.admin else 'regular', post_count := count(.posts), recent_post := ( select .posts order by .created_at desc limit 1 ) { title, created_at }, } filter .is_active = true; ``` **Why good:** Inline conditional with `if..else`, subquery for latest post with its own shape, aggregation alongside scalar fields --- ## Pattern 6: EdgeQL INSERT Patterns ### Good Example -- Insert with Link Resolution ```edgeql # Insert resolving links via subqueries insert Post { title := 'Getting Started with EdgeDB', body := 'EdgeDB is a graph-relational database...', status := Status.draft, author := ( select User filter .email = 'alice@example.com' ), tags := { (select Tag filter .name = 'edgedb'), (select Tag filter .name = 'tutorial'), }, }; ``` **Why good:** Links assigned via subqueries (not raw IDs), set literal `{ ... }` for multi link, author resolved by business key (email) ### Good Example -- Insert or Update (Upsert) ```edgeql # Insert unless conflict, then update insert User { name := 'Alice', email := 'alice@example.com', role := Role.admin, organization := (select Organization filter .name = 'Acme'), } unless conflict on .email else ( update User set { name := 'Alice', role := Role.admin, } ); ``` **Why good:** `unless conflict on` targets the exclusive constraint, `else` clause performs update on conflict, avoids race conditions between check-then-insert --- ## Pattern 7: EdgeQL UPDATE Patterns ### Good Example -- Multi Link Manipulation ```edgeql # Add tags without removing existing ones update Post filter .id = <uuid>$post_id set { tags += (select Tag filter .name in {'featured', 'trending'}), }; # Remove specific tags update Post filter .id = <uuid>$post_id set { tags -= (select Tag filter .name = 'draft'), }; # Replace all tags entirely update Post filter .id = <uuid>$post_id set { tags := (select Tag filter .name in {'final', 'reviewed'}), }; ``` **Why good:** `+=` adds to set, `-=` removes from set, `:=` replaces entire set -- three distinct operations for multi link manipulation ### Good Example -- Self-Referential Update ```edgeql # Increment a counter using current value update Post filter .id = <uuid>$post_id set { view_count := .view_count + 1, }; ``` **Why good:** `.view_count` refers to the current value, update is atomic --- ## Pattern 8: Migration Workflow ### Good Example -- Standard Development Workflow ```bash # 1. Start with a clean state gel migration status # Shows: Database is up to date. No migrations pending. # 2. Edit schema files # vim dbschema/default.gel # Add: required phone: str; to User type # 3. Generate migration (interactive) gel migration create # Gel will prompt: # "did you add property 'phone' to object type 'default::User'?" # Type 'y' to confirm # 4. Review generated migration file (optional but recommended) # cat dbschema/migrations/00002-m1abc123.edgeql # 5. Apply migration gel migrate # 6. Regenerate TypeScript types npx @gel/generate edgeql-js # 7. Update your TypeScript code to use the new field ``` **Why good:** Schema-first workflow, interactive migration creation with human review, separate create and apply steps, TypeScript code generation after migration ### Prototyping with Watch Mode ```bash # Auto-create and apply migrations on file change (prototyping only) gel watch --migrate ``` **Why good:** Rapid iteration during development -- Gel monitors `.gel` files and automatically creates and applies migrations in the background. Not for production use. ### Handling Migration Conflicts ```bash # If migration create fails due to schema issues: # 1. Fix the .gel files # 2. Re-run migration create gel migration create # If you need to see what changed: gel migration status gel describe schema # current database schema # If you need to start over (development only): gel migration create --allow-empty # create empty migration to reset state ``` --- _For query builder patterns, see [query-builder.md](query-builder.md). For advanced schema, see [advanced-schema.md](advanced-schema.md)._ -
query-builder.md 12.8 KB
# Gel (formerly EdgeDB) Query Builder Examples > TypeScript query builder patterns using `e.select`, `e.insert`, `e.update`, `e.delete`, and `e.params`. See [SKILL.md](../SKILL.md) for core concepts. > > **Package names:** New projects use `gel` and `@gel/generate`. Legacy `edgedb` and `@edgedb/generate` packages still work via compatibility shims. **Core patterns:** See [core.md](core.md). **Advanced schema:** See [advanced-schema.md](advanced-schema.md). --- ## Pattern 1: Query Builder Setup ### Good Example -- Standard Setup ```typescript import { createClient } from "gel"; import e, { type $infer } from "./dbschema/edgeql-js"; const client = createClient(); // Extract inferred result types from query expressions const usersQuery = e.select(e.User, () => ({ id: true, name: true })); type UsersResult = $infer<typeof usersQuery>; // => Array<{ id: string; name: string }> export { client, e }; ``` **Why good:** Query builder imported from generated directory, `e` is the conventional name, `$infer` extracts compile-time result types from any query expression, named exports ### Generating the Query Builder ```bash # Requires a running Gel instance (it introspects the live schema) npx @gel/generate edgeql-js # Output: generates files in ./dbschema/edgeql-js/ # Re-run after every `gel migrate` ``` **Why good:** Generator introspects the actual database schema (not just .gel files), ensuring generated types exactly match the database --- ## Pattern 2: e.select -- Querying Data ### Good Example -- Basic Select with Shape ```typescript import { createClient } from "gel"; import e from "./dbschema/edgeql-js"; const client = createClient(); // Select all users with specific fields const allUsersQuery = e.select(e.User, () => ({ id: true, name: true, email: true, })); // Result type is automatically inferred: // Array<{ id: string; name: string; email: string }> const users = await allUsersQuery.run(client); export { users }; ``` **Why good:** Shape expression specifies exactly which fields to return, result type is automatically inferred by TypeScript, `.run(client)` executes the query ### Good Example -- Nested Shapes and Filtering ```typescript import e from "./dbschema/edgeql-js"; const PAGE_SIZE = 20; const publishedPostsQuery = e.select(e.Post, (post) => ({ id: true, title: true, excerpt: true, author: { name: true, email: true, }, tags: { name: true, }, comment_count: e.count(post.comments), filter: e.op(post.status, "=", e.Status.published), order_by: { expression: post.published_at, direction: e.DESC, }, limit: PAGE_SIZE, })); export { publishedPostsQuery }; ``` **Why good:** Nested shapes for links, computed field (comment_count) using `e.count`, filter/order_by/limit as shape properties, named constant for limit, status compared with generated enum `e.Status.published` ### Good Example -- Filter on Exclusive Constraint (Singleton) ```typescript import e from "./dbschema/edgeql-js"; // Object shorthand -- preferred when filtering on exclusive properties const userByIdQuery = e.select(e.User, () => ({ id: true, name: true, email: true, role: true, posts: { title: true, status: true, }, filter_single: { id: "a1b2c3d4-..." }, })); // Expression form -- use when you need e.op for complex filters const userByEmailQuery = e.select(e.User, (user) => ({ id: true, name: true, filter_single: e.op(user.email, "=", "alice@example.com"), })); // Result type: { id: string; name: string; ... } | null // (not an array -- filter_single changes cardinality) ``` **Why good:** Object shorthand `{ id: "..." }` is concise for exclusive properties, `e.op` form provides full expression flexibility, `filter_single` tells the query builder this returns zero or one result (not an array) ### Bad Example -- Missing Filter on Delete-All ```typescript // BAD: No filter -- selects ALL users const allQuery = e.select(e.User, () => ({ id: true, name: true, })); // This returns every user in the database -- intentional? // If you want all users, be explicit about it. ``` **Why bad:** No filter returns the entire table -- make sure this is intentional, especially in production with large datasets --- ## Pattern 3: e.insert -- Creating Data ### Good Example -- Insert with Link Resolution ```typescript import e from "./dbschema/edgeql-js"; const insertPostQuery = e.insert(e.Post, { title: "Getting Started with EdgeDB", body: "EdgeDB is a graph-relational database...", status: e.Status.draft, author: e.select(e.User, (user) => ({ filter_single: e.op(user.email, "=", "alice@example.com"), })), tags: e.select(e.Tag, (tag) => ({ filter: e.op(tag.name, "in", e.set("edgedb", "tutorial")), })), }); // Select the inserted post to get its fields back const insertAndReturnQuery = e.select(insertPostQuery, () => ({ id: true, title: true, author: { name: true }, })); export { insertAndReturnQuery }; ``` **Why good:** Links resolved via subqueries (not raw IDs), multi link assigned with `e.set()` for tag names, `e.select()` wrapping insert to return specific fields, enum value via `e.Status.draft` ### Good Example -- Upsert (Insert Unless Conflict) ```typescript import e from "./dbschema/edgeql-js"; const upsertUserQuery = e .insert(e.User, { name: "Alice", email: "alice@example.com", role: e.Role.admin, organization: e.select(e.Organization, (org) => ({ filter_single: e.op(org.name, "=", "Acme"), })), }) .unlessConflict((user) => ({ on: user.email, else: e.update(user, () => ({ set: { name: "Alice", role: e.Role.admin, }, })), })); export { upsertUserQuery }; ``` **Why good:** `.unlessConflict()` handles duplicate key gracefully, `on: user.email` targets the exclusive constraint, `else` clause updates on conflict --- ## Pattern 4: e.update -- Modifying Data ### Good Example -- Update with Multi Link Manipulation ```typescript import e from "./dbschema/edgeql-js"; // Add tags (+=) const addTagsQuery = e.update(e.Post, (post) => ({ filter_single: e.op(post.id, "=", e.uuid(postId)), set: { tags: { "+=": e.select(e.Tag, (tag) => ({ filter: e.op(tag.name, "in", e.set("featured", "trending")), })), }, }, })); // Remove tags (-=) const removeTagQuery = e.update(e.Post, (post) => ({ filter_single: e.op(post.id, "=", e.uuid(postId)), set: { tags: { "-=": e.select(e.Tag, (tag) => ({ filter_single: e.op(tag.name, "=", "draft"), })), }, }, })); // Replace all tags (:=) const replaceTagsQuery = e.update(e.Post, (post) => ({ filter_single: e.op(post.id, "=", e.uuid(postId)), set: { tags: e.select(e.Tag, (tag) => ({ filter: e.op(tag.name, "in", e.set("final", "reviewed")), })), }, })); export { addTagsQuery, removeTagQuery, replaceTagsQuery }; ``` **Why good:** Three distinct operations -- `+=` adds, `-=` removes, plain assignment replaces, each corresponds to EdgeQL's set manipulation operators ### Good Example -- Self-Referential Update ```typescript import e from "./dbschema/edgeql-js"; const incrementViewsQuery = e.update(e.Post, (post) => ({ filter_single: e.op(post.id, "=", e.uuid(postId)), set: { view_count: e.op(post.view_count, "+", 1), }, })); export { incrementViewsQuery }; ``` **Why good:** `e.op(post.view_count, '+', 1)` references the current value, update is atomic --- ## Pattern 5: e.delete -- Removing Data ### Good Example -- Delete with Filter ```typescript import e from "./dbschema/edgeql-js"; // Delete specific post const deletePostQuery = e.delete(e.Post, (post) => ({ filter_single: e.op(post.id, "=", e.uuid(postId)), })); // Delete returns the deleted objects -- select to get fields const deleteAndReturnQuery = e.select(deletePostQuery, () => ({ id: true, title: true, })); // Bulk delete with condition const DAYS_CUTOFF = 30; const cleanupQuery = e.delete(e.Post, (post) => ({ filter: e.op( e.op(post.status, "=", e.Status.draft), "and", e.op( post.created_at, "<", e.op(e.datetime_of_statement(), "-", e.duration(`P${DAYS_CUTOFF}D`)), ), ), })); export { deleteAndReturnQuery, cleanupQuery }; ``` **Why good:** `filter_single` for deleting one object, `e.select` wrapping delete to return deleted fields, compound filter for bulk cleanup, named constant for cutoff --- ## Pattern 6: e.params -- Parameterized Queries ### Good Example -- Reusable Parameterized Query ```typescript import e from "./dbschema/edgeql-js"; // Define a reusable parameterized query const getUserByEmailQuery = e.params({ email: e.str }, ($) => e.select(e.User, (user) => ({ id: true, name: true, email: true, role: true, organization: { name: true }, filter_single: e.op(user.email, "=", $.email), })), ); // Usage -- pass params to .run() const user = await getUserByEmailQuery.run(client, { email: "alice@example.com", }); export { getUserByEmailQuery }; ``` **Why good:** `e.params` declares parameter types, query is reusable with different values, parameters are validated at runtime and compile time ### Good Example -- Multiple Parameters with Optional ```typescript import e from "./dbschema/edgeql-js"; const PAGE_SIZE = 20; const searchPostsQuery = e.params( { search_term: e.str, status: e.optional(e.str), page: e.int64, }, ($) => e.select(e.Post, (post) => ({ id: true, title: true, excerpt: true, author: { name: true }, filter: e.op( e.op(post.title, "ilike", e.op("%", "++", $.search_term, "++", "%")), "and", e.op( e.op($.status, "is", e.set()), // null check "or", e.op(post.status, "=", $.status), ), ), order_by: { expression: post.published_at, direction: e.DESC, }, limit: PAGE_SIZE, offset: e.op($.page, "*", PAGE_SIZE), })), ); export { searchPostsQuery }; ``` **Why good:** `e.optional()` for nullable parameters, pagination with offset calculation, ilike for case-insensitive search, null check pattern for optional filter --- ## Pattern 7: Query Composition ### Good Example -- Composing Queries ```typescript import e from "./dbschema/edgeql-js"; // Create a tag, then use it in an insert const newTag = e.insert(e.Tag, { name: "edgedb-v6" }); const newPost = e.insert(e.Post, { title: "What's New in EdgeDB 6", body: "EdgeDB 6 adds SQL support...", status: e.Status.published, published_at: e.datetime_of_statement(), author: e.select(e.User, (user) => ({ filter_single: e.op(user.email, "=", "alice@example.com"), })), tags: newTag, // Reference the insert expression directly }); // Select the final result const query = e.select(newPost, () => ({ id: true, title: true, tags: { name: true }, })); // The query builder automatically hoists composed expressions into WITH blocks const result = await query.run(client); export { result }; ``` **Why good:** Query expressions are composable -- reference one expression inside another, query builder auto-generates `WITH` blocks, no intermediate variables needed for the actual EdgeQL --- ## Pattern 8: Transactions with Query Builder ### Good Example -- Transaction with Query Builder ```typescript import { createClient } from "gel"; import e from "./dbschema/edgeql-js"; const client = createClient(); const TRANSFER_AMOUNT = 100; async function transferCredits(fromUserId: string, toUserId: string) { await client.transaction(async (tx) => { // CRITICAL: pass tx (not client) to .run() const debitQuery = e.update(e.Account, (account) => ({ filter_single: e.op(account.user.id, "=", e.uuid(fromUserId)), set: { balance: e.op(account.balance, "-", TRANSFER_AMOUNT), }, })); const result = await e .select(debitQuery, () => ({ balance: true })) .run(tx); // tx, not client! if (result && result.balance < 0) { throw new Error("Insufficient balance"); } await e .update(e.Account, (account) => ({ filter_single: e.op(account.user.id, "=", e.uuid(toUserId)), set: { balance: e.op(account.balance, "+", TRANSFER_AMOUNT), }, })) .run(tx); // tx, not client! }); } export { transferCredits }; ``` **Why good:** `client.transaction()` handles commit/abort/retry, `tx` passed to every `.run()` call, error inside callback triggers rollback, named constant for amount ### Bad Example -- Using Client Inside Transaction ```typescript // BAD: Using client instead of tx await client.transaction(async (tx) => { await someQuery.run(client); // WRONG -- runs outside the transaction! await anotherQuery.run(tx); // Only this one is transactional }); ``` **Why bad:** `client` inside transaction runs outside it -- will not roll back on error, data inconsistency --- _For core patterns, see [core.md](core.md). For advanced schema, see [advanced-schema.md](advanced-schema.md)._
-
-
reference.md 10.4 KB
# Gel (formerly EdgeDB) Reference > Decision frameworks, quick reference, CLI commands, and scalar types. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples. > > **CLI naming:** New projects use `gel` CLI commands. Legacy `edgedb` CLI commands still work via compatibility symlinks. --- ## Decision Framework ### Link vs Property ``` Does the field reference another object type? |-- YES --> Use a link | |-- Is it exactly one related object? | | |-- YES --> required link (or optional link if nullable) | | '-- NO --> multi link | '-- Do you need to traverse it in reverse? | |-- YES --> Add a computed backlink on the target type | '-- NO --> Just the forward link is enough '-- NO --> Use a property |-- Is it one of the built-in scalar types? | |-- YES --> Use the scalar type directly (str, int64, float64, bool, etc.) | '-- NO --> Define a custom scalar (enum, constrained str, etc.) '-- Is it a nested structure? |-- YES --> Consider a separate type with a link (EdgeDB has no embedded documents) '-- NO --> Property with appropriate scalar type ``` ### EdgeQL vs Query Builder ``` Are you writing TypeScript? |-- YES --> Do you want compile-time type safety? | |-- YES --> Use the query builder (e.select, e.insert, etc.) | '-- NO --> Raw EdgeQL strings with type parameters '-- NO --> Raw EdgeQL strings (only option for non-TS) Is the query dynamic (runtime-determined shape)? |-- YES --> Query builder composes better for dynamic shapes '-- NO --> Either approach works; query builder preferred for consistency ``` ### required vs optional ``` Must this field always have a value? |-- YES --> required (INSERT will fail without it) '-- NO --> optional (default -- allows empty set) For multi links: |-- Must there be at least one? --> required multi link '-- Can it be empty? --> multi link (no required) ``` --- ## CLI Commands ```bash # Project initialization gel project init # Initialize project with gel.toml # Instance management gel instance create my_project # Create a new instance gel instance list # List instances gel instance destroy my_project # Destroy an instance # Migration workflow gel migration create # Generate migration from .gel diff (interactive) gel migrate # Apply pending migrations (idempotent) gel migration status # Show migration status gel watch --migrate # Auto-migrate on file change (prototyping) # Interactive query shell gel # Open EdgeQL REPL gel query "select 1 + 1" # Run a single query # Schema introspection gel describe type User # Show type details gel describe schema # Dump full schema # Branching (v5+) gel branch create dev # Create a branch gel branch switch dev # Switch to a branch gel branch list # List branches # Code generation npx @gel/generate edgeql-js # Generate query builder npx @gel/generate queries # Generate typed functions from .edgeql files npx @gel/generate interfaces # Generate TypeScript interfaces from schema # UI gel ui # Open the built-in web UI ``` --- ## Scalar Types | EdgeQL Type | TypeScript Type | Description | | --------------------- | --------------- | ----------------------------------- | | `str` | `string` | Unicode string | | `bool` | `boolean` | Boolean | | `int16` | `number` | 16-bit integer | | `int32` | `number` | 32-bit integer | | `int64` | `number` | 64-bit integer | | `float32` | `number` | 32-bit float | | `float64` | `number` | 64-bit float | | `bigint` | `bigint` | Arbitrary precision integer | | `decimal` | N/A (string) | Arbitrary precision decimal | | `uuid` | `string` | UUID (auto-generated `id` property) | | `datetime` | `Date` | Timezone-aware datetime | | `cal::local_datetime` | `LocalDateTime` | Timezone-naive datetime | | `cal::local_date` | `LocalDate` | Date without time | | `cal::local_time` | `LocalTime` | Time without date | | `duration` | `Duration` | Time span | | `json` | `unknown` | Arbitrary JSON | | `bytes` | `Buffer` | Binary data | | `sequence` | `number` | Auto-incrementing integer | --- ## EdgeQL Operators Quick Reference | Operator | Description | Example | | ---------------- | -------------------------- | ---------------------------------- | | `:=` | Assignment | `set { name := 'Alice' }` | | `=` / `!=` | Equality / inequality | `filter .name = 'Alice'` | | `?=` / `?!=` | Optional equality | `filter .name ?= 'Alice'` | | `++` | String/array concatenation | `'Hello' ++ ' ' ++ 'World'` | | `+` `-` `*` `/` | Arithmetic | `.price * .quantity` | | `and` `or` `not` | Logical | `.age > 18 and .active = true` | | `in` | Set membership | `.status in {'active', 'pending'}` | | `like` / `ilike` | Pattern matching | `.name ilike '%alice%'` | | `exists` | Non-empty set test | `filter exists .email` | | `??` | Coalesce (first non-empty) | `.nickname ?? .name` | | `[is Type]` | Type filter | `.<author[is Post]` | | `if..else` | Conditional | `'Yes' if .active else 'No'` | | `distinct` | Deduplicate set | `select distinct .tags` | | `detached` | Escape current scope | `detached User` in subqueries | --- ## Set Operations | Operation | Description | Example | | ----------------- | -------------------- | ------------------------------------ | | `union` / `UNION` | Set union | `{1, 2} union {2, 3}` => `{1, 2, 3}` | | `intersect` | Set intersection | Not built-in; use filter + in | | `except` | Set difference | `{1, 2, 3} except {2}` => `{1, 3}` | | `count()` | Count elements | `select count(User)` | | `exists` | Test non-empty | `select exists (select User)` | | `array_agg()` | Convert set to array | `select array_agg(User.name)` | | `array_unpack()` | Convert array to set | `select array_unpack([1, 2, 3])` | --- ## Common Constraints | Constraint | Description | Example Usage | | ---------------------- | ------------------------- | ------------------------------------------ | | `exclusive` | Unique value | `constraint exclusive;` | | `exclusive on (expr)` | Compound unique | `constraint exclusive on ((.email, .org))` | | `min_value(n)` | Minimum numeric value | `constraint min_value(0);` | | `max_value(n)` | Maximum numeric value | `constraint max_value(1000);` | | `min_len_value(n)` | Minimum string length | `constraint min_len_value(1);` | | `max_len_value(n)` | Maximum string length | `constraint max_len_value(200);` | | `regexp(r'pattern')` | Regex validation | `constraint regexp(r'^[a-z]+$');` | | `expression on (expr)` | Custom boolean expression | `constraint expression on (.start < .end)` | --- ## Link Modification Operators (UPDATE) | Operator | Description | Example | | -------- | ---------------------- | ---------------------------------- | | `:=` | Replace entire set | `set { tags := ... }` | | `+=` | Add to multi link | `set { tags += (select Tag ...) }` | | `-=` | Remove from multi link | `set { tags -= (select Tag ...) }` | --- ## Environment Variables Both `GEL_*` and `EDGEDB_*` prefixes are supported. New projects should prefer `GEL_*`. | Variable (new / legacy) | Description | | ------------------------------------------------ | ------------------------------- | | `GEL_DSN` / `EDGEDB_DSN` | Full connection string | | `GEL_INSTANCE` / `EDGEDB_INSTANCE` | Instance name | | `GEL_HOST` / `EDGEDB_HOST` | Server hostname | | `GEL_PORT` / `EDGEDB_PORT` | Server port (default: 5656) | | `GEL_DATABASE` / `EDGEDB_DATABASE` | Database name | | `GEL_BRANCH` / `EDGEDB_BRANCH` | Branch name (v5+) | | `GEL_USER` / `EDGEDB_USER` | Username | | `GEL_PASSWORD` / `EDGEDB_PASSWORD` | Password | | `GEL_SECRET_KEY` / `EDGEDB_SECRET_KEY` | Secret key for Gel Cloud | | `GEL_CLIENT_SECURITY` / `EDGEDB_CLIENT_SECURITY` | `insecure_dev_mode` or `strict` | --- ## Naming Migration (Feb 2025) EdgeDB was rebranded to **Gel** in February 2025. All old names continue to work via compatibility shims. | Old Name | New Name | Status | | ------------------- | ---------------- | ------------------------- | | `edgedb` (npm) | `gel` | Shim available, both work | | `@edgedb/generate` | `@gel/generate` | Shim available, both work | | `edgedb` CLI | `gel` CLI | Symlinks available | | `edgedb.toml` | `gel.toml` | Both supported | | `.esdl` files | `.gel` files | Both supported | | `EDGEDB_*` env vars | `GEL_*` env vars | Both supported | An automated codemod tool is available for migration: see the [official blog post](https://www.geldata.com/blog/edgedb-is-now-gel-and-postgres-is-the-future) for details. -
SKILL.md 12.6 KB
--- name: api-database-edgedb description: Graph-relational database with EdgeQL query language, code-first schema, link-based relations, computed properties, and fully typed TypeScript query builder --- # Gel (formerly EdgeDB) Patterns > **Quick Guide:** Gel (formerly EdgeDB) is a graph-relational database built on PostgreSQL. Define schemas in `.gel` files using SDL with types, links, and computed properties. Use `gel migration create` + `gel migrate` for schema changes. Query with EdgeQL (set-based, deeply nested shapes) or the TypeScript query builder (`e.select`, `e.insert`). Everything in EdgeQL is a set -- empty sets need explicit casts, and operations on sets produce Cartesian products. Use `global` variables with access policies for row-level security. The query builder requires a running database for code generation (`npx @gel/generate edgeql-js`). > > **Naming:** EdgeDB was rebranded to **Gel** in February 2025. The `edgedb` npm package, CLI, and `.esdl` extension still work via compatibility shims, but new projects should use `gel`, `@gel/generate`, and `.gel` files. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST run `npx @gel/generate edgeql-js` after every `gel migrate` -- the generated query builder is based on the database schema and becomes stale after migrations)** **(You MUST cast empty sets explicitly (`<str>{}`, `<int64>{}`) -- bare `{}` is a syntax error because EdgeQL is strongly typed and cannot infer the type of an empty set)** **(You MUST understand that all EdgeQL values are sets -- operations on multi-valued expressions produce Cartesian products, not element-wise results)** **(You MUST pass the transaction object `tx` (not `client`) to ALL query `.run()` calls inside `client.transaction()` -- using `client` inside a transaction runs queries outside the transaction)** **(You MUST NOT use volatile functions like `datetime_current()` in schema-defined computed properties -- use `datetime_of_transaction()` or `datetime_of_statement()` instead)** </critical_requirements> --- **Auto-detection:** Gel, gel, EdgeDB, edgedb, EdgeQL, edgeql, .gel, .esdl, dbschema, edgeql-js, createClient, e.select, e.insert, e.update, e.delete, e.params, gel migrate, gel migration, edgedb migrate, SDL schema, backlink, access policy, gel.toml, edgedb.toml **When to use:** - Defining graph-relational schemas with types, links, and computed properties - Writing type-safe queries with EdgeQL or the TypeScript query builder - Managing schema migrations with the built-in migration system - Modeling complex relationships (multi links, backlinks, polymorphism) - Implementing row-level security with access policies and globals **Key patterns covered:** - Client setup and connection (`createClient`, DSN, environment variables) - Schema definition in SDL (types, properties, links, constraints, computed) - EdgeQL query language (SELECT shapes, INSERT, UPDATE, DELETE) - TypeScript query builder (`e.select`, `e.insert`, `e.update`, `e.delete`) - Migrations workflow (`gel migration create`, `gel migrate`) **When NOT to use:** - Simple key-value storage (use a dedicated key-value store) - Projects that need raw SQL as the primary interface (Gel uses EdgeQL; Gel 6+ adds native SQL support but EdgeQL is the primary interface) - Environments where you cannot run the Gel server (it is not an embedded database) **Detailed Resources:** - For decision frameworks and quick reference, see [reference.md](reference.md) **Core Patterns:** - [examples/core.md](examples/core.md) - Client setup, schema definition, EdgeQL basics, migration workflow **Query Builder:** - [examples/query-builder.md](examples/query-builder.md) - TypeScript query builder (e.select, e.insert, e.update, e.delete, e.params) **Advanced Schema:** - [examples/advanced-schema.md](examples/advanced-schema.md) - Access policies, backlinks, abstract types, polymorphism, triggers --- <philosophy> ## Philosophy Gel is a graph-relational database. It combines the relational model (tables, constraints, ACID) with a graph model (links between objects, deep traversal). The core idea: **relationships are first-class citizens, not join tables.** **Core principles:** 1. **Schema is the source of truth** -- Define everything in `.gel` files (or `.esdl` for legacy projects). Migrations are auto-generated by comparing your schema files against the database state. 2. **Links over foreign keys** -- Use `link` to connect types. Gel handles the underlying foreign keys. You never write JOIN -- you traverse links with dot notation. 3. **Sets everywhere** -- Every value in EdgeQL is a set. A single string is a set of one element. This is the most important mental model shift from SQL. 4. **Shapes for projection** -- SELECT returns structured, nested objects (like GraphQL responses), not flat rows. You specify the "shape" of what you want. 5. **Computed properties are views** -- Computed properties and links are not stored; they are evaluated on every query. Use them for derived data. 6. **Query builder for TypeScript** -- The generated query builder provides compile-time type safety. Prefer it over raw EdgeQL strings in TypeScript projects. **When to use Gel:** - Applications with complex, deeply nested relationships (social graphs, content systems, e-commerce) - Projects that benefit from graph-style traversals without sacrificing relational integrity - TypeScript projects that want compile-time type-safe database queries - Teams that want automatic migration generation from schema changes **When NOT to use:** - Existing projects locked into raw PostgreSQL with extensive stored procedures - Applications where SQL compatibility is the only acceptable query language (Gel 6+ has native SQL support, but EdgeQL is the primary interface) - Environments that cannot run the Gel server process </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Client Setup Create a client with `createClient()`. Connection details are auto-discovered from `gel.toml` (or `edgedb.toml`) or environment variables. ```typescript import { createClient } from "gel"; const client = createClient(); // auto-discovers from project config export { client }; ``` Choose the right query method by expected cardinality: `query()` for sets, `querySingle()` for optional single, `queryRequiredSingle()` when result is guaranteed, `execute()` for side-effect-only statements. See [examples/core.md](examples/core.md) for full client setup patterns. --- ### Pattern 2: Schema Definition (SDL) Schemas live in `dbschema/*.gel` files (or `*.esdl` for legacy projects). Use `required` for non-null, `link` for relationships, `constraint exclusive` for uniqueness, computed backlinks (`.<author[is Post]`) for reverse traversal. ``` # dbschema/default.gel module default { type User { required name: str; required email: str { constraint exclusive; }; multi posts := .<author[is Post]; # computed backlink } type Post { required title: str; required author: User; # link, not uuid! } } ``` **Key rule:** Always use `link` for relationships -- never raw `uuid` properties. See [examples/core.md](examples/core.md) for complete schema patterns with constraints, indexes, and enums. --- ### Pattern 3: EdgeQL Queries SELECT uses shapes for projection (like GraphQL), INSERT assigns links via subqueries, UPDATE uses `+=`/`-=`/`:=` for multi link manipulation. ```edgeql select User { name, email, posts: { title, status } filter .status = Status.published, } filter .email = 'alice@example.com'; ``` See [examples/core.md](examples/core.md) for SELECT/INSERT/UPDATE/DELETE patterns and parameterized queries. --- ### Pattern 4: Migrations Gel compares your `.gel` files against the database and auto-generates migrations. ```bash gel migration create # generate migration from schema diff (interactive) gel migrate # apply pending migrations (idempotent) npx @gel/generate edgeql-js # regenerate query builder ``` Never edit the database with DDL directly -- always modify `.gel` files and use the migration workflow. See [examples/core.md](examples/core.md) for the full workflow including `gel watch --migrate` for prototyping. --- ### Pattern 5: Transactions Pass `tx` (not `client`) to ALL operations inside `client.transaction()`. Using `client` inside the callback runs queries outside the transaction. ```typescript await client.transaction(async (tx) => { await tx.execute(`update Account ...`); // tx, not client! }); ``` See [examples/core.md](examples/core.md) for transaction patterns and [examples/query-builder.md](examples/query-builder.md) for query builder transactions. </patterns> --- <red_flags> ## RED FLAGS **High Priority Issues:** - Using `client` instead of `tx` inside `client.transaction()` -- queries run outside the transaction and cannot be rolled back - Forgetting to run `npx @gel/generate edgeql-js` after `gel migrate` -- query builder types are stale and TypeScript won't catch schema mismatches - Using raw `uuid` properties instead of `link` -- defeats Gel's graph traversal and referential integrity - Using `datetime_current()` in schema-defined computed properties -- volatile functions are forbidden in schema computeds; use `datetime_of_statement()` or `datetime_of_transaction()` **Medium Priority Issues:** - Bare `{}` for empty sets -- EdgeQL requires explicit type cast (`<str>{}`, `<array<int64>>[]`) because the type cannot be inferred from an empty literal - Using `:=` when you mean `+=` on multi links in UPDATE -- `:=` replaces the entire set, `+=` adds to it, `-=` removes from it - Not specifying `filter` on UPDATE/DELETE -- without a filter, the operation applies to ALL objects of that type - Editing the database with DDL directly instead of through `.gel` files + migrations -- causes schema drift between files and database **Common Mistakes:** - Expecting element-wise behavior from set operations -- `{1, 2} + {10, 20}` produces `{11, 21, 12, 22}` (Cartesian product), not `{11, 22}` - Forgetting that `select` on a single link returns an object (not an ID) -- you do not need to JOIN; just traverse with `.` - Using `select count(MyType)` and expecting `querySingle` to work -- `count()` always returns exactly one value, so use `queryRequiredSingle` - Defining a computed backlink but forgetting the type filter -- `.<author` without `[is Post]` returns all types that have an `author` link **Gotchas & Edge Cases:** - Computed properties are not stored -- they are re-evaluated on every query, which can be expensive for complex expressions - `required` on a multi link means "at least one" -- an empty set violates the constraint, which can be surprising - String concatenation uses `++` not `+` -- the `+` operator is for arithmetic only - `LIMIT 1` does NOT make a query return a singleton for cardinality purposes -- use `filter .id = <uuid>$id` (exclusive constraint) for the query builder to infer singleton cardinality - Multi links are unordered sets -- if you need ordering, add an `order by` in your query or use an intermediate type with an `order` property - Backlinks (`.<link_name`) default to `multi` cardinality -- use `single` keyword explicitly if you know the relationship is one-to-one - EdgeDB branches (v5+) are separate database copies, not lightweight references -- branching a large database takes time and disk space proportional to the data size </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST run `npx @gel/generate edgeql-js` after every `gel migrate` -- the generated query builder is based on the database schema and becomes stale after migrations)** **(You MUST cast empty sets explicitly (`<str>{}`, `<int64>{}`) -- bare `{}` is a syntax error because EdgeQL is strongly typed and cannot infer the type of an empty set)** **(You MUST understand that all EdgeQL values are sets -- operations on multi-valued expressions produce Cartesian products, not element-wise results)** **(You MUST pass the transaction object `tx` (not `client`) to ALL query `.run()` calls inside `client.transaction()` -- using `client` inside a transaction runs queries outside the transaction)** **(You MUST NOT use volatile functions like `datetime_current()` in schema-defined computed properties -- use `datetime_of_transaction()` or `datetime_of_statement()` instead)** **Failure to follow these rules will cause stale types, silent data bugs, or transaction isolation failures.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.