skyward-orm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +502 -0
- data/lib/skyward/orm/version.rb +7 -0
- data/lib/skyward/orm.rb +9 -0
- data/sig/generated/skyward/orm/version.rbs +7 -0
- data/sig/generated/skyward/orm.rbs +2 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0611e9bbfef973bdbcf47265afa0c0d5ba42e677c987eaeb9112028e244146ac
|
|
4
|
+
data.tar.gz: fdd3b26d97b405b701bdfdd324ab503e8e7ee8c733a81161462a4bd578595e6c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: edf1a82ea278503add84a869cfeaa9dc09e087a63b927eac2e376a7d5b9ecf9d6692b517e10f6beaa1b4c41efbcdb18e75febff3d241d63038642e0e95a21640
|
|
7
|
+
data.tar.gz: 611b2c1d13fe60486b5bfe6e33bcf7a26bd1a8a1fefb6dddc94626690c982c9e887fba0bedd493d80fa0651951caee3747b7398f7b0a0679de155ed26f577341
|
data/README.md
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# Skyward ORM
|
|
2
|
+
|
|
3
|
+
> [!CAUTION]
|
|
4
|
+
> **Work in progress: Skyward ORM doesn't do anything yet.** Version 0.1 is a skeleton. The gems install and load, but none of the features described here are built. This README is the spec for what's coming, not documentation of what exists. Don't depend on it yet.
|
|
5
|
+
|
|
6
|
+
A small, fast Ruby ORM where **a class is a table and its rows are values**.
|
|
7
|
+
|
|
8
|
+
You write a plain Ruby class with a typed reader for each column, and tell Skyward ORM which table it lives in. The class is then the table: you query it, write to it and migrate it. What comes back are immutable instances of your class. There are no callbacks, no dirty tracking and no lazy loading. You get your data and the SQL that fetched it.
|
|
9
|
+
|
|
10
|
+
Skyward ORM is half of Skyward. The other half is [Skyward API](../skyward-api/README.md), but each works without the other.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# rbs_inline: enabled
|
|
14
|
+
require "skyward/orm"
|
|
15
|
+
|
|
16
|
+
class Todo < Skyward::Record
|
|
17
|
+
table :todos
|
|
18
|
+
default done: false
|
|
19
|
+
|
|
20
|
+
# @dynamic id, title, done
|
|
21
|
+
attr_reader :id #: Integer
|
|
22
|
+
attr_reader :title #: String
|
|
23
|
+
attr_reader :done #: bool
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Skyward.connect("sqlite://todos.db")
|
|
27
|
+
|
|
28
|
+
Todo.insert(title: "Buy milk")
|
|
29
|
+
# => #<Todo id=1, title="Buy milk", done=false>
|
|
30
|
+
|
|
31
|
+
Todo.where(done: false).map(&:title)
|
|
32
|
+
# => ["Buy milk"]
|
|
33
|
+
|
|
34
|
+
Todo.where(id: 1).update(done: true)
|
|
35
|
+
# => [#<Todo id=1, title="Buy milk", done=true>]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
$ skyward migration create_todos # writes the CREATE TABLE for you, from the class
|
|
40
|
+
$ skyward migrate
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Contents
|
|
46
|
+
|
|
47
|
+
- [Install](#install)
|
|
48
|
+
- [Tables](#tables)
|
|
49
|
+
- [Migrations](#migrations)
|
|
50
|
+
- [Connecting](#connecting)
|
|
51
|
+
- [Reading](#reading)
|
|
52
|
+
- [Projections, joins and aggregates](#projections-joins-and-aggregates)
|
|
53
|
+
- [Raw SQL](#raw-sql)
|
|
54
|
+
- [Writing](#writing)
|
|
55
|
+
- [Transactions](#transactions)
|
|
56
|
+
- [Types](#types)
|
|
57
|
+
- [Testing](#testing)
|
|
58
|
+
- [Performance](#performance)
|
|
59
|
+
- [CLI](#cli)
|
|
60
|
+
- [Coming from…](#coming-from)
|
|
61
|
+
- [Design principles](#design-principles)
|
|
62
|
+
- [Status](#status)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# Gemfile
|
|
70
|
+
gem "skyward-orm"
|
|
71
|
+
gem "pg" # or "sqlite3"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Skyward ORM supports every Ruby that isn't end-of-life (3.3 and later, as of November 2026), and PostgreSQL and SQLite.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Tables
|
|
79
|
+
|
|
80
|
+
A table is a plain Ruby class that subclasses `Skyward::Record`. Each `attr_reader` is a column, and its `#:` comment is the column's type. In an app organised by domain, name it after what it is: `Todos::Record`, `Lists::Record`.
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# rbs_inline: enabled
|
|
84
|
+
|
|
85
|
+
class Todo < Skyward::Record
|
|
86
|
+
table :todos
|
|
87
|
+
references :list_id, List, on_delete: :cascade
|
|
88
|
+
default priority: :normal, done: false, created_at: Skyward.sql("now()")
|
|
89
|
+
check :title, length: 1..200
|
|
90
|
+
index :list_id, :done
|
|
91
|
+
|
|
92
|
+
# @dynamic id, list_id, title, priority, due_on, done, created_at
|
|
93
|
+
attr_reader :id #: Integer
|
|
94
|
+
attr_reader :list_id #: Integer
|
|
95
|
+
attr_reader :title #: String
|
|
96
|
+
attr_reader :priority #: :low | :normal | :high
|
|
97
|
+
attr_reader :due_on #: Date?
|
|
98
|
+
attr_reader :done #: bool
|
|
99
|
+
attr_reader :created_at #: Time
|
|
100
|
+
|
|
101
|
+
# @rbs today: Date
|
|
102
|
+
# @rbs return: bool
|
|
103
|
+
def overdue?(today = Date.today) = !done && !due_on.nil? && due_on < today
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**The class is the table.** Everything at class level describes or queries `todos`. **An instance is a row**, and Skyward ORM makes it a value: it's frozen, compares with `==`, and has `to_h`, `with` and pattern matching. You build one with `Todo.new(id: 1, title: "Milk", ...)`. Leave out a column and it raises. It's safe to share between threads, cache, return from a Skyward API endpoint, or build in a test.
|
|
108
|
+
|
|
109
|
+
The `# @dynamic` line lists the columns for Steep, which doesn't count an `attr_reader` as defining a method.
|
|
110
|
+
|
|
111
|
+
Methods are just methods. `overdue?` is ordinary Ruby, and a computed value is a `def`, because on a table an `attr_reader` always means a column.
|
|
112
|
+
|
|
113
|
+
The class body is the schema, so everything about the table is in one place:
|
|
114
|
+
|
|
115
|
+
| Declaration | Means |
|
|
116
|
+
| ---------------------------------------- | ------------------------------------------------------------- |
|
|
117
|
+
| `table :todos` | the rows live in `todos` |
|
|
118
|
+
| `primary_key :uuid` | the primary key, if it isn't `:id` |
|
|
119
|
+
| `references :list_id, List` | a foreign key to `List`'s primary key (`on_delete:` optional) |
|
|
120
|
+
| `default done: false` | column defaults; `Skyward.sql("...")` for an SQL expression |
|
|
121
|
+
| `unique :email` | a unique constraint, on one or more columns |
|
|
122
|
+
| `check :title, length: 1..200` | a check constraint on the column's length; `check :title, Skyward.sql("...")` for any other SQL expression |
|
|
123
|
+
| `index :list_id, :done` | an index, on one or more columns |
|
|
124
|
+
|
|
125
|
+
Column types come from the `#:` comments, the same types Steep reads. `T?` means a nullable column, everything else is `NOT NULL`, and a symbol union such as `:low | :normal | :high` gets a check constraint for its values.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Migrations
|
|
130
|
+
|
|
131
|
+
You don't write migrations by hand. Change the class, and Skyward ORM writes the SQL that changes the database to match:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
$ skyward migration create_todos
|
|
135
|
+
Todo has no table yet:
|
|
136
|
+
+ todos 7 columns, 1 reference, 2 checks, 1 index
|
|
137
|
+
Wrote db/migrate/20261018093000_create_todos.sql. Review it, then run `skyward migrate`.
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```sql
|
|
141
|
+
-- db/migrate/20261018093000_create_todos.sql
|
|
142
|
+
CREATE TABLE todos (
|
|
143
|
+
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
144
|
+
list_id bigint NOT NULL REFERENCES lists ON DELETE CASCADE,
|
|
145
|
+
title text NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
|
|
146
|
+
priority text NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high')),
|
|
147
|
+
due_on date,
|
|
148
|
+
done boolean NOT NULL DEFAULT false,
|
|
149
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX todos_list_id_done_index ON todos (list_id, done);
|
|
152
|
+
|
|
153
|
+
-- down
|
|
154
|
+
DROP TABLE todos;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Later, add `:notes #: String?` to `Todo` and run it again:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
$ skyward migration add_notes_to_todos
|
|
161
|
+
Todo differs from the todos table:
|
|
162
|
+
+ notes text NULL
|
|
163
|
+
Wrote db/migrate/20261019141500_add_notes_to_todos.sql.
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- **Migrations are plain SQL files you commit.** Read them, edit them, and add data backfills to the same file. Skyward ORM runs each one once, in order, inside a transaction. `skyward rollback` runs the `-- down` section.
|
|
167
|
+
- **Skyward ORM asks when it can't know what you meant.** If a column disappears and another appears, it asks whether you renamed it, rather than dropping your data.
|
|
168
|
+
- **Generation needs types.** A column without a `#:` type still works at runtime, but Skyward ORM can't guess its SQL type. The migration gets `-- TODO: todos.notes needs a type`, and you write that line yourself.
|
|
169
|
+
- **A new required column needs a default.** The rows already in the table need a value for it, so the migration gets `-- TODO: todos.notes needs a DEFAULT for the rows todos already has`. Give it one, or make the column nullable.
|
|
170
|
+
- **Hand-written migrations are always an option.** `skyward migration --empty backfill_priorities` gives you an empty file for SQL you write yourself.
|
|
171
|
+
|
|
172
|
+
Business rules like the title length live in the database, where they hold for every writer, and they come back to Ruby as a [`Violation`](#when-the-database-says-no).
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Connecting
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
Skyward.connect(ENV.fetch("DATABASE_URL"), pool: 10)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Call it once, where your app boots. Skyward ORM reads your column types from the `.rbs` files under `sig/`, or the directory you pass as `signatures:`.
|
|
183
|
+
|
|
184
|
+
Like ActiveRecord, Skyward ORM keeps the connection for you, so `Todo.where(...)` just works. Also like ActiveRecord, it gives you the seams that make that safe: tests that each run in a rolled-back transaction, and `Skyward.using(other_database) { ... }` to switch databases for one block. The switch applies only to the current fiber, never globally.
|
|
185
|
+
|
|
186
|
+
When you connect, Skyward ORM compares every loaded table class with the live schema and refuses to start if they disagree:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
Skyward::SchemaMismatch: Todo doesn't match the todos table
|
|
190
|
+
|
|
191
|
+
notes todos has no column notes.
|
|
192
|
+
Run `skyward migrate`, or `skyward migration` if you haven't generated one.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A table class loaded after connect is checked the first time it's used.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Reading
|
|
200
|
+
|
|
201
|
+
Every query starts at the table. Queries are immutable: each method returns a new query, and nothing runs until you ask for rows.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
open = Todo.where(done: false).order(created_at: :desc)
|
|
205
|
+
|
|
206
|
+
open.limit(20).to_a # => Array[Todo]
|
|
207
|
+
open.first # => Todo or nil
|
|
208
|
+
open.count # => Integer, via COUNT(*)
|
|
209
|
+
open.exists? # => bool
|
|
210
|
+
Todo.find_by(id: 42) # => Todo or nil
|
|
211
|
+
Todo.all # => every row, as a query
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Queries are `Enumerable`, so `each`, `map`, `group_by` and friends work, and they run the query once. `where` filters in SQL. `select` and `filter` filter in Ruby after fetching, just as they do on an `Array`.
|
|
215
|
+
|
|
216
|
+
`where` takes the hash conditions you already know, plus the Ruby values that naturally mean something in SQL:
|
|
217
|
+
|
|
218
|
+
| Ruby | SQL |
|
|
219
|
+
| ---------------------------------------- | ----------------------------------------------------------- |
|
|
220
|
+
| `where(done: false)` | `done = false` |
|
|
221
|
+
| `where(due_on: nil)` | `due_on IS NULL` |
|
|
222
|
+
| `where(priority: [:high, :normal])` | `priority IN ('high', 'normal')` |
|
|
223
|
+
| `where(due_on: ..Date.today)` | `due_on <= '2026-10-18'` |
|
|
224
|
+
| `where(due_on: Date.today...)` | `due_on >= '2026-10-18'` |
|
|
225
|
+
| `where(created_at: from...to)` | `created_at >= ? AND created_at < ?` |
|
|
226
|
+
| `exclude(done: true)` | `NOT (done = true)` |
|
|
227
|
+
| `where(done: false).or(priority: :high)` | `done = false OR priority = 'high'` |
|
|
228
|
+
| `where("title ILIKE ?", "%milk%")` | `title ILIKE '%milk%'`, with the value bound as a parameter |
|
|
229
|
+
|
|
230
|
+
Order and paging: `order(:title)`, `order(created_at: :desc)`, `limit(20)` and `offset(40)`.
|
|
231
|
+
|
|
232
|
+
### Scopes are class methods
|
|
233
|
+
|
|
234
|
+
Skyward ORM has no scope DSL, because Ruby already has methods, and the class is the table:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class Todo
|
|
238
|
+
# @rbs return: Skyward::Query[Todo]
|
|
239
|
+
def self.open = where(done: false).order(created_at: :desc)
|
|
240
|
+
|
|
241
|
+
# @rbs today: Date
|
|
242
|
+
# @rbs return: Skyward::Query[Todo]
|
|
243
|
+
def self.overdue(today: Date.today) = open.where(due_on: ...today)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
Todo.overdue.to_a
|
|
247
|
+
Todo.open.where(list_id: 3) # start from a scope, then narrow it
|
|
248
|
+
Todo.where(list_id: 3).merge(Todo.open) # or combine two queries with merge
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Seeing the SQL
|
|
252
|
+
|
|
253
|
+
Every query can show its SQL without running it:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
Todo.where(done: false, priority: [:high, :normal]).order(created_at: :desc).to_sql
|
|
257
|
+
# => ["SELECT id, list_id, title, priority, due_on, done, created_at FROM todos
|
|
258
|
+
# WHERE done = $1 AND priority IN ($2, $3) ORDER BY created_at DESC",
|
|
259
|
+
# [false, "high", "normal"]]
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Related rows
|
|
263
|
+
|
|
264
|
+
Skyward ORM has no associations, so nothing loads behind your back and N+1 queries can't hide. Loading related rows takes two queries and some `Enumerable`:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
lists = List.order(:name).to_a
|
|
268
|
+
todos = Todo.where(list_id: lists.map(&:id)).group_by(&:list_id)
|
|
269
|
+
|
|
270
|
+
lists.map { |list| ListWithTodos.new(**list.to_h, todos: todos.fetch(list.id, [])) }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Projections, joins and aggregates
|
|
276
|
+
|
|
277
|
+
When you want a different shape, describe it with any class that takes keyword arguments (`Data` is the easiest) and select `into` it:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
TodoTitle = Data.define(:id, :title)
|
|
281
|
+
|
|
282
|
+
Todo.where(done: false).into(TodoTitle).to_a
|
|
283
|
+
# SELECT id, title FROM todos WHERE done = false
|
|
284
|
+
# => Array[TodoTitle]
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
A member reads the column with the same name. Anything else takes an SQL expression:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
TodoWithList = Data.define(:id, :title, :list_name)
|
|
291
|
+
|
|
292
|
+
Todo.join(List, list_id: :id).into(TodoWithList, list_name: "lists.name")
|
|
293
|
+
# SELECT todos.id, todos.title, lists.name AS list_name
|
|
294
|
+
# FROM todos JOIN lists ON todos.list_id = lists.id
|
|
295
|
+
|
|
296
|
+
OpenCount = Data.define(:list_id, :open)
|
|
297
|
+
|
|
298
|
+
Todo.where(done: false).group(:list_id).into(OpenCount, open: "count(*)")
|
|
299
|
+
# SELECT list_id, count(*) AS open FROM todos WHERE done = false GROUP BY list_id
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`join` is an inner join and `left_join` is a left join. A member without an expression reads from the table the query started from, so `id` above is `todos.id`. A joined table's columns always need an expression.
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Raw SQL
|
|
307
|
+
|
|
308
|
+
Skyward ORM builds SQL. It doesn't hide it. When a query is easier to write than to build, write it, and name the shape you want back:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
Overdue = Data.define(:title, :days_late)
|
|
312
|
+
|
|
313
|
+
Skyward.query(Overdue, <<~SQL, Date.today)
|
|
314
|
+
SELECT title, current_date - due_on AS days_late
|
|
315
|
+
FROM todos
|
|
316
|
+
WHERE due_on < ? AND NOT done
|
|
317
|
+
ORDER BY days_late DESC
|
|
318
|
+
SQL
|
|
319
|
+
# => Array[Overdue]
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Values are always bound as parameters, never interpolated. If you leave out the class, you get an array of symbol-keyed hashes.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Writing
|
|
327
|
+
|
|
328
|
+
Writes return what they wrote, using `RETURNING`, so you never need a second query to see the result:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Todo.insert(title: "Buy milk", list_id: 1) # => Todo
|
|
332
|
+
Todo.insert_all([{ title: "Eggs", list_id: 1 }, ...]) # => Array[Todo]
|
|
333
|
+
Todo.where(id: 42).update(done: true) # => Array[Todo], the updated rows
|
|
334
|
+
Todo.where(done: true).delete # => Array[Todo], the deleted rows
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Columns with a default (`id`, `done`, `created_at`) and nullable ones (`due_on`) are optional. The rest are required, and leaving one out raises an `ArgumentError` straight away.
|
|
338
|
+
|
|
339
|
+
### When the database says no
|
|
340
|
+
|
|
341
|
+
A duplicate email or a missing parent row isn't exceptional. It's an outcome your code should handle. So the plain write methods return either the rows or a `Skyward::Violation`:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
result = User.insert(email:)
|
|
345
|
+
case result
|
|
346
|
+
when User then created result
|
|
347
|
+
else result.kind == :unique ? conflict("That email is already registered") : unprocessable(result.message)
|
|
348
|
+
end
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Steep 2.1 can't type-check `case`/`in`, so typed code matches on the class with `case`/`when`, and on the violation's `kind`.
|
|
352
|
+
|
|
353
|
+
`Skyward::Violation` has `kind` (`:unique`, `:foreign_key`, `:not_null` or `:check`), `constraint`, `columns` and the database's `message`.
|
|
354
|
+
|
|
355
|
+
When a violation would mean a bug, use the bang version. It returns the rows or raises `Skyward::ViolationError`, the way `save` and `save!` work in ActiveRecord:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
Todo.insert!(title: "Buy milk", list_id: 1) # => Todo
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Transactions
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
Skyward.transaction do
|
|
367
|
+
list = List.insert!(name: "Groceries")
|
|
368
|
+
Todo.insert!(title: "Milk", list_id: list.id)
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Every query inside the block runs in the transaction, and the block's value is returned. Skyward ORM rolls back if the block raises, **or if it returns a `Skyward::Violation`**, so the non-bang methods compose:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
result = Skyward.transaction do
|
|
376
|
+
user = User.insert(email:)
|
|
377
|
+
case user
|
|
378
|
+
when User then Membership.insert(user_id: user.id, team_id:)
|
|
379
|
+
else user # a violation: rolls back, then returns it
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
A transaction belongs to the fiber that opened it. Ruby copies fiber storage into child fibers and threads, so a task started inside the block would otherwise share its connection. Skyward ORM raises if one tries to use it: "this transaction belongs to another fiber; start your own, or return the result to the block". `Skyward.transaction?` tells you whether you're inside one.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Types
|
|
389
|
+
|
|
390
|
+
Skyward ORM reads the same RBS as Steep, whether it's written inline or in `sig/`. It reads the `.rbs` files, so inline types count once rbs-inline has written them to `sig/`. Here's what gets checked, and when:
|
|
391
|
+
|
|
392
|
+
| When | What's checked |
|
|
393
|
+
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
394
|
+
| **As you type (Steep)** | The row type of every query. `Todo.where(...)` is a `Skyward::Query[Todo]`, `.to_a` is an `Array[Todo]`, `.into(TodoTitle)` is a `Query[TodoTitle]`, and `insert` returns `Todo \| Skyward::Violation`. No annotations are needed. |
|
|
395
|
+
| **When you generate a migration** | Column types, nullability and enum values all come from your types. |
|
|
396
|
+
| **At connect** | Every table class against the live schema, which catches migrations you haven't run. |
|
|
397
|
+
| **When a query is built** | Column names exist, and values match their column's type. `where(dnoe: false)` and `where(done: "yes")` both raise immediately, so your first test run catches them. |
|
|
398
|
+
| **In the database** | Constraints, which come back as `Skyward::Violation` values. |
|
|
399
|
+
|
|
400
|
+
Types are what Skyward ORM generates migrations from. Leave them off and queries still work, rows still come back as instances of your class, holding whatever values the driver returns, and you write the SQL for those columns yourself.
|
|
401
|
+
|
|
402
|
+
### How types map
|
|
403
|
+
|
|
404
|
+
| RBS | PostgreSQL | SQLite |
|
|
405
|
+
| ----------------------- | ------------------------------------------------ | ----------------- |
|
|
406
|
+
| `Integer` | `bigint` | `INTEGER` |
|
|
407
|
+
| `Float` | `double precision` | `REAL` |
|
|
408
|
+
| `BigDecimal` | `numeric` | `TEXT` |
|
|
409
|
+
| `String` | `text` | `TEXT` |
|
|
410
|
+
| `bool` | `boolean` | `INTEGER` (0/1) |
|
|
411
|
+
| `Date` | `date` | `TEXT` (ISO 8601) |
|
|
412
|
+
| `Time` | `timestamptz` | `TEXT` (ISO 8601) |
|
|
413
|
+
| `:low \| :high` | `text` with a check, read back as Symbols | `TEXT` with a check |
|
|
414
|
+
| `T?` | a nullable column | a nullable column |
|
|
415
|
+
| `Hash[String, untyped]` | `jsonb` | `TEXT` (JSON) |
|
|
416
|
+
| `Array[T]` | `T[]` | `TEXT` (JSON) |
|
|
417
|
+
|
|
418
|
+
This is the type in a generated migration. When reading an existing column, Skyward ORM also accepts compatible types, such as `integer` or `varchar`. These mappings match Skyward API's, so a `Todo` read from the database and a `Todo` sent as JSON are the same thing.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Testing
|
|
423
|
+
|
|
424
|
+
Test against a real database. In-memory SQLite or a local PostgreSQL is fast enough, and it's the only way to know your SQL works.
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
# test/test_helper.rb
|
|
428
|
+
Skyward.connect(ENV.fetch("TEST_DATABASE_URL"))
|
|
429
|
+
Skyward.migrate("db/migrate") # runs any migration the test database hasn't had
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```ruby
|
|
433
|
+
describe Todo do
|
|
434
|
+
include Skyward::Transactional # each test runs in a transaction that's rolled back afterwards
|
|
435
|
+
|
|
436
|
+
let(:list) { List.insert!(name: "Home") }
|
|
437
|
+
|
|
438
|
+
describe ".overdue" do
|
|
439
|
+
it "skips todos that are done" do
|
|
440
|
+
late = Todo.insert!(title: "Milk", list_id: list.id, due_on: Date.new(2026, 10, 1))
|
|
441
|
+
Todo.insert!(title: "Eggs", list_id: list.id, due_on: Date.new(2026, 10, 1), done: true)
|
|
442
|
+
|
|
443
|
+
assert_equal [late], Todo.overdue(today: Date.new(2026, 10, 18)).to_a
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Rows are values, so assertions compare them directly with `==`.
|
|
450
|
+
|
|
451
|
+
---
|
|
452
|
+
|
|
453
|
+
## Performance
|
|
454
|
+
|
|
455
|
+
- **Rows are values.** Skyward ORM has no identity map, dirty tracking, callbacks or per-row bookkeeping. Each row is one frozen object and its values.
|
|
456
|
+
- **Decoding is compiled.** Skyward ORM compiles a decoder for each table class that turns database values straight into rows, with no per-row hash in between.
|
|
457
|
+
- **SQL is cached by shape.** `where(id: 1)` and `where(id: 2)` share one SQL string and one prepared statement. Building a query is a cache lookup plus binding values.
|
|
458
|
+
- **Pooled connections, safe across threads and fibers.** The pool works under Puma or Falcon.
|
|
459
|
+
- **No hidden queries.** Skyward ORM has no lazy loading, so every query is one you wrote.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## CLI
|
|
464
|
+
|
|
465
|
+
Skyward ORM and Skyward API share one `skyward` command. Each adds its own commands to it. The executable comes with Skyward Core, which every Skyward gem depends on. The ORM's commands live in `lib/skyward/orm/commands.rb`, which registers each with `Skyward::Commands.register`.
|
|
466
|
+
|
|
467
|
+
| Command | Does |
|
|
468
|
+
| ------------------------------------- | ----------------------------------------------------------------------------- |
|
|
469
|
+
| `skyward migration add_notes_to_todos` | compares your table classes with the database and writes the migration |
|
|
470
|
+
| `skyward migration --empty backfill` | writes an empty migration for SQL you write yourself |
|
|
471
|
+
| `skyward migrate` | runs pending migrations |
|
|
472
|
+
| `skyward rollback` | runs the `-- down` section of the latest migration |
|
|
473
|
+
| `skyward status` | lists migrations, and any differences between your classes and the database |
|
|
474
|
+
| `skyward scaffold todos` | prints a table class for an existing table, for adopting a database Skyward ORM didn't create |
|
|
475
|
+
|
|
476
|
+
The CLI runs in your app's directory and requires its `boot.rb`, which loads your table classes and calls `Skyward.connect`.
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## Coming from…
|
|
481
|
+
|
|
482
|
+
- **ActiveRecord.** `Todo.where` works the way you expect, and `insert`/`insert!` work like `save`/`save!`. But a row is data rather than an object with a lifecycle: it has no callbacks, validations or associations. The columns are written in the class, so you can see them, and migrations are written for you from them.
|
|
483
|
+
- **Sequel.** It's the same idea of queries as values, but the rows are instances of your own typed classes rather than hashes or models.
|
|
484
|
+
- **Django or Prisma.** It's the same declare-then-generate approach to migrations, in plain Ruby with RBS types.
|
|
485
|
+
- **Raw SQL.** Use it whenever you like (`Skyward.query`), and keep typed results and bound parameters.
|
|
486
|
+
|
|
487
|
+
---
|
|
488
|
+
|
|
489
|
+
## Design principles
|
|
490
|
+
|
|
491
|
+
1. **A class is a table, and its rows are values.** You query the class, and you get back frozen instances of it that you can use anywhere.
|
|
492
|
+
2. **The class is the schema.** Columns, types and constraints are declared once, in Ruby, where Steep and Skyward ORM can both read them. Migrations follow from them.
|
|
493
|
+
3. **A query is a value.** It's immutable, composable and printable, and nothing runs until you ask for rows.
|
|
494
|
+
4. **SQL is the feature, not the problem.** Skyward ORM builds the SQL you'd write yourself, writes your migrations in SQL you can read, and lets you write it yourself when that's clearer.
|
|
495
|
+
5. **Outcomes are values.** The database saying no is a result you handle, not an exception you rescue.
|
|
496
|
+
6. **No hidden work.** Skyward ORM has no lazy loading and no callbacks. Every query is one you can see.
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## Status
|
|
501
|
+
|
|
502
|
+
This README is the spec. Skyward 0.1 is a skeleton: the gem installs and loads, and none of the features above are built yet. Skyward ORM is built after Skyward API, and this section lists each part as it lands.
|
data/lib/skyward/orm.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: skyward-orm
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jake Bottrall
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: skyward-core
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: zeitwerk
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.8'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.8'
|
|
40
|
+
email:
|
|
41
|
+
- jakebottrall@gmail.com
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- README.md
|
|
47
|
+
- lib/skyward/orm.rb
|
|
48
|
+
- lib/skyward/orm/version.rb
|
|
49
|
+
- sig/generated/skyward/orm.rbs
|
|
50
|
+
- sig/generated/skyward/orm/version.rbs
|
|
51
|
+
homepage: https://github.com/bottrall/skyward
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/bottrall/skyward
|
|
56
|
+
source_code_uri: https://github.com/bottrall/skyward/tree/main/skyward-orm
|
|
57
|
+
changelog_uri: https://github.com/bottrall/skyward/blob/main/CHANGELOG.md
|
|
58
|
+
bug_tracker_uri: https://github.com/bottrall/skyward/issues
|
|
59
|
+
rubygems_mfa_required: 'true'
|
|
60
|
+
rdoc_options: []
|
|
61
|
+
require_paths:
|
|
62
|
+
- lib
|
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.3'
|
|
68
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '0'
|
|
73
|
+
requirements: []
|
|
74
|
+
rubygems_version: 4.0.20
|
|
75
|
+
specification_version: 4
|
|
76
|
+
summary: A small, fast ORM where a class is a table and its rows are values, typed
|
|
77
|
+
with RBS.
|
|
78
|
+
test_files: []
|