{"id":17681730,"url":"https://github.com/jgaskins/interro","last_synced_at":"2025-10-09T23:03:01.451Z","repository":{"id":143300833,"uuid":"301919400","full_name":"jgaskins/interro","owner":"jgaskins","description":"Crystal ORM based on Postgres, emphasizes type-safe queries, separates query objects from model objects","archived":false,"fork":false,"pushed_at":"2025-08-22T19:15:28.000Z","size":279,"stargazers_count":29,"open_issues_count":2,"forks_count":3,"subscribers_count":7,"default_branch":"master","last_synced_at":"2025-09-24T11:58:20.822Z","etag":null,"topics":["crystal","database","orm","postgres","sql"],"latest_commit_sha":null,"homepage":"","language":"Crystal","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jgaskins.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2020-10-07T03:44:50.000Z","updated_at":"2025-08-27T12:24:57.000Z","dependencies_parsed_at":"2023-11-19T17:26:35.497Z","dependency_job_id":"5930a7d8-2f51-4654-ba7c-09a1710d5f4f","html_url":"https://github.com/jgaskins/interro","commit_stats":{"total_commits":98,"total_committers":3,"mean_commits":"32.666666666666664","dds":"0.40816326530612246","last_synced_commit":"580a7f8193705976f86e345ebdeaa2ffa8062b7e"},"previous_names":[],"tags_count":20,"template":false,"template_full_name":null,"purl":"pkg:github/jgaskins/interro","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jgaskins%2Finterro","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jgaskins%2Finterro/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jgaskins%2Finterro/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jgaskins%2Finterro/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jgaskins","download_url":"https://codeload.github.com/jgaskins/interro/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jgaskins%2Finterro/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":279002214,"owners_count":26083340,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-10-09T02:00:07.460Z","response_time":59,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["crystal","database","orm","postgres","sql"],"created_at":"2024-10-24T09:12:01.253Z","updated_at":"2025-10-09T23:03:01.433Z","avatar_url":"https://github.com/jgaskins.png","language":"Crystal","readme":"# Interro\n\nPostgres database querying with Crystal\n\n## Installation\n\n1. Add the dependency to your `shard.yml`:\n\n   ```yaml\n   dependencies:\n     interro:\n       github: jgaskins/interro\n   ```\n\n2. Run `shards install`\n\n## Configuration\n\n```crystal\nrequire \"interro\"\nrequire \"db\"\n\nInterro.config do |c|\n  c.db = DB.open(ENV[\"DATABASE_URL\"])\n\n  # or if you're using using replication, you can specify separate DBs to read\n  # from and write to:\n  c.read_db = DB.open(ENV[\"DB_READ_URL\"])\n  c.write_db = DB.open(ENV[\"DB_WRITE_URL\"])\nend\n```\n\n## Migrations\n\nMigrations are, by convention, in the `./db/migrations` directory.\n\n### Generating a migration\n\nWhen you installed Interro into your application, it created a `bin/interro-migration` executable, so we can use that to generate our migration. Let's say we want to generate a migration to create our `users` table:\n\n```\nbin/interro-migration g CreateUsers\n```\n\nThis creates a directory called `db/migrations/YYYY_MM_DD_HH_MM_SS_NANOSECONDS-CreateUsers`. Inside this directory are two files called `up.sql` and `down.sql`. Respectively, these files represent the SQL queries needed to execute and roll-back the migration. Opening our `up.sql` file, we can edit it to say:\n\n```sql\nCREATE TABLE users(\n  id UUID PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(),\n  email TEXT UNIQUE NOT NULL,\n  name TEXT NOT NULL,\n  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n)\n```\n\nand in our `down.sql` file:\n\n```sql\nDROP TABLE users\n```\n\n**Note:** You can only execute one statement per SQL file. Additional statements must be done inside a separate migration.\n\n### Executing the migration\n\nYour `CreateUsers` migration can be executed with the following command:\n\n```\nbin/interro-migration run CreateUsers\n```\n\nIf you want to execute _all_ migrations that have not yet been run (say, if you just pulled someone else's changes that contained a migration), you can simply omit the migration name. The default is to run all migrations.\n\n### Rolling back a migration\n\nYou can roll back a specific migration by executing the following command:\n\n```\nbin/interro-migration rollback CreateUsers\n```\n\n## Models\n\nOnce our table is created, we can create a model to represent that data inside the application. To do that, we create a `class` or `struct` with the desired name (for example, we might choose `User` to represent a row in the `users` table):\n\n```crystal\nstruct User\n  include DB::Serializable\n\n  getter id : UUID\n  getter name : String\n  getter email : String\n  getter created_at : Time\n  getter updated_at : Time\nend\n```\n\nThere are 3 things to note here:\n\n1. We `include DB::Serializable`. This mixin is provided by the `crystal-lang/crystal-db` shard to deserialize rows into objects. Interro does not require anything else of your models than that.\n2. Since we specified `NOT NULL` on all of the columns in our migration, we can avoid making any of the properties `nil`-able. If any of your columns do not have `NOT NULL` constraints, you should allow them to be `nil` here or you will not be able to deserialize those rows.\n3. Our model only specifies properties using `getter` and not `property`. This makes the models immutable.\n\n## Queries\n\nInterro supports 2 different concepts for queries:\n\n1. `Interro::QueryBuilder(T)` for generating SQL queries\n2. `Interro::Query` to allow you to write your own SQL queries\n\n### `QueryBuilder(T)`\n\nThe simplest way to get started is to write a `struct` that inherits from `Interro::QueryBuilder` for your model:\n\n```crystal\nstruct UserQuery \u003c Interro::QueryBuilder(User)\n  table \"users\" # Send queries to the \"users\" table\nend\n```\n\nIf you're familiar with ActiveRecord in Ruby, you might be tempted to then run something like `UserQuery.where(foo: \"bar\")`, but Interro's query builder works a little differently. First, it must be instantiated.\n\n```crystal\nusers = UserQuery.new\n```\n\nThen, instead of using methods like `where` all over your application, you need to add methods to give names to those concepts. For example, if you want to find all the users in a given group:\n\n```crystal\nstruct UserQuery \u003c Interro::QueryBuilder(User)\n  table \"users\"\n\n  def members_of(group : Group)\n    where(group_id: group.id)\n  end\nend\n```\n\nIf you're familiar with ActiveRecord, these are very similar to scopes. Interro requires you to put your queries behind these \"scope\" methods in order to insulate your application from your database structure. Methods like `where` are unavailable outside the class. This way, when your database structure changes, it isolates the code changes to the methods inside these classes.\n\nHere are a few methods provided by `Interro::QueryBuilder`:\n\n- `where(name: \"Jamie\")`\n- `where { |user| user.created_at \u003c timestamp }`\n- `inner_join(\"groups\", as: \"g\", on: \"users.group_id = g.id\")`\n- `order_by(created_at: \"DESC\")`\n- `limit(25)`\n- `scalar(\"count(*)\", as: Int64)`\n- `insert(name: \"Jamie\", email: \"jamie@example.com\") : T`: returns the created record\n- `update(role: \"admin\") : T`: returns the updated models (allowing for immutable models)\n- `delete`\n\nEach one of these methods returns a new instance of the query builder. This lets you compose them in your query builder's methods and even makes your own methods composable. For example:\n\n```crystal\nstruct UserQuery \u003c Interro::QueryBuilder(User)\n  table \"users\"\n\n  def with_id(id : UUID)\n    where(id: id).first\n  end\n\n  def registered_before(timestamp : Time)\n    where { |user| user.created_at \u003c timestamp }\n  end\n\n  def oldest_first\n    order_by created_at: \"DESC\"\n  end\n\n  def in_group(group : Group)\n    where(group_id: group.id)\n  end\n\n  def in_group_with_name(group_name : String)\n    self # I don't like explicit `self` but it lines up the method chain nicely\n      .inner_join(\"groups\", as: \"g\", on: \"users.group_id = g.id\")\n      .where(\"g.name\": group_name)\n  end\n\n  def deactivate!(user : User) : User\n    update active: false\n  end\n\n  def at_most(count : Int32)\n    limit count\n  end\n\n  def count\n    scalar(\"count(*)\", as: Int64)\n  end\nend\n\nUserQuery.new\n  .in_group(group)\n  .oldest_first\n  .at_most(25)\n```\n\nThis query will get you the 25 oldest users (by registration date) in the specified group. The benefit here is that if we change the relationship between users and groups such that users can be a member of multiple groups, for example, this call doesn't need to change. You only need to change the internals of the query classes that know about that and you can keep the interfaces the exact same.\n\n#### Transactions\n\nYou can operate a transaction using the `Interro.transaction(\u0026)` method and passing that transaction to your query objects. For example, to deactivate a group and all its users within the same transaction:\n\n```crystal\nInterro.transaction do |txn|\n  group = GroupQuery[txn].deactivate! id: group_id\n  UserQuery[txn]\n    .in_group(group)\n    .deactivate_all!\nend\n```\n\nIf an exception occurs within the transaction block, it will be rolled back, so it may be important not to overwrite any variables from outside the block until the block completes:\n\n```crystal\ngroup = GroupQuery.new.with_id(group_id)\n\nInterro.transaction do |txn|\n  group = GroupQuery[txn].deactivate!(id: group_id)\n  UserQuery[txn]\n    .in_group(group)\n    .deactivate_all!\n\n  # oops, connection to the database goes out!\nend\n\ngroup # This will be the deactivated one even if the transaction fails\n```\n\n### `Interro::Query`\n\nThe other way to create queries with Interro is to create entire query objects. These are structs that represent a single SQL query:\n\n```crystal\nstruct GetUserByID \u003c Interro::Query\n  def call(id : UUID) : User?\n    read_one? \u003c\u003c-SQL, id, as: User\n      SELECT *\n      FROM users\n      WHERE id = $1\n      LIMIT 1\n    SQL\n  end\nend\n\nif user = GetUserByID[user_id]\n  # ...\nend\n```\n\nThe use case for these query objects are when a query is more complex than `Interro::QueryBuilder` can generate. For example, complex reporting queries, queries that return multiple entities per row, or any arbitrary SQL statement needed.\n\nMethods you can use within `Interro::Query` objects:\n\n- Run against the read DB:\n  - `read_one(query, *args, as: MyClass)`: asserts that one and only one row matches your query. If there are 0 or \u003e1, an exception is raised.\n  - `read_one?(query, *args, as: MyClass)`: returns either the first matching row or `nil` if no rows match.\n  - `read_all(query, *args, as: MyClass) : Array(MyClass)`: returns all matching rows as an array\n- Run against the write DB:\n  - `write_one(query, *args, as: MyClass)`: asserts a single match, assumes a `RETURNING` clause\n  - `write(query, *args)`: runs the specified query and ignores any returned data\n\n#### Transactions\n\nTransactions are performed the same way as with `QueryBuilder`, by passing the transaction to the queries with the transaction in brackets:\n\n```crystal\nInterro.transaction do |txn|\n  user = GetUserByID[txn][user_id]\n  ChangeUserPassword[txn][new_password]\nend\n```\n\n## Development\n\n- Install Postgres\n  - macOS\n    - https://postgresapp.com/\n    - `brew install postgresql`\n  - Linux\n    - `apt-get install postgresql`\n    - Snap?\n- Make sure there is a default user and database (usually either `$USER` or `postgres` for both)\n- Run specs with `crystal spec`\n\n## Contributing\n\n1. Fork it (\u003chttps://github.com/jgaskins/interro/fork\u003e)\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create a new Pull Request\n\n## Contributors\n\n- [Jamie Gaskins](https://github.com/jgaskins) - creator and maintainer\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjgaskins%2Finterro","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjgaskins%2Finterro","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjgaskins%2Finterro/lists"}