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 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.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skyward
4
+ module ORM
5
+ VERSION = "0.1.0" #: String
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "skyward/core"
4
+ require "zeitwerk"
5
+ require_relative "orm/version"
6
+
7
+ loader = Zeitwerk::Loader.for_gem_extension(Skyward)
8
+ loader.inflector.inflect("orm" => "ORM")
9
+ loader.setup
@@ -0,0 +1,7 @@
1
+ # Generated from lib/skyward/orm/version.rb with RBS::Inline
2
+
3
+ module Skyward
4
+ module ORM
5
+ VERSION: String
6
+ end
7
+ end
@@ -0,0 +1,2 @@
1
+ # Generated from lib/skyward/orm.rb with RBS::Inline
2
+
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: []