diff --git a/docs/intro/index.rst b/docs/intro/index.rst index e02b4939fcd..f07e30fe31c 100644 --- a/docs/intro/index.rst +++ b/docs/intro/index.rst @@ -9,7 +9,7 @@ Get Started :maxdepth: 3 :hidden: - quickstart + quickstart/index cli instances projects diff --git a/docs/intro/quickstart.rst b/docs/intro/quickstart.rst deleted file mode 100644 index 88e700073f3..00000000000 --- a/docs/intro/quickstart.rst +++ /dev/null @@ -1,536 +0,0 @@ -.. _ref_quickstart: - -========== -Quickstart -========== - -Welcome to |Gel|! - -This quickstart will walk you through the entire process of creating a simple -Gel-powered application: installation, defining your schema, adding some -data, and writing your first query. Let's jump in! - -.. _ref_quickstart_install: - -1. Installation -=============== - -First let's install the Gel CLI. Open a terminal and run the appropriate -command below. - -JavaScript and Python users ---------------------------- - -If you use ``npx`` or ``uvx`` you can skip the installation steps below -and use Gel CLI like this: - -.. code-block:: bash - - # JavaScript: - $ npx gel project init - - # Python - $ uvx gel project init - - -Linux ------ - -.. tabs:: - - .. code-tab:: bash - :caption: Script - - $ curl https://geldata.com/sh --proto '=https' -sSf1 | sh - - .. code-tab:: bash - :caption: APT - - $ # Import the Gel packaging key - $ sudo mkdir -p /usr/local/share/keyrings && \ - sudo curl --proto '=https' --tlsv1.2 -sSf \ - -o /usr/local/share/keyrings/gel-keyring.gpg \ - https://packages.geldata.com/keys/gel-keyring.gpg && \ - $ # Add the Gel package repository - $ echo deb [signed-by=/usr/local/share/keyrings/gel-keyring.gpg]\ - https://packages.geldata.com/apt \ - $(grep "VERSION_CODENAME=" /etc/os-release | cut -d= -f2) main \ - | sudo tee /etc/apt/sources.list.d/gel.list - $ # Install the Gel package - $ sudo apt-get update && sudo apt-get install gel-6 - - .. code-tab:: bash - :caption: YUM - - $ # Add the Gel package repository - $ sudo curl --proto '=https' --tlsv1.2 -sSfL \ - https://packages.geldata.com/rpm/gel-rhel.repo \ - > /etc/yum.repos.d/gel.repo - $ # Install the Gel package - $ sudo yum install gel-6 - -macOS ------ - -.. tabs:: - - .. code-tab:: bash - :caption: Script - - $ curl https://geldata.com/sh --proto '=https' -sSf1 | sh - - .. code-tab:: bash - :caption: Homebrew - - $ # Add the Gel tap to your Homebrew - $ brew tap geldata/tap - $ # Install Gel CLI - $ brew install gel-cli - -Windows (Powershell) --------------------- - -.. note:: - - Gel on Windows requires WSL 2 to create local instances because the - Gel server runs on Linux. It is *not* required if you will use the CLI - only to manage Gel Cloud and/or other remote instances. This quickstart - *does* create local instances, so WSL 2 is required to complete the - quickstart. - -.. code-block:: powershell - - PS> iwr https://geldata.com/ps1 -useb | iex - -.. note:: Command prompt installation - - To install Gel in the Windows Command prompt, follow these steps: - - 1. `Download the CLI `__ - - 2. Navigate to the download location in the command prompt - - 3. Run the installation command: - - .. code-block:: - - gel-cli.exe _self_install - -The script installation methods download and execute a bash script that -installs the |gelcmd| CLI on your machine. You may be asked for your -password. Once the installation completes, you may need to **restart your -terminal** before you can use the |gelcmd| command. - -Now let's set up your Gel project. - -.. _ref_quickstart_createdb: - -2. Initialize a project -======================= - -In a terminal, create a new directory and ``cd`` into it. - -.. code-block:: bash - - $ mkdir quickstart - $ cd quickstart - -Then initialize your Gel project: - -.. code-block:: bash - - $ gel project init - -This starts an interactive tool that walks you through the process of setting -up your first Gel instance. You should see something like this: - -.. code-block:: bash - - $ gel project init - No `tel.toml` found in `/path/to/quickstart` or above - Do you want to initialize a new project? [Y/n] - > Y - Specify the name of Gel instance to use with this project - [default: quickstart]: - > quickstart - Checking Gel versions... - Specify the version of Gel to use with this project [default: x.x]: - > x.x - Specify branch name: [default: main]: - > main - ┌─────────────────────┬───────────────────────────────────────────────┐ - │ Project directory │ ~/path/to/quickstart │ - │ Project config │ ~/path/to/quickstart/gel.toml │ - │ Schema dir (empty) │ ~/path/to/quickstart/dbschema │ - │ Installation method │ portable package │ - │ Version │ x.x+cc4f3b5 │ - │ Instance name │ quickstart │ - └─────────────────────┴───────────────────────────────────────────────┘ - Downloading package... - 00:00:01 [====================] 41.40 MiB/41.40 MiB 32.89MiB/s | ETA: 0s - Successfully installed x.x+cc4f3b5 - Initializing Gel instance... - Applying migrations... - Everything is up to date. Revision initial - Project initialized. - To connect to quickstart, run `gel` - - -This did a couple things. - -1. First, it scaffolded your project by creating an - :ref:`ref_reference_gel_toml` config file and a schema file - :dotgel:`dbschema/default`. In the next section, you'll define a schema in - :dotgel:`default`. - -2. Second, it spun up an Gel instance called ``quickstart`` and "linked" it - to the current directory. As long as you're inside the project - directory, all CLI commands will be executed against this - instance. For more details on how Gel projects work, check out the - :ref:`Managing instances ` guide. - -.. note:: - - Quick note! You can have several **instances** of Gel running on your - computer simultaneously. Each instance may be **branched** many times. Each - branch may have an independent schema consisting of a number of **modules** - (though commonly your schema will be entirely defined inside the ``default`` - module). - -Let's connect to our new instance! Run |gelcmd| in your terminal to open an -interactive REPL to your instance. You're now connected to a live Gel -instance running on your computer! Try executing a simple query (``select 1 + 1;``) after the -REPL prompt (``quickstart:main>``): - -.. code-block:: edgeql-repl - - quickstart:main> select 1 + 1; - {2} - -Run ``\q`` to exit the REPL. More interesting queries are coming soon, -promise! But first we need to set up a schema. - -.. _ref_quickstart_createdb_sdl: - -3. Set up your schema -===================== - -Open the ``quickstart`` directory in your IDE or editor of choice. You should -see the following file structure. - -.. code-block:: - - /path/to/quickstart - ├── gel.toml - ├── dbschema - │ ├── default.gel - │ ├── migrations - -|Gel| schemas are defined with a dedicated schema definition language called -(predictably) Gel SDL (or just **SDL** for short). It's an elegant, -declarative way to define your data model. - -SDL lives inside |.gel| files. Commonly, your entire schema will be -declared in a file called :dotgel:`default` but you can split your schema -across several |.gel| files if you prefer. - -.. note:: - - Syntax-highlighter packages/extensions for |.gel| files are available - for - `Visual Studio Code `_, - `Sublime Text `_, - `Atom `_, - and `Vim `_. - -Let's build a simple movie database. We'll need to define two **object types** -(equivalent to a *table* in SQL): Movie and Person. Open -:dotgel:`dbschema/default` in your editor of choice and paste the following: - -.. code-block:: sdl - - module default { - type Person { - required name: str; - } - - type Movie { - title: str; - multi actors: Person; - } - }; - - -A few things to note here. - -- Our types don't contain an ``id`` property; Gel automatically - creates this property and assigns a unique UUID to every object inserted - into the database. -- The ``Movie`` type includes a **link** named ``actors``. In Gel, links are - used to represent relationships between object types. They eliminate the need - for foreign keys; later, you'll see just how easy it is to write "deep" - queries without JOINs. -- The object types are inside a ``module`` called ``default``. You can split - up your schema into logical subunits called modules, though it's common to - define the entire schema in a single module called ``default``. - -Now we're ready to run a migration to apply this schema to the database. - -4. Run a migration -================== - -Generate a migration file with :gelcmd:`migration create`. This command -gathers up our :dotgel:`*` files and sends them to the database. The *database -itself* parses these files, compares them against its current schema, and -generates a migration plan! Then the database sends this plan back to the CLI, -which creates a migration file. - -.. code-block:: bash - - $ gel migration create - Created ./dbschema/migrations/00001.edgeql (id: ) - -.. note:: - - If you're interested, open this migration file to see what's inside! It's - a simple EdgeQL script consisting of :ref:`DDL ` commands like - ``create type``, ``alter type``, and ``create property``. - -The migration file has been *created* but we haven't *applied it* against the -database. Let's do that. - -.. code-block:: bash - - $ gel migrate - Applied m1k54jubcs62wlzfebn3pxwwngajvlbf6c6qfslsuagkylg2fzv2lq (00001.edgeql) - -Looking good! Let's make sure that worked by running :gelcmd:`list types` on -the command line. This will print a table containing all currently-defined -object types. - -.. code-block:: bash - - $ gel list types - ┌─────────────────┬──────────────────────────────┐ - │ Name │ Extending │ - ├─────────────────┼──────────────────────────────┤ - │ default::Movie │ std::BaseObject, std::Object │ - │ default::Person │ std::BaseObject, std::Object │ - └─────────────────┴──────────────────────────────┘ - - -.. _ref_quickstart_migrations: - -.. _Migrate your schema: - -Before we proceed, let's try making a small change to our schema: making the -``title`` property of ``Movie`` required. First, update the schema file: - -.. code-block:: sdl-diff - - type Movie { - - title: str; - + required title: str; - multi actors: Person; - } - -Then create another migration. Because this isn't the initial migration, we -see something a little different than before. - -.. code-block:: bash - - $ gel migration create - did you make property 'title' of object type 'default::Movie' - required? [y,n,l,c,b,s,q,?] - > - -As before, Gel parses the schema files and compared them against its -current internal schema. It correctly detects the change we made, and prompts -us to confirm it. This interactive process lets you sanity check every change -and provide guidance when a migration is ambiguous (e.g. when a property is -renamed). - -Enter ``y`` to confirm the change. - -.. code-block:: bash - - $ gel migration create - did you make property 'title' of object type 'default::Movie' - required? [y,n,l,c,b,s,q,?] - > y - Please specify an expression to populate existing objects in - order to make property 'title' of object type 'default::Movie' required: - fill_expr> {} - -Hm, now we're seeing another prompt. Because ``title`` is changing from -*optional* to *required*, Gel is asking us what to do for all the ``Movie`` -objects that don't currently have a value for ``title`` defined. We'll just -specify a placeholder value of "Untitled". Replace the ``{}`` value -with ``"Untitled"`` and press Enter. - -.. code-block:: - - fill_expr> "Untitled" - Created dbschema/migrations/00002.edgeql (id: ) - - -If we look at the generated migration file, we see it contains the following -lines: - -.. code-block:: edgeql - - ALTER TYPE default::Movie { - ALTER PROPERTY title { - SET REQUIRED USING ('Untitled'); - }; - }; - -Let's wrap up by applying the new migration. - -.. code-block:: bash - - $ gel migrate - Applied m1rd2ikgwdtlj5ws7ll6rwzvyiui2xbrkzig4adsvwy2sje7kxeh3a (00002.edgeql) - -.. _ref_quickstart_insert_data: - -.. _Insert data: - -.. _Run some queries: - -5. Write some queries -===================== - -Let's write some simple queries via *Gel UI*, the admin dashboard baked -into every Gel instance. To open the dashboard: - -.. code-block:: bash - - $ gel ui - Opening URL in browser: - http://localhost:107xx/ui?authToken= - -You should see a simple landing page, as below. You'll see a card for each -branch of your instance. Remember: each instance can be branched multiple -times! - -.. image:: images/ui_landing.jpg - :width: 100% - -Currently, there's only one branch, which is simply called |main| by -default. Click the |main| card. - -.. image:: images/ui_db.jpg - :width: 100% - -Then click ``Open Editor`` so we can start writing some queries. We'll start -simple: ``select "Hello world!";``. Click ``RUN`` to execute the query. - -.. image:: images/ui_hello.jpg - :width: 100% - -The result of the query will appear on the right. - -The query will also be added to your history of previous queries, which can be -accessed via the "HISTORY" tab located on the lower left side of the editor. - -Now let's actually ``insert`` an object into our database. Copy the following -query into the query textarea and hit ``Run``. - -.. code-block:: edgeql - - insert Movie { - title := "Dune" - }; - -Nice! You've officially inserted the first object into your database! Let's -add a couple cast members with an ``update`` query. - -.. code-block:: edgeql - - update Movie - filter .title = "Dune" - set { - actors := { - (insert Person { name := "Timothee Chalamet" }), - (insert Person { name := "Zendaya" }) - } - }; - -Finally, we can run a ``select`` query to fetch all the data we just inserted. - -.. code-block:: edgeql - - select Movie { - title, - actors: { - name - } - }; - -Click the outermost ``COPY`` button in the top right of the query result area -to copy the result of this query to your clipboard as JSON. The copied text -will look something like this: - -.. code-block:: json - - [ - { - "title": "Dune", - "actors": [ - { - "name": "Timothee Chalamet" - }, - { - "name": "Zendaya" - } - ] - } - ] - -|Gel| UI is a useful development tool, but in practice your application will -likely be using one of Gel's *client libraries* to execute queries. Gel -provides official libraries for many langauges: - -- :ref:`JavaScript/TypeScript ` -- :ref:`Go ` -- :ref:`Python ` - -.. XXX: link to third-party doc websites -.. - :ref:`Rust ` -.. - :ref:`C# and F# ` -.. - :ref:`Java ` -.. - :ref:`Dart ` -.. - :ref:`Elixir ` - -Check out the :ref:`Clients ` guide to get -started with the language of your choice. - -.. _ref_quickstart_onwards: - -.. _Computeds: - -Onwards and upwards -=================== - -You now know the basics of Gel! You've installed the CLI and database, set -up a local project, run a couple migrations, inserted and queried some data, -and used a client library. - -- For a more in-depth exploration of each topic covered here, continue reading - the other pages in the Getting Started section, which will cover important - topics like migrations, the schema language, and EdgeQL in greater detail. - -.. XXX: -.. - For guided tours of major concepts, check out the showcase pages for `Data -.. Modeling `_, `EdgeQL -.. `_, and `Migrations -.. `_. - -- To start building an application using the language of your choice, check out - our client libraries: - - - :ref:`JavaScript/TypeScript ` - - :ref:`Go ` - - :ref:`Python ` diff --git a/docs/intro/quickstart/connecting.rst b/docs/intro/quickstart/connecting.rst new file mode 100644 index 00000000000..36838dd4f01 --- /dev/null +++ b/docs/intro/quickstart/connecting.rst @@ -0,0 +1,97 @@ +.. _ref_quickstart_connecting: + +========================== +Connecting to the database +========================== + +.. edb:split-section:: + + Before diving into the application, let's take a quick look at how to connect to the database from your code. We will intialize a client and use it to make a simple, static query to the database, and log the result to the console. + + .. note:: + + Notice that the ``createClient`` function isn't being passed any connection details. With |Gel|, you do not need to come up with your own scheme for how to build the correct database connection credentials and worry about leaking them into your code. You simply use |Gel| "projects" for local development, and set the appropriate environment variables in your deployment environments, and the ``createClient`` function knows what to do! + + .. edb:split-point:: + + .. code-block:: typescript + :caption: ./test.ts + + import { createClient } from "gel"; + + const client = createClient(); + + async function main() { + console.log(await client.query("select 'Hello from Gel!';")); + } + + main().then( + () => process.exit(0), + (err) => { + console.error(err); + process.exit(1); + } + ); + + + .. code-block:: sh + + $ npx tsx test.ts + [ 'Hello from Gel!' ] + +.. edb:split-section:: + + + With TypeScript, there are three ways to run a query: use a string EdgeQL query, use the ``queries`` generator to turn a string of EdgeQL into a TypeScript function, or use the query builder API to build queries dynamically in a type-safe manner. In this tutorial, you will use the TypeScript query builder API. + + This query builder must be regenerated any time the schema changes, so a hook has been added to the ``gel.toml`` file to generate the query builder any time the schema is updated. Moving beyond this simple query, use the query builder API to insert a few ``Deck`` objects into the database, and then select them back. + + .. edb:split-point:: + + .. code-block:: typescript-diff + :caption: ./test.ts + + import { createClient } from "gel"; + + import e from "@/dbschema/edgeql-js"; + + const client = createClient(); + + async function main() { + console.log(await client.query("select 'Hello from Gel!';")); + + + await e.insert(e.Deck, { name: "I am one" }).run(client); + + + + await e.insert(e.Deck, { name: "I am two" }).run(client); + + + + const decks = await e + + .select(e.Deck, () => ({ + + id: true, + + name: true, + + })) + + .run(client); + + + + console.table(decks); + + + + await e.delete(e.Deck).run(client); + } + + main().then( + () => process.exit(0), + (err) => { + console.error(err); + process.exit(1); + } + ); + + .. code-block:: sh + + $ npx tsx test.ts + [ 'Hello from Gel!' ] + ┌─────────┬────────────────────────────────────────┬────────────┐ + │ (index) │ id │ name │ + ├─────────┼────────────────────────────────────────┼────────────┤ + │ 0 │ 'f4cd3e6c-ea75-11ef-83ec-037350ea8a6e' │ 'I am one' │ + │ 1 │ 'f4cf27ae-ea75-11ef-83ec-3f7b2fceab24' │ 'I am two' │ + └─────────┴────────────────────────────────────────┴────────────┘ + +Now that you know how to connect to the database, you will see that we have provided an initialized ``Client`` object in the ``/lib/gel.ts`` module. Throughout the rest of the tutorial, you will import this ``Client`` object and use it to make queries. diff --git a/docs/intro/quickstart/images/flashcards-import.png b/docs/intro/quickstart/images/flashcards-import.png new file mode 100644 index 00000000000..8b3d66a2f06 Binary files /dev/null and b/docs/intro/quickstart/images/flashcards-import.png differ diff --git a/docs/intro/quickstart/images/schema-ui.png b/docs/intro/quickstart/images/schema-ui.png new file mode 100644 index 00000000000..3f197e8baeb Binary files /dev/null and b/docs/intro/quickstart/images/schema-ui.png differ diff --git a/docs/intro/quickstart/images/timestamped.png b/docs/intro/quickstart/images/timestamped.png new file mode 100644 index 00000000000..5d3f523a36b Binary files /dev/null and b/docs/intro/quickstart/images/timestamped.png differ diff --git a/docs/intro/quickstart/index.rst b/docs/intro/quickstart/index.rst new file mode 100644 index 00000000000..a9dd7543131 --- /dev/null +++ b/docs/intro/quickstart/index.rst @@ -0,0 +1,62 @@ +.. _ref_quickstart: + +========== +Quickstart +========== + +.. toctree:: + :maxdepth: 1 + :hidden: + + setup + modeling + connecting + working + inheritance + + +Welcome to the quickstart tutorial! In this tutorial, you will update a simple Next.js application to use |Gel| as your data layer. The application will let users build and manage their own study decks, with each flashcard featuring customizable text on both sides - making it perfect for studying, memorization practice, or creating educational games. + +Don't worry if you're new to |Gel| - you will be up and running with a working Next.js application and a local |Gel| database in just about **5 minutes**. From there, you will replace the static mock data with a |Gel| powered data layer in roughly 30-45 minutes. + +By the end of this tutorial, you will be comfortable with: + +* Creating and updating a database schema +* Running migrations to evolve your data +* Writing EdgeQL queries in text and via a TypeScript query builder +* Building an app backed by |Gel| + +Features of the flashcards app +------------------------------ + +* Create, edit, and delete decks +* Add/remove cards with front/back content +* Simple Next.js + Tailwind UI +* Clean, type-safe schema with |Gel| + +Requirements +------------ + +Before you start, you need: + +* Basic familiarity with TypeScript, Next.js, and React +* Node.js 20+ on a Unix-like OS (Linux, macOS, or WSL) +* A code editor you love + +Why |Gel| for Next.js? +---------------------- + +* **Type Safety**: Catch data errors before runtime +* **Rich Modeling**: Use object types and links to model relations +* **Modern Tooling**: TypeScript-friendly schemas and migrations +* **Performance**: Efficient queries for complex data +* **Developer Experience**: An intuitive query language (EdgeQL) + +Need Help? +---------- + +If you run into issues while following this tutorial: + +* Check the `Gel documentation `_ +* Visit our `community Discord `_ +* File an issue on `GitHub `_ diff --git a/docs/intro/quickstart/inheritance.rst b/docs/intro/quickstart/inheritance.rst new file mode 100644 index 00000000000..520b4d33dad --- /dev/null +++ b/docs/intro/quickstart/inheritance.rst @@ -0,0 +1,113 @@ +.. _ref_quickstart_inheritance: + +======================== +Adding shared properties +======================== + +.. edb:split-section:: + + One common pattern in applications is to add shared properties to the schema that are used by multiple objects. For example, you might want to add a ``created_at`` and ``updated_at`` property to every object in your schema. You can do this by adding an abstract type and using it as a mixin for your other object types. + + .. code-block:: sdl-diff + :caption: dbschema/default.gel + + module default { + + abstract type Timestamped { + + required created_at: datetime { + + default := datetime_of_statement(); + + }; + + required updated_at: datetime { + + default := datetime_of_statement(); + + }; + + } + + + - type Deck { + + type Deck extending Timestamped { + required name: str; + description: str; + + multi cards: Card { + constraint exclusive; + on target delete allow; + }; + }; + + - type Card { + + type Card extending Timestamped { + required order: int64; + required front: str; + required back: str; + } + } + +.. edb:split-section:: + + Since you don't have historical data for when these objects were actually created or modified, the migration will fall back to the default values set in the ``Timestamped`` type. + + .. code-block:: sh + + $ npx gel migration create + did you create object type 'default::Timestamped'? [y,n,l,c,b,s,q,?] + > y + did you alter object type 'default::Card'? [y,n,l,c,b,s,q,?] + > y + did you alter object type 'default::Deck'? [y,n,l,c,b,s,q,?] + > y + Created /home/strinh/projects/flashcards/dbschema/migrations/00004-m1d2m5n.edgeql, id: m1d2m5n5ajkalyijrxdliioyginonqbtfzihvwdfdmfwodunszstya + + $ npx gel migrate + Applying m1d2m5n5ajkalyijrxdliioyginonqbtfzihvwdfdmfwodunszstya (00004-m1d2m5n.edgeql) + ... parsed + ... applied + Generating query builder... + Detected tsconfig.json, generating TypeScript files. + To override this, use the --target flag. + Run `npx @gel/generate --help` for full options. + Introspecting database schema... + Generating runtime spec... + Generating cast maps... + Generating scalars... + Generating object types... + Generating function types... + Generating operators... + Generating set impl... + Generating globals... + Generating index... + Writing files to ./dbschema/edgeql-js + Generation complete! 🤘 + +.. edb:split-section:: + + Update the ``getDecks`` query to sort the decks by ``updated_at`` in descending order. + + .. code-block:: typescript-diff + :caption: app/queries.ts + + import { client } from "@/lib/gel"; + import e from "@/dbschema/edgeql-js"; + + export async function getDecks() { + const decks = await e.select(e.Deck, (deck) => ({ + id: true, + name: true, + description: true, + cards: e.select(deck.cards, (card) => ({ + id: true, + front: true, + back: true, + order_by: card.order, + })), + + order_by: { + + expression: deck.updated_at, + + direction: e.DESC, + + }, + })).run(client); + + return decks; + } + +.. edb:split-section:: + + Now when you look at the data in the UI, you will see the new properties on each of your object types. + + .. image:: images/timestamped.png diff --git a/docs/intro/quickstart/modeling.rst b/docs/intro/quickstart/modeling.rst new file mode 100644 index 00000000000..ce54e63690d --- /dev/null +++ b/docs/intro/quickstart/modeling.rst @@ -0,0 +1,108 @@ +.. _ref_quickstart_modeling: + +================= +Modeling the data +================= + +.. edb:split-section:: + + The flashcards application has a simple data model, but it's interesting enough to utilize many unique features of the |Gel| schema language. + + Looking at the mock data in our example JSON file ``./deck-edgeql.json``, you can see this structure in the JSON. There is a ``Card`` type that describes a single flashcard, which contains two required string properties: ``front`` and ``back``. Each ``Deck`` object has a link to zero or more ``Card`` objects in an array. + + .. code-block:: typescript + + interface Card { + front: string; + back: string; + } + + interface Deck { + name: string; + description: string | null; + cards: Card[]; + } + +.. edb:split-section:: + + Starting with this simple model, add these types to the :dotgel:`dbschema/default` schema file. As you can see, the types closely mirror the JSON mock data. + + Also of note, the link between ``Card`` and ``Deck`` objects creates a "1-to-n" relationship, where each ``Deck`` object has a link to zero or more ``Card`` objects. When you query the ``Deck.cards`` link, the cards will be unordered, so the ``Card`` type needs an explicit ``order`` property to allow sorting them at query time. + + By default, when you try to delete an object that is linked to another object, the database will prevent you from doing so. We want to support removing a ``Card``, so we define a deletion policy on the ``cards`` link that allows deleting the target of this link. + + .. code-block:: sdl-diff + :caption: dbschema/default.gel + + module default { + + type Card { + + required order: int64; + + required front: str; + + required back: str; + + }; + + + + type Deck { + + required name: str; + + description: str; + + multi cards: Card { + + constraint exclusive; + + on target delete allow; + + }; + + }; + }; + +.. edb:split-section:: + + Congratulations! This first version of the data model's schema is *stored in a file on disk*. Now you need to signal the database to actually create types for ``Deck`` and ``Card`` in the database. + + To make |Gel| do that, you need to do two quick steps: + + 1. **Create a migration**: a "migration" is a file containing a set of low level instructions that define how the database schema should change. It records any additions, modifications, or deletions to your schema in a way that the database can understand. + + .. note:: + + When you are changing existing schema, the CLI migration tool might ask questions to ensure that it understands your changes exactly. Since the existing schema was empty, the CLI will skip asking any questions and simply create the migration file. + + 2. **Apply the migration**: This executes the migration file on the database, instructing |Gel| to implement the recorded changes in the database. Essentially, this step updates the database structure to match your defined schema, ensuring that the ``Deck`` and ``Card`` types are created and ready for use. + + .. note:: + + Notice that after the migration is applied, the CLI will automatically run the script to generate the query builder. This is a convenience feature that is enabled by the ``schema.update.after`` hook in the ``gel.toml`` file. + + .. code-block:: sh + + $ npx gel migration create + Created ./dbschema/migrations/00001-m125ajr.edgeql, id: m125ajrbqp7ov36s7aniefxc376ofxdlketzspy4yddd3hrh4lxmla + $ npx gel migrate + Applying m125ajrbqp7ov36s7aniefxc376ofxdlketzspy4yddd3hrh4lxmla (00001-m125ajr.edgeql) + ... parsed + ... applied + Generating query builder... + Detected tsconfig.json, generating TypeScript files. + To override this, use the --target flag. + Run `npx @gel/generate --help` for full options. + Introspecting database schema... + Generating runtime spec... + Generating cast maps... + Generating scalars... + Generating object types... + Generating function types... + Generating operators... + Generating set impl... + Generating globals... + Generating index... + Writing files to ./dbschema/edgeql-js + Generation complete! 🤘 + + +.. edb:split-section:: + + Take a look at the schema you've generated in the built-in database UI. Use this tool to visualize your data model and see the object types and links you've defined. + + .. edb:split-point:: + + .. code-block:: sh + + $ npx gel ui + + .. image:: images/schema-ui.png diff --git a/docs/intro/quickstart/setup.rst b/docs/intro/quickstart/setup.rst new file mode 100644 index 00000000000..371fb5bd57d --- /dev/null +++ b/docs/intro/quickstart/setup.rst @@ -0,0 +1,89 @@ +.. _ref_quickstart_setup: + +=========================== +Setting up your environment +=========================== + +.. edb:split-section:: + + Use git to clone `the Next.js starter template `_ into a new directory called ``flashcards``. This will create a fully configured Next.js project and a local |Gel| instance with an empty schema. You will see the database instance being created and the project being initialized. You are now ready to start building the application. + + .. code-block:: sh + + $ git clone \ + git@github.com:geldata/quickstart-nextjs.git \ + flashcards + $ cd flashcards + $ npm install + $ npx gel project init + + +.. edb:split-section:: + + Explore the empty database by starting our REPL from the project root. + + .. code-block:: sh + + $ npx gel + +.. edb:split-section:: + + Try the following queries which will work without any schema defined. + + .. code-block:: edgeql-repl + + db> select 42; + {42} + db> select sum({1, 2, 3}); + {6} + db> with cards := { + ... ( + ... front := "What is the highest mountain in the world?", + ... back := "Mount Everest", + ... ), + ... ( + ... front := "Which ocean contains the deepest trench on Earth?", + ... back := "The Pacific Ocean", + ... ), + ... } + ... select cards order by random() limit 1; + { + ( + front := "What is the highest mountain in the world?", + back := "Mount Everest", + ) + } + +.. edb:split-section:: + + Fun! You will create a proper data model for the application in the next step, but for now, take a look around the project you've just created. Most of the project files will be familiar if you've worked with Next.js before. Here are the files that integrate |Gel|: + + - ``gel.toml``: The configuration file for the Gel project instance. Notice that we have a ``hooks.migration.apply.after`` hook that will run ``npx @gel/generate edgeql-js`` after migrations are applied. This will generate the query builder code that you'll use to interact with the database. More details on that to come! + - ``dbschema/``: This directory contains the schema for the database, and later supporting files like migrations, and generated code. + - :dotgel:`dbschema/default`: The default schema file that you'll use to define your data model. It is empty for now, but you'll add your data model to this file in the next step. + - ``lib/gel.ts``: A utility module that exports the Gel client, which you'll use to interact with the database. + + .. tabs:: + + .. code-tab:: toml + :caption: gel.toml + + [instance] + server-version = 6.0 + + [hooks] + schema.update.after = "npx @gel/generate edgeql-js" + + .. code-tab:: sdl + :caption: dbschema/default.gel + + module default { + + } + + .. code-tab:: typescript + :caption: lib/gel.ts + + import { createClient } from "gel"; + + export const client = createClient(); diff --git a/docs/intro/quickstart/working.rst b/docs/intro/quickstart/working.rst new file mode 100644 index 00000000000..3aee348a036 --- /dev/null +++ b/docs/intro/quickstart/working.rst @@ -0,0 +1,635 @@ +.. _ref_quickstart_working: + +===================== +Working with the data +===================== + +In this section, you will update the existing application to use |Gel| to store and query data, instead of a static JSON file. Having a working application with mock data allows you to focus on learning how |Gel| works, without getting bogged down by the details of the application. + +Bulk importing of data +====================== + +.. edb:split-section:: + + Begin by updating the server action to import a deck with cards. Loop through each card in the deck and insert it, building an array of IDs as you go. This array of IDs will be used to set the ``cards`` link on the ``Deck`` object after all cards have been inserted. + + The array of card IDs is initially an array of strings. To satisfy the |Gel| type system, which expects the ``id`` property of ``Card`` objects to be a ``uuid`` rather than a ``str``, you need to cast the array of strings to an array of UUIDs. Use the ``e.literal(e.array(e.uuid), cardIds)`` function to perform this casting. + + The function ``e.includes(cardIdsLiteral, c.id)`` from our standard library checks if a value is present in an array and returns a boolean. When inserting the ``Deck`` object, set the ``cards`` to the result of selecting only the ``Card`` objects whose ``id`` is included in the ``cardIds`` array. + + .. code-block:: typescript-diff + :caption: app/actions.ts + + "use server"; + + - import { readFile, writeFile } from "node:fs/promises"; + + import { client } from "@/lib/gel"; + + import e from "@/dbschema/edgeql-js"; + import { revalidatePath } from "next/cache"; + - import { RawJSONDeck, Deck } from "@/lib/models"; + + import { RawJSONDeck } from "@/lib/models"; + + export async function importDeck(formData: FormData) { + const file = formData.get("file") as File; + const rawDeck = JSON.parse(await file.text()) as RawJSONDeck; + const deck = { + ...rawDeck, + - id: crypto.randomUUID(), + - cards: rawDeck.cards.map((card) => ({ + + cards: rawDeck.cards.map((card, index) => ({ + ...card, + - id: crypto.randomUUID(), + + order: index, + })), + }; + - + - const existingDecks = JSON.parse( + - await readFile("./decks.json", "utf-8") + - ) as Deck[]; + - + - await writeFile( + - "./decks.json", + - JSON.stringify([...existingDecks, deck], null, 2) + - ); + + const cardIds: string[] = []; + + for (const card of deck.cards) { + + const createdCard = await e + + .insert(e.Card, { + + front: card.front, + + back: card.back, + + order: card.order, + + }) + + .run(client); + + + + cardIds.push(createdCard.id); + + } + + + + const cardIdsLiteral = e.literal(e.array(e.uuid), cardIds); + + + + await e.insert(e.Deck, { + + name: deck.name, + + description: deck.description, + + cards: e.select(e.Card, (c) => ({ + + filter: e.contains(cardIdsLiteral, c.id), + + })), + + }).run(client); + + revalidatePath("/"); + } + +.. edb:split-section:: + + This works, but you might notice that it is not atomic. For instance, if one of the ``Card`` objects fails to insert, the entire operation will fail and the ``Deck`` will not be inserted, but some data will still linger. To make this operation atomic, update the ``importDeck`` action to use a transaction. + + .. code-block:: typescript-diff + :caption: app/actions.ts + + "use server"; + + import { client } from "@/lib/gel"; + import e from "@/dbschema/edgeql-js"; + import { revalidatePath } from "next/cache"; + import { RawJSONDeck } from "@/lib/models"; + + export async function importDeck(formData: FormData) { + const file = formData.get("file") as File; + const rawDeck = JSON.parse(await file.text()) as RawJSONDeck; + const deck = { + ...rawDeck, + cards: rawDeck.cards.map((card, index) => ({ + ...card, + order: index, + })), + }; + + await client.transaction(async (tx) => { + const cardIds: string[] = []; + for (const card of deck.cards) { + const createdCard = await e + .insert(e.Card, { + front: card.front, + back: card.back, + order: card.order, + }) + - .run(client); + + .run(tx); + + cardIds.push(createdCard.id); + } + + const cardIdsLiteral = e.literal(e.array(e.uuid), cardIds); + + await e.insert(e.Deck, { + name: deck.name, + description: deck.description, + cards: e.select(e.Card, (c) => ({ + filter: e.contains(cardIdsLiteral, c.id), + })), + - }).run(client); + + }).run(tx); + + }); + + revalidatePath("/"); + } + +.. edb:split-section:: + + You might think this is as good as it gets, and many ORMs will create a similar set of queries. However, with the query builder, you can improve this by crafting a single query that inserts the ``Deck`` and ``Card`` objects, along with their links, in one efficient query. + + The first thing to notice is that the ``e.params`` function is used to define parameters for your query instead of embedding literal values directly. This approach eliminates the need for casting, as was necessary with the ``cardIds`` array. By defining the ``cards`` parameter as an array of tuples, you ensure full type safety with both TypeScript and the database. + + Another key feature of this query builder expression is the ``e.for(e.array_unpack(params.cards), (card) => {...})`` construct. This expression converts the array of tuples into a set of tuples and generates a set containing an expression for each element. Essentially, you assign the ``Deck.cards`` set of ``Card`` objects to the result of inserting each element from the ``cards`` array. This is similar to what you were doing before by selecting all ``Card`` objects by their ``id``, but is more efficient since you are inserting the ``Deck`` and all ``Card`` objects in one query. + + .. code-block:: typescript-diff + :caption: app/actions.ts + + "use server"; + + import { client } from "@/lib/gel"; + import e from "@/dbschema/edgeql-js"; + import { revalidatePath } from "next/cache"; + import { RawJSONDeck } from "@/lib/models"; + + export async function importDeck(formData: FormData) { + const file = formData.get("file") as File; + const rawDeck = JSON.parse(await file.text()) as RawJSONDeck; + const deck = { + ...rawDeck, + cards: rawDeck.cards.map((card, index) => ({ + ...card, + order: index, + })), + }; + - await client.transaction(async (tx) => { + - const cardIds: string[] = []; + - for (const card of deck.cards) { + - const createdCard = await e + - .insert(e.Card, { + - front: card.front, + - back: card.back, + - order: card.order, + - }) + - .run(tx); + - + - cardIds.push(createdCard.id); + - } + - + - const cardIdsLiteral = e.literal(e.array(e.uuid), cardIds); + - + - await e.insert(e.Deck, { + - name: deck.name, + - description: deck.description, + - cards: e.select(e.Card, (c) => ({ + - filter: e.contains(cardIdsLiteral, c.id), + - })), + - }).run(tx); + - }); + + await e + + .params( + + { + + name: e.str, + + description: e.optional(e.str), + + cards: e.array(e.tuple({ front: e.str, back: e.str, order: e.int64 })), + + }, + + (params) => + + e.insert(e.Deck, { + + name: params.name, + + description: params.description, + + cards: e.for(e.array_unpack(params.cards), (card) => + + e.insert(e.Card, { + + front: card.front, + + back: card.back, + + order: card.order, + + }) + + ), + + }) + + ) + + .run(client, deck); + + revalidatePath("/"); + } + +Updating data +============= + +.. edb:split-section:: + + Next, you will update the Server Actions for each ``Deck`` object: ``updateDeck``, ``addCard``, and ``deleteCard``. Start with ``updateDeck``, which is the most complex because it is dynamic. You can set either the ``title`` or ``description`` fields in an update. Use the dynamic nature of the query builder to generate separate queries based on which fields are present in the form data. + + This may seem a bit intimidating at first, but the key to making this query dynamic is the ``nameSet`` and ``descriptionSet`` variables. These variables conditionally add the ``name`` or ``description`` fields to the ``set`` parameter of the ``update`` call. + + .. code-block:: typescript-diff + :caption: app/deck/[id]/actions.ts + + "use server"; + + import { revalidatePath } from "next/cache"; + import { readFile, writeFile } from "node:fs/promises"; + + import { client } from "@/lib/gel"; + + import e from "@/dbschema/edgeql-js"; + import { Deck } from "@/lib/models"; + + export async function updateDeck(formData: FormData) { + const id = formData.get("id"); + const name = formData.get("name"); + const description = formData.get("description"); + + if ( + typeof id !== "string" || + (typeof name !== "string" && + typeof description !== "string") + ) { + return; + } + + - const decks = JSON.parse( + - await readFile("./decks.json", "utf-8") + - ) as Deck[]; + - decks[index].name = name ?? decks[index].name; + + const nameSet = typeof name === "string" ? { name } : {}; + - decks[index].description = description ?? decks[index].description; + + const descriptionSet = + + typeof description === "string" ? { description: description || null } : {}; + + + await e + + .update(e.Deck, (d) => ({ + + filter_single: e.op(d.id, "=", e.uuid(id)), + + set: { + + ...nameSet, + + ...descriptionSet, + + }, + + })).run(client); + - await writeFile("./decks.json", JSON.stringify(decks, null, 2)); + revalidatePath(`/deck/${id}`); + } + + export async function addCard(formData: FormData) { + const deckId = formData.get("deckId"); + const front = formData.get("front"); + const back = formData.get("back"); + + if ( + typeof deckId !== "string" || + typeof front !== "string" || + typeof back !== "string" + ) { + return; + } + + const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + + const deck = decks.find((deck) => deck.id === deckId); + if (!deck) { + return; + } + + deck.cards.push({ front, back, id: crypto.randomUUID() }); + await writeFile("./decks.json", JSON.stringify(decks, null, 2)); + + revalidatePath(`/deck/${deckId}`); + } + + export async function deleteCard(formData: FormData) { + const cardId = formData.get("cardId"); + + if (typeof cardId !== "string") { + return; + } + + const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + const deck = decks.find((deck) => deck.cards.some((card) => card.id === cardId)); + if (!deck) { + return; + } + + deck.cards = deck.cards.filter((card) => card.id !== cardId); + await writeFile("./decks.json", JSON.stringify(decks, null, 2)); + + revalidatePath(`/`); + } + +Adding linked data +================== + +.. edb:split-section:: + + For the ``addCard`` action, you need to insert a new ``Card`` object and update the ``Deck.cards`` set to include the new ``Card`` object. Notice that the ``order`` property is set by selecting the maximum ``order`` property of this ``Deck.cards`` set and incrementing it by 1. + + The syntax for adding an object to a set of links is ``{ "+=": object }``. You can think of this as a shortcut for setting the link set to the current set plus the new object. + + .. code-block:: typescript-diff + :caption: app/deck/[id]/actions.ts + + "use server"; + + import { revalidatePath } from "next/cache"; + import { readFile, writeFile } from "node:fs/promises"; + import { client } from "@/lib/gel"; + import e from "@/dbschema/edgeql-js"; + import { Deck } from "@/lib/models"; + + export async function updateDeck(formData: FormData) { + const id = formData.get("id"); + const name = formData.get("name"); + const description = formData.get("description"); + + if ( + typeof id !== "string" || + (typeof name !== "string" && + typeof description !== "string") + ) { + return; + } + + const nameSet = typeof name === "string" ? { name } : {}; + const descriptionSet = + typeof description === "string" ? { description: description || null } : {}; + + await e + .update(e.Deck, (d) => ({ + filter_single: e.op(d.id, "=", e.uuid(id)), + set: { + ...nameSet, + ...descriptionSet, + }, + })).run(client); + revalidatePath(`/deck/${id}`); + } + + export async function addCard(formData: FormData) { + const deckId = formData.get("deckId"); + const front = formData.get("front"); + const back = formData.get("back"); + + if ( + typeof deckId !== "string" || + typeof front !== "string" || + typeof back !== "string" + ) { + return; + } + + - const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + - + - const deck = decks.find((deck) => deck.id === deckId); + - if (!deck) { + - return; + - } + - + - deck.cards.push({ front, back, id: crypto.randomUUID() }); + - await writeFile("./decks.json", JSON.stringify(decks, null, 2)); + + await e + + .params( + + { + + front: e.str, + + back: e.str, + + deckId: e.uuid, + + }, + + (params) => { + + const deck = e.assert_exists( + + e.select(e.Deck, (d) => ({ + + filter_single: e.op(d.id, "=", params.deckId), + + })) + + ); + + + + const order = e.cast(e.int64, e.max(deck.cards.order)); + + const card = e.insert(e.Card, { + + front: params.front, + + back: params.back, + + order: e.op(order, "+", 1), + + }); + + return e.update(deck, (d) => ({ + + set: { + + cards: { + + "+=": card + + }, + + }, + + })) + + } + + ) + + .run(client, { + + front, + + back, + + deckId, + + }); + + revalidatePath(`/deck/${deckId}`); + } + + export async function deleteCard(formData: FormData) { + const cardId = formData.get("cardId"); + + if (typeof cardId !== "string") { + return; + } + + const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + const deck = decks.find((deck) => deck.cards.some((card) => card.id === cardId)); + if (!deck) { + return; + } + + deck.cards = deck.cards.filter((card) => card.id !== cardId); + await writeFile("./decks.json", JSON.stringify(decks, null, 2)); + + revalidatePath(`/`); + } + +Deleting linked data +==================== + +.. edb:split-section:: + + For the ``deleteCard`` action, delete the ``Card`` object and based on the deletion policy we set up earlier in the schema, the object will be deleted from the database and removed from the ``Deck.cards`` set. + + .. code-block:: typescript-diff + :caption: app/deck/[id]/actions.ts + + "use server"; + + import { revalidatePath } from "next/cache"; + - import { readFile, writeFile } from "node:fs/promises"; + import { client } from "@/lib/gel"; + import e from "@/dbschema/edgeql-js"; + import { Deck } from "@/lib/models"; + + export async function updateDeck(formData: FormData) { + const id = formData.get("id"); + const name = formData.get("name"); + const description = formData.get("description"); + + if ( + typeof id !== "string" || + (typeof name !== "string" && + typeof description !== "string") + ) { + return; + } + + const nameSet = typeof name === "string" ? { name } : {}; + const descriptionSet = + typeof description === "string" ? { description: description || null } : {}; + + await e + .update(e.Deck, (d) => ({ + filter_single: e.op(d.id, "=", e.uuid(id)), + set: { + ...nameSet, + ...descriptionSet, + }, + })).run(client); + revalidatePath(`/deck/${id}`); + } + + export async function addCard(formData: FormData) { + const deckId = formData.get("deckId"); + const front = formData.get("front"); + const back = formData.get("back"); + + if ( + typeof deckId !== "string" || + typeof front !== "string" || + typeof back !== "string" + ) { + return; + } + + await e + .params( + { + front: e.str, + back: e.str, + deckId: e.uuid, + }, + (params) => { + const deck = e.assert_exists( + e.select(e.Deck, (d) => ({ + filter_single: e.op(d.id, "=", params.deckId), + })) + ); + + const order = e.cast(e.int64, e.max(deck.cards.order)); + const card = e.insert(e.Card, { + front: params.front, + back: params.back, + order: e.op(order, "+", 1), + }); + return e.update(deck, (d) => ({ + set: { + cards: { + "+=": card + }, + }, + })) + } + ) + .run(client, { + front, + back, + deckId, + }); + + revalidatePath(`/deck/${deckId}`); + } + + export async function deleteCard(formData: FormData) { + const cardId = formData.get("cardId"); + + if (typeof cardId !== "string") { + return; + } + + - const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + - const deck = decks.find((deck) => deck.cards.some((card) => card.id === cardId)); + - if (!deck) { + - return; + - } + - + - deck.cards = deck.cards.filter((card) => card.id !== cardId); + - await writeFile("./decks.json", JSON.stringify(decks, null, 2)); + + await e + + .params({ id: e.uuid }, (params) => + + e.delete(e.Card, (c) => ({ + + filter_single: e.op(c.id, "=", params.id), + + })) + + ) + + .run(client, { id: cardId }); + + + + revalidatePath(`/`); + } + +Querying data +============= + +.. edb:split-section:: + + Next, update the two ``queries.ts`` methods: ``getDecks`` and ``getDeck``. + + .. tabs:: + + .. code-tab:: typescript-diff + :caption: app/queries.ts + + - import { readFile } from "node:fs/promises"; + + import { client } from "@/lib/gel"; + + import e from "@/dbschema/edgeql-js"; + - + - import { Deck } from "@/lib/models"; + + export async function getDecks() { + - const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + + const decks = await e.select(e.Deck, (deck) => ({ + + id: true, + + name: true, + + description: true, + + cards: e.select(deck.cards, (card) => ({ + + id: true, + + front: true, + + back: true, + + order_by: card.order, + + })), + + })).run(client); + + return decks; + } + + .. code-tab:: typescript-diff + :caption: app/deck/[id]/queries.ts + + - import { readFile } from "node:fs/promises"; + - import { Deck } from "@/lib/models"; + + import { client } from "@/lib/gel"; + + import e from "@/dbschema/edgeql-js"; + + export async function getDeck({ id }: { id: string }) { + - const decks = JSON.parse(await readFile("./decks.json", "utf-8")) as Deck[]; + - return decks.find((deck) => deck.id === id) ?? null; + + return await e + + .select(e.Deck, (deck) => ({ + + filter_single: e.op(deck.id, "=", e.uuid(id)), + + id: true, + + name: true, + + description: true, + + cards: e.select(deck.cards, (card) => ({ + + id: true, + + front: true, + + back: true, + + order_by: card.order, + + })), + + })) + + .run(client); + } + +.. edb:split-section:: + + In a terminal, run the Next.js development server. + + .. code-block:: sh + + $ npm run dev + +.. edb:split-section:: + + A static JSON file to seed your database with a deck of trivia cards is included in the project. Open your browser and navigate to the app at ``_. Use the "Import JSON" button to import this JSON file into your database. + + .. image:: images/flashcards-import.png diff --git a/edb/tools/docs/edb.py b/edb/tools/docs/edb.py index 7761d86ec33..8ae53de86de 100644 --- a/edb/tools/docs/edb.py +++ b/edb/tools/docs/edb.py @@ -151,7 +151,7 @@ def apply(self): for node in self.document.traverse(d_nodes.substitution_reference): nt = node.astext() if nt.lower() in { - "gel", "gel's","edgedb", "gelcmd", ".gel", "gel.toml", + "gel", "gel's", "edgedb", "gelcmd", ".gel", "gel.toml", "gel-server", "geluri", "admin", "main", "branch", "branches" }: @@ -182,6 +182,7 @@ def apply(self): else: node.replace_self(d_nodes.Text(nt)) + class GelCmdRole: def __call__(