appquery 0.8.0.rc3 → 0.9.0.rc1
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 +4 -4
- data/.worktree.yml.example +40 -0
- data/.yardopts +1 -1
- data/CHANGELOG.md +91 -0
- data/Procfile.yard +4 -0
- data/README.md +147 -6
- data/lib/app_query/base_query.rb +2 -1
- data/lib/app_query/mappable.rb +10 -21
- data/lib/app_query/rspec/helpers.rb +11 -1
- data/lib/app_query/rspec.rb +22 -2
- data/lib/app_query/tokenizer.rb +7 -1
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +199 -21
- data/mise.local.toml.example +2 -2
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de70b2690d884b36f8d29f8a2e76c7edefba25ca9f9fcc9785e70389c35f3aa0
|
|
4
|
+
data.tar.gz: 3c81f2fb6dd4679a86a99df91485df234ccfd449c6de0cb5317c6ca8f8e5751b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2a0d5f7c2a320426cf292e4f5afdcbe8ca9d499eca85cb8139d7c44d0dc2498443fc70853bc274c6c912e9753ee7a176ad552d9e4f6fe0d0f1adc4d2ad00bba
|
|
7
|
+
data.tar.gz: 7a038760466d79804b06e64ba18d6161a2070fe728b2ec529a1dbd62c04729866b8af6def94f9dafcec7965d060710c46bbe3e874b6cb67df2e32413a4a62cc1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Worktree configuration for bonchi.
|
|
2
|
+
# See https://github.com/eval/bonchi
|
|
3
|
+
|
|
4
|
+
# Minimum bonchi version required.
|
|
5
|
+
# min_version: 0.6.0
|
|
6
|
+
|
|
7
|
+
# Files to copy from the main worktree before setup.
|
|
8
|
+
copy:
|
|
9
|
+
- mise.toml
|
|
10
|
+
- mise.local.toml
|
|
11
|
+
- _irbrc
|
|
12
|
+
- bin/worktree-dev
|
|
13
|
+
|
|
14
|
+
# Files to symlink from the main worktree (useful for large directories).
|
|
15
|
+
# link:
|
|
16
|
+
# - node_modules
|
|
17
|
+
|
|
18
|
+
# Env var names to allocate unique ports for (from global pool).
|
|
19
|
+
# ports:
|
|
20
|
+
# - PORT
|
|
21
|
+
|
|
22
|
+
# Regex replacements in copied files. Env vars ($VAR) are expanded.
|
|
23
|
+
# Short form:
|
|
24
|
+
# replace:
|
|
25
|
+
# .env.local:
|
|
26
|
+
# - "^PORT=.*": "PORT=$PORT"
|
|
27
|
+
# Full form (with optional missing: warn, default: halt):
|
|
28
|
+
# replace:
|
|
29
|
+
# .env.local:
|
|
30
|
+
# - match: "^PORT=.*"
|
|
31
|
+
# with: "PORT=$PORT"
|
|
32
|
+
# missing: warn
|
|
33
|
+
|
|
34
|
+
# Commands to run before the setup command (port env vars are available).
|
|
35
|
+
pre_setup:
|
|
36
|
+
- mise trust
|
|
37
|
+
- bin/setup
|
|
38
|
+
|
|
39
|
+
# The setup command to run (default: bin/setup).
|
|
40
|
+
setup: bin/worktree-dev
|
data/.yardopts
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### 💥 Breaking Changes
|
|
4
|
+
|
|
5
|
+
- ⚠️ **`AppQuery::Mappable` extension API changed.**
|
|
6
|
+
Row-level middleware now appends a transformer to the underlying `Q`'s `row_builder` pipeline via an overridden `#query`, instead of overriding `select_all`/`select_one`. The previous pattern of overriding those two methods will silently do nothing on row-returning paths it didn't cover (`entries`, `first`, `last`, `take`, `with_select(non_nil).first`, …). Any custom middleware that overrode `select_all`/`select_one` should migrate to:
|
|
7
|
+
```ruby
|
|
8
|
+
def query
|
|
9
|
+
@query ||= super.tap { |q| q.row_builder << method(:build_row) }
|
|
10
|
+
end
|
|
11
|
+
```
|
|
12
|
+
- ⚠️ **`Q#column` now raises `ArgumentError` for unknown columns.**
|
|
13
|
+
Previously, on SQLite, `q.column(:typo)` silently returned a row per record containing the *string* `"typo"` (the SQLite "double-quoted strings are identifiers OR string literals" quirk masked the missing column). It now pre-validates against `column_names` and raises with the available column list — consistently across SQLite and PostgreSQL.
|
|
14
|
+
|
|
15
|
+
### ✨ Features
|
|
16
|
+
|
|
17
|
+
- 🧩 **`AppQuery::RowBuilder`** — composable pipeline of row transformers exposed as `Q#row_builder`. Append with `q.row_builder << callable`; transformers run in registration order. Multiple row-level middlewares stack cleanly in `include` order. The pipeline is applied everywhere `Q` exposes rows (`entries`, `first`, `last`, `take`, `take_last`, `with_select(...).first`, …) and is independently copied across `deep_dup` so chained queries don't mutate their parent.
|
|
18
|
+
- 🎯 **`Mappable` is now one method.** Maps everywhere — including `entries`, `last`, `take(n)`, `with_select("…").first` paths that previously slipped through. `raw` bypass still works.
|
|
19
|
+
- 🐛 **`Q#column` typo protection** — see breaking-change note above.
|
|
20
|
+
- 🐛 **Comments inside CTE selects** no longer break tokenization; the whole `(SELECT … -- foo … )` is preserved as a single `CTE_SELECT` token.
|
|
21
|
+
- Publishing gem requires MFA
|
|
22
|
+
|
|
23
|
+
## 0.8.0
|
|
24
|
+
|
|
25
|
+
**Releasedate**: 14-1-2026
|
|
26
|
+
**Rubygems**: https://rubygems.org/gems/appquery/versions/0.8.0
|
|
27
|
+
|
|
28
|
+
### 💥 Breaking Changes
|
|
29
|
+
|
|
30
|
+
- ⚠️ **RSpec helpers refactored**
|
|
31
|
+
Query under test is expected to be a class, `select_*` are no longer separate helpers:
|
|
32
|
+
```ruby
|
|
33
|
+
expect(described_query.first).to \
|
|
34
|
+
include("id" => be_a(Integer), ...)
|
|
35
|
+
expect(described_query.entries).to include(a_hash_including("item_code" => "123456"))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### ✨ Features
|
|
39
|
+
|
|
40
|
+
- 📤 **`copy_to`** — efficient PostgreSQL COPY export to CSV/text/binary
|
|
41
|
+
```ruby
|
|
42
|
+
# Return as string
|
|
43
|
+
csv = AppQuery[:users].copy_to
|
|
44
|
+
|
|
45
|
+
# Write to file
|
|
46
|
+
AppQuery[:users].copy_to(dest: "export.csv")
|
|
47
|
+
|
|
48
|
+
# Stream to IO (e.g., Rails response)
|
|
49
|
+
query.copy_to(dest: response.stream)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- 🎯 **`cte(:name)`** — focus a query on a specific CTE for testing or inspection
|
|
53
|
+
```ruby
|
|
54
|
+
query = AppQuery("WITH active AS (...), admins AS (...) SELECT ...")
|
|
55
|
+
query.cte(:active).entries # select from the active CTE
|
|
56
|
+
query.cte(:admins).count # count rows in admins CTE
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- 🗃️ **`AppQuery.table(:name)`** — quick query from a table
|
|
60
|
+
```ruby
|
|
61
|
+
AppQuery.table(:products).count
|
|
62
|
+
AppQuery.table(:users).take(5)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- 🔢 **`take(n)` / `take_last(n)`** — fetch first or last n rows
|
|
66
|
+
```ruby
|
|
67
|
+
query.take(5) # first 5 rows
|
|
68
|
+
query.take_last(5) # last 5 rows
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- ⏮️ **`last`** — fetch the last row (counterpart to `first`)
|
|
72
|
+
```ruby
|
|
73
|
+
query.last # => {"id" => 42, "name" => "Zoe"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- 📋 **`column_names`** — get column names without fetching rows
|
|
77
|
+
```ruby
|
|
78
|
+
query.column_names # => ["id", "name", "email"]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- 🦄 **`unique:` keyword for `Q#column`** — return distinct values
|
|
82
|
+
```ruby
|
|
83
|
+
query.column(:status, unique: true) # => ["active", "pending"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- 🏗️ **Overhauled generators** — moved to `AppQuery::` namespace
|
|
87
|
+
```bash
|
|
88
|
+
rails g app_query:example # annotated example query
|
|
89
|
+
rails g app_query:query Products
|
|
90
|
+
rails g query Products # hidden alias
|
|
91
|
+
rails g query --help # details
|
|
92
|
+
```
|
|
93
|
+
|
|
3
94
|
## 0.7.0
|
|
4
95
|
|
|
5
96
|
**Releasedate**: 8-1-2026
|
data/Procfile.yard
ADDED
data/README.md
CHANGED
|
@@ -87,6 +87,8 @@ That introduces new problems: the not-so-intuitive `select_all`/`select_one`/`se
|
|
|
87
87
|
- Easy inspection and testing—especially for CTE-based queries
|
|
88
88
|
- Clean parameterization via named binds and ERB
|
|
89
89
|
|
|
90
|
+
Read [this blog post](https://www.gertgoet.com/appquery.html) for additional context and an overview.
|
|
91
|
+
|
|
90
92
|
## Installation
|
|
91
93
|
|
|
92
94
|
```bash
|
|
@@ -117,6 +119,31 @@ AppQuery[:weekly_sales].select_all(binds: {week: 1, year: 2025})
|
|
|
117
119
|
#=> [{"week" => 1, "category" => "Electronics", "revenue" => 12500}, ...]
|
|
118
120
|
```
|
|
119
121
|
|
|
122
|
+
Even better:
|
|
123
|
+
|
|
124
|
+
Use the query-class and define binds, vars, casts, middleware etc.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class WeeklySalesQuery < ApplicationQuery
|
|
128
|
+
include AppQuery::Paginatable
|
|
129
|
+
per_page 25
|
|
130
|
+
|
|
131
|
+
bind :week
|
|
132
|
+
bind :year, default: 2026
|
|
133
|
+
|
|
134
|
+
cast metadata: :json
|
|
135
|
+
|
|
136
|
+
# add factory methods for specific purposes
|
|
137
|
+
def self.build(page: 1, week:, year: 2026)
|
|
138
|
+
new(week:, year:).paginate(page:)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
WeeklySalesQuery.build(week: 1).entries
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Read more about the query-class in [the API docs](https://eval.github.io/appquery/AppQuery/BaseQuery.html).
|
|
146
|
+
|
|
120
147
|
## Usage
|
|
121
148
|
|
|
122
149
|
> [!NOTE]
|
|
@@ -243,25 +270,135 @@ File.open("users.csv.gz", "wb") do |f|
|
|
|
243
270
|
end
|
|
244
271
|
```
|
|
245
272
|
|
|
273
|
+
See [the method docs](https://eval.github.io/appquery/AppQuery/Q.html#copy_to-instance_method) for more (Rails) examples.
|
|
274
|
+
|
|
246
275
|
### RSpec Integration
|
|
247
276
|
|
|
248
277
|
Generated spec files include helpers:
|
|
249
278
|
|
|
250
279
|
```ruby
|
|
251
280
|
# spec/queries/reports/weekly_query_spec.rb
|
|
252
|
-
RSpec.describe
|
|
281
|
+
RSpec.describe Reports::WeeklyQuery, type: :query, binds: {since: 3.weeks.ago} do
|
|
253
282
|
describe "CTE articles" do
|
|
254
283
|
specify do
|
|
255
|
-
expect(described_query.
|
|
284
|
+
expect(described_query.entries).to \
|
|
256
285
|
include(a_hash_including("article_id" => 1))
|
|
257
|
-
|
|
258
|
-
# Short version: query, cte and select are implied from descriptions
|
|
259
|
-
expect(select_all).to include(a_hash_including("article_id" => 1))
|
|
260
286
|
end
|
|
261
287
|
end
|
|
262
288
|
end
|
|
263
289
|
```
|
|
264
290
|
|
|
291
|
+
See [the API docs](https://eval.github.io/appquery/AppQuery/RSpec/Helpers.html) for more RSpec examples.
|
|
292
|
+
|
|
293
|
+
### Writing a Middleware
|
|
294
|
+
|
|
295
|
+
A `BaseQuery` middleware is a `Module` you `include` into a query class. There are three layers to extend at, depending on *where* you want to act. The three compose cleanly on the same query class.
|
|
296
|
+
|
|
297
|
+
| You want to… | Layer | How |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| change/decorate each row | **row-level** | append to `q.row_builder` |
|
|
300
|
+
| filter, wrap, cap, paginate, cache the collection | **result-level** | override `#entries` (or `#first`, `#last`, …) |
|
|
301
|
+
| change the SQL/binds before it runs | **query-level** | override `#query`, return a different `Q` |
|
|
302
|
+
|
|
303
|
+
#### Row-level (the `row_builder` pipeline)
|
|
304
|
+
|
|
305
|
+
`Q#row_builder` is a composable pipeline of callables that each receive a row Hash and return whatever should replace it — a Hash, a `Data`, a Struct, your own model.
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
module Stamping
|
|
309
|
+
extend ActiveSupport::Concern
|
|
310
|
+
|
|
311
|
+
def query
|
|
312
|
+
@query ||= super.tap { |q| q.row_builder << ->(row) { row.merge("stamped_at" => Time.now) } }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
class ArticlesQuery < ApplicationQuery
|
|
317
|
+
include Stamping
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
ArticlesQuery.new.first # => {"id" => 1, ..., "stamped_at" => 2026-...}
|
|
321
|
+
ArticlesQuery.new.entries.first # same — every row-returning path flows through row_builder
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The pipeline propagates through `with_select(non_nil)`, `add_binds`, `with_binds`, `with_cast`, `with_sql`, and CTE focusing (`#cte`), so chained calls keep the same mapping. Each child gets an independent copy — mutating it doesn't affect the parent.
|
|
325
|
+
|
|
326
|
+
**Stacking row-level middlewares.** Transformers run in **`include` order** — earliest `include` first, latest `include` last. With
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
class MyQuery < ApplicationQuery
|
|
330
|
+
include Stamping # runs first — its row goes into…
|
|
331
|
+
include AppQuery::Mappable # …which sees the stamped hash and builds an Item
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`Stamping`'s lambda runs first, then `Mappable.build_row` consumes the already-stamped hash. ⚠️ Once a transformer returns a non-Hash (e.g. a `Data`), downstream transformers see that object — so a hash-merging transformer placed *after* `Mappable` would fail. Order the chain accordingly.
|
|
336
|
+
|
|
337
|
+
**Doesn't fit row_builder:** filtering ("drop rows the viewer can't see") and collapsing rows. Both act on the collection, not a single row — use the result-level layer instead.
|
|
338
|
+
|
|
339
|
+
#### Result-level (wrap a row-returning method)
|
|
340
|
+
|
|
341
|
+
When you want to act on the whole collection — wrap, cap, cache, filter, paginate — override the row-returning method and call `super`. `super` returns rows that have *already* been through `row_builder`, so this composes with row-level middleware without thinking.
|
|
342
|
+
|
|
343
|
+
`Paginatable` is the canonical example (wraps `#entries` in a `PaginatedResult`). Some others:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
module Caching
|
|
347
|
+
# memoise the whole result on the instance
|
|
348
|
+
def entries = @_entries ||= super
|
|
349
|
+
def first = @_first ||= super
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
module ScopedToTenant
|
|
353
|
+
def entries = super.select { |r| r["tenant_id"] == Current.tenant.id }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
module Capped
|
|
357
|
+
CapResult = Data.define(:records, :hit_cap?) do
|
|
358
|
+
include Enumerable
|
|
359
|
+
def each(&b) = records.each(&b)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def entries
|
|
363
|
+
rows = super
|
|
364
|
+
CapResult.new(records: rows.first(self.class.cap), hit_cap?: rows.size > self.class.cap)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
#### Query-level (rewrite SQL/binds before execution)
|
|
370
|
+
|
|
371
|
+
When you want to change what the database actually sees — tenant scoping, soft-delete filtering, default ordering — override `#query` and return a transformed `Q`. Use `with_select` / `with_binds` / `add_binds` etc.; they propagate the `row_builder` pipeline via `deep_dup`, so row-level middleware keeps working.
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
module TenantScoped
|
|
375
|
+
def query
|
|
376
|
+
@query ||= super.add_binds(tenant_id: Current.tenant.id)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
module HidesDeleted
|
|
381
|
+
def query
|
|
382
|
+
@query ||= super.with_select("SELECT * FROM :_ WHERE deleted_at IS NULL")
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
#### Putting it together
|
|
388
|
+
|
|
389
|
+
All three layers can sit on one class:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
class ArticlesQuery < ApplicationQuery
|
|
393
|
+
include HidesDeleted # query-level: rewrites SQL
|
|
394
|
+
include Stamping # row-level: adds "stamped_at"
|
|
395
|
+
include AppQuery::Mappable # row-level: builds Item (must come after row-mutating middleware)
|
|
396
|
+
include AppQuery::Paginatable # result-level: wraps entries
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Pipeline at run time, top to bottom: SQL is rewritten to filter `deleted_at IS NULL` → DB returns rows → each row gets `stamped_at` → each row becomes an `Item` → the array of Items is wrapped in a `PaginatedResult`.
|
|
401
|
+
|
|
265
402
|
## API Documentation
|
|
266
403
|
|
|
267
404
|
See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
|
|
@@ -289,9 +426,13 @@ bin/run rails_head console
|
|
|
289
426
|
|
|
290
427
|
# Run tests
|
|
291
428
|
rake spec
|
|
429
|
+
|
|
430
|
+
# YARD with reload (requires entr and overmind/foreman)
|
|
431
|
+
bin/yard-dev
|
|
292
432
|
```
|
|
293
433
|
|
|
294
|
-
Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
|
|
434
|
+
Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
|
|
435
|
+
Using [bonchi](https://rubygems.org/gems/bonchi) allows for agentic working via git worktrees. See `.worktree.yml.example` for a config.
|
|
295
436
|
|
|
296
437
|
### Releasing
|
|
297
438
|
|
data/lib/app_query/base_query.rb
CHANGED
|
@@ -154,7 +154,8 @@ module AppQuery
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s,
|
|
157
|
+
delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s,
|
|
158
|
+
:column, :first, :last, :take, :take_last, :ids, :copy_to, to: :query
|
|
158
159
|
|
|
159
160
|
def query
|
|
160
161
|
@query ||= base_query
|
data/lib/app_query/mappable.rb
CHANGED
|
@@ -66,31 +66,20 @@ module AppQuery
|
|
|
66
66
|
self
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def
|
|
74
|
-
|
|
69
|
+
# Append our transform to the underlying Q's RowBuilder pipeline so every
|
|
70
|
+
# row-returning path (entries, first, last, take, with_select(...).first,
|
|
71
|
+
# …) sees mapped rows. Stacks with other row-level middlewares in
|
|
72
|
+
# include-order — earlier `include`s run first.
|
|
73
|
+
def query
|
|
74
|
+
@query ||= super.tap { |q| q.row_builder << method(:build_row) }
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
private
|
|
78
78
|
|
|
79
|
-
def
|
|
80
|
-
return
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
attrs = klass.members
|
|
84
|
-
result.transform! { |row| klass.new(**row.symbolize_keys.slice(*attrs)) }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def map_one(result)
|
|
88
|
-
return result if @raw
|
|
89
|
-
return result unless (klass = resolve_map_klass)
|
|
90
|
-
return result unless result
|
|
91
|
-
|
|
92
|
-
attrs = klass.members
|
|
93
|
-
klass.new(**result.symbolize_keys.slice(*attrs))
|
|
79
|
+
def build_row(row)
|
|
80
|
+
return row if @raw
|
|
81
|
+
return row unless (klass = resolve_map_klass)
|
|
82
|
+
klass.new(**row.symbolize_keys.slice(*klass.members))
|
|
94
83
|
end
|
|
95
84
|
|
|
96
85
|
def resolve_map_klass
|
|
@@ -31,7 +31,17 @@ module AppQuery
|
|
|
31
31
|
# RSpec.describe ProductsQuery, type: :query do
|
|
32
32
|
# describe "as admin", vars: {admin: true} do
|
|
33
33
|
# it "returns all products" do
|
|
34
|
-
# expect(described_query.
|
|
34
|
+
# expect(described_query.count).to eq(3)
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# @example SQL logging for debugging
|
|
40
|
+
# RSpec.describe ProductsQuery, type: :query do
|
|
41
|
+
# describe "debugging", log: true do
|
|
42
|
+
# it "logs SQL to stdout" do
|
|
43
|
+
# # SQL queries will be printed to the console
|
|
44
|
+
# described_query.entries
|
|
35
45
|
# end
|
|
36
46
|
# end
|
|
37
47
|
# end
|
data/lib/app_query/rspec.rb
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
|
|
1
|
+
module AppQuery
|
|
2
|
+
# RSpec integration for testing query classes.
|
|
3
|
+
#
|
|
4
|
+
# Provides helpers for testing queries, including CTE isolation,
|
|
5
|
+
# bind/var metadata, and SQL logging.
|
|
6
|
+
#
|
|
7
|
+
# @example Setup in spec/rails_helper.rb
|
|
8
|
+
# require "app_query/rspec"
|
|
9
|
+
#
|
|
10
|
+
# @example Basic query spec
|
|
11
|
+
# RSpec.describe ProductsQuery, type: :query do
|
|
12
|
+
# it "returns products" do
|
|
13
|
+
# expect(described_query.entries).to be_present
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @see AppQuery::RSpec::Helpers
|
|
18
|
+
module RSpec
|
|
19
|
+
autoload :Helpers, "app_query/rspec/helpers"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
2
22
|
|
|
3
|
-
RSpec.configure do |config|
|
|
23
|
+
::RSpec.configure do |config|
|
|
4
24
|
config.include AppQuery::RSpec::Helpers, type: :query
|
|
5
25
|
|
|
6
26
|
# Enable SQL logging with `log: true` metadata
|
data/lib/app_query/tokenizer.rb
CHANGED
|
@@ -257,9 +257,15 @@ module AppQuery
|
|
|
257
257
|
|
|
258
258
|
level = 1
|
|
259
259
|
loop do
|
|
260
|
-
read_until(
|
|
260
|
+
read_until(%r{\)|\(|'|--|/\*})
|
|
261
261
|
if eos?
|
|
262
262
|
err "CTE select ended prematurely"
|
|
263
|
+
elsif match?("--")
|
|
264
|
+
read_until(/\n/)
|
|
265
|
+
elsif match?(%r{/\*})
|
|
266
|
+
read_until(%r{\*/})
|
|
267
|
+
err "CTE select ended prematurely" if eos?
|
|
268
|
+
read_char 2
|
|
263
269
|
elsif match?(/'/)
|
|
264
270
|
# Skip string literal (handle escaped quotes '')
|
|
265
271
|
read_char
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -73,6 +73,26 @@ module AppQuery
|
|
|
73
73
|
end
|
|
74
74
|
reset_configuration!
|
|
75
75
|
|
|
76
|
+
# @!group Quoting Helpers
|
|
77
|
+
|
|
78
|
+
# Quotes a table name for safe use in SQL.
|
|
79
|
+
#
|
|
80
|
+
# @param name [String, Symbol] the table name
|
|
81
|
+
# @return [String] the quoted table name
|
|
82
|
+
def self.quote_table(name)
|
|
83
|
+
ActiveRecord::Base.connection.quote_table_name(name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Quotes a column name for safe use in SQL.
|
|
87
|
+
#
|
|
88
|
+
# @param name [String, Symbol] the column name
|
|
89
|
+
# @return [String] the quoted column name
|
|
90
|
+
def self.quote_column(name)
|
|
91
|
+
ActiveRecord::Base.connection.quote_column_name(name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @!endgroup
|
|
95
|
+
|
|
76
96
|
# Loads a query from a file in the configured query path.
|
|
77
97
|
#
|
|
78
98
|
# When no extension is provided, tries `.sql` first, then `.sql.erb`.
|
|
@@ -132,23 +152,89 @@ module AppQuery
|
|
|
132
152
|
# AppQuery.table(:users, binds: {active: true})
|
|
133
153
|
# .select_all("SELECT * FROM :_ WHERE active = :active")
|
|
134
154
|
def self.table(name, **opts)
|
|
135
|
-
|
|
136
|
-
|
|
155
|
+
Q.new("SELECT * FROM #{quote_table(name)}", name: "AppQuery.table(#{name})", **opts)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Composable pipeline of row transformers.
|
|
159
|
+
#
|
|
160
|
+
# Appended via `<<` and applied in registration order — earliest pushed
|
|
161
|
+
# runs first, latest pushed runs last (so its return value is what callers
|
|
162
|
+
# see). An empty pipeline is a no-op.
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# rb = AppQuery::RowBuilder.new
|
|
166
|
+
# rb << ->(row) { row.merge("a" => 1) }
|
|
167
|
+
# rb << ->(row) { row.merge("b" => 2) }
|
|
168
|
+
# rb.call({}) # => {"a" => 1, "b" => 2}
|
|
169
|
+
class RowBuilder
|
|
170
|
+
def initialize(procs = [])
|
|
171
|
+
@procs = procs
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Append a transformer. Returns self so `q.row_builder << proc` reads
|
|
175
|
+
# naturally and `<<=` also works.
|
|
176
|
+
def <<(callable)
|
|
177
|
+
@procs << callable
|
|
178
|
+
self
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def call(row)
|
|
182
|
+
@procs.reduce(row) { |acc, p| p.call(acc) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def empty? = @procs.empty?
|
|
186
|
+
|
|
187
|
+
def size = @procs.size
|
|
188
|
+
|
|
189
|
+
# Independent copy — used by Q#deep_dup so chained queries don't share
|
|
190
|
+
# the parent's pipeline.
|
|
191
|
+
def dup
|
|
192
|
+
self.class.new(@procs.dup)
|
|
193
|
+
end
|
|
137
194
|
end
|
|
138
195
|
|
|
139
196
|
class Result < ActiveRecord::Result
|
|
140
|
-
attr_accessor :cast
|
|
197
|
+
attr_accessor :cast, :row_builder
|
|
141
198
|
alias_method :cast?, :cast
|
|
142
199
|
|
|
143
|
-
def initialize(columns, rows, overrides = nil, cast: false)
|
|
200
|
+
def initialize(columns, rows, overrides = nil, cast: false, row_builder: nil)
|
|
144
201
|
super(columns, rows, overrides)
|
|
145
202
|
@cast = cast
|
|
203
|
+
@row_builder = row_builder
|
|
146
204
|
# Rails v6.1: prevent mutate on frozen object on #first
|
|
147
205
|
@hash_rows = [] if columns.empty?
|
|
148
206
|
end
|
|
149
207
|
|
|
208
|
+
# AR::Result#first reads @hash_rows directly when not yet memoized,
|
|
209
|
+
# bypassing our hash_rows override. Force it through.
|
|
210
|
+
def first
|
|
211
|
+
hash_rows.first
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Returns an array of values for a single column.
|
|
215
|
+
#
|
|
216
|
+
# @note If you only need a single column, prefer {Q#column} which selects
|
|
217
|
+
# only that column from the database, avoiding fetching all columns.
|
|
218
|
+
#
|
|
219
|
+
# @param name [String, Symbol, nil] the column name (nil returns first column)
|
|
220
|
+
# @param unique [Boolean] whether to return only unique values
|
|
221
|
+
# @return [Array] the column values
|
|
222
|
+
# @raise [ArgumentError] if the column doesn't exist
|
|
223
|
+
#
|
|
224
|
+
# @example Get values by column name
|
|
225
|
+
# result.column(:name) # => ["Alice", "Bob"]
|
|
226
|
+
# result.column("name") # => ["Alice", "Bob"]
|
|
227
|
+
#
|
|
228
|
+
# @example Get first column (no name)
|
|
229
|
+
# result.column # => [1, 2, 3]
|
|
230
|
+
#
|
|
231
|
+
# @example Get unique values
|
|
232
|
+
# result.column(:status, unique: true) # => ["active", "pending"]
|
|
233
|
+
#
|
|
234
|
+
# @see Q#column
|
|
150
235
|
def column(name = nil, unique: false)
|
|
151
236
|
return [] if empty?
|
|
237
|
+
name = name&.to_s
|
|
152
238
|
unless name.nil? || includes_column?(name)
|
|
153
239
|
raise ArgumentError, "Unknown column #{name.inspect}. Should be one of #{columns.inspect}."
|
|
154
240
|
end
|
|
@@ -163,9 +249,13 @@ module AppQuery
|
|
|
163
249
|
private
|
|
164
250
|
|
|
165
251
|
# Override to provide indifferent access (string or symbol keys).
|
|
252
|
+
# Applies the RowBuilder pipeline lazily so callers see built rows
|
|
253
|
+
# everywhere (first, last, each, entries, to_a, [] …). An empty/absent
|
|
254
|
+
# builder is a no-op.
|
|
166
255
|
def hash_rows
|
|
167
256
|
@hash_rows ||= rows.map do |row|
|
|
168
|
-
columns.zip(row).to_h.with_indifferent_access
|
|
257
|
+
hash = columns.zip(row).to_h.with_indifferent_access
|
|
258
|
+
row_builder ? row_builder.call(hash) : hash
|
|
169
259
|
end
|
|
170
260
|
end
|
|
171
261
|
|
|
@@ -202,9 +292,9 @@ module AppQuery
|
|
|
202
292
|
end
|
|
203
293
|
end
|
|
204
294
|
|
|
205
|
-
def self.from_ar_result(r, cast = nil)
|
|
295
|
+
def self.from_ar_result(r, cast = nil, row_builder: nil)
|
|
206
296
|
if r.empty?
|
|
207
|
-
r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types)
|
|
297
|
+
r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types, row_builder:)
|
|
208
298
|
else
|
|
209
299
|
cast &&= case cast
|
|
210
300
|
when Array
|
|
@@ -216,7 +306,7 @@ module AppQuery
|
|
|
216
306
|
end
|
|
217
307
|
if !cast || (cast.empty? && r.column_types.empty?)
|
|
218
308
|
# nothing to cast
|
|
219
|
-
new(r.columns, r.rows, r.column_types)
|
|
309
|
+
new(r.columns, r.rows, r.column_types, row_builder:)
|
|
220
310
|
else
|
|
221
311
|
overrides = (r.column_types || {}).merge(cast)
|
|
222
312
|
rows = r.cast_values(overrides)
|
|
@@ -226,7 +316,7 @@ module AppQuery
|
|
|
226
316
|
# > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
|
|
227
317
|
# => [[1, 2]]
|
|
228
318
|
rows = rows.zip if r.columns.one?
|
|
229
|
-
new(r.columns, rows, overrides, cast: true)
|
|
319
|
+
new(r.columns, rows, overrides, cast: true, row_builder:)
|
|
230
320
|
end
|
|
231
321
|
end
|
|
232
322
|
end
|
|
@@ -276,6 +366,15 @@ module AppQuery
|
|
|
276
366
|
# @return [Boolean, Hash, Array] casting configuration
|
|
277
367
|
attr_reader :sql, :name, :filename, :binds, :cast
|
|
278
368
|
|
|
369
|
+
# Middleware extension point. The {RowBuilder} is a composable pipeline:
|
|
370
|
+
# middlewares append transformers with `q.row_builder << ->(row) { … }`
|
|
371
|
+
# and the result is applied to every row everywhere Q exposes rows
|
|
372
|
+
# (entries, first, last, take, take_last, with_select(...).first, …).
|
|
373
|
+
# Propagated through {#deep_dup} (with an independent copy) so chained
|
|
374
|
+
# queries inherit the pipeline but don't mutate the parent's.
|
|
375
|
+
# @return [RowBuilder]
|
|
376
|
+
attr_accessor :row_builder
|
|
377
|
+
|
|
279
378
|
# Creates a new query object.
|
|
280
379
|
#
|
|
281
380
|
# @param sql [String] the SQL query string (may contain ERB)
|
|
@@ -289,13 +388,14 @@ module AppQuery
|
|
|
289
388
|
#
|
|
290
389
|
# @example With ERB and binds
|
|
291
390
|
# Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
|
|
292
|
-
def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
|
|
391
|
+
def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0, row_builder: nil)
|
|
293
392
|
@sql = sql
|
|
294
393
|
@name = name
|
|
295
394
|
@filename = filename
|
|
296
395
|
@binds = binds
|
|
297
396
|
@cast = cast
|
|
298
397
|
@cte_depth = cte_depth
|
|
398
|
+
@row_builder = row_builder || RowBuilder.new
|
|
299
399
|
@binds = binds_with_defaults(sql, binds)
|
|
300
400
|
end
|
|
301
401
|
|
|
@@ -318,8 +418,8 @@ module AppQuery
|
|
|
318
418
|
end
|
|
319
419
|
end
|
|
320
420
|
|
|
321
|
-
def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
|
|
322
|
-
self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
|
|
421
|
+
def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth, row_builder: self.row_builder.dup)
|
|
422
|
+
self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:, row_builder:)
|
|
323
423
|
end
|
|
324
424
|
|
|
325
425
|
# @!group Rendering
|
|
@@ -427,7 +527,7 @@ module AppQuery
|
|
|
427
527
|
ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
|
|
428
528
|
end
|
|
429
529
|
ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
|
|
430
|
-
Result.from_ar_result(result, cast)
|
|
530
|
+
Result.from_ar_result(result, cast, row_builder: aq.row_builder)
|
|
431
531
|
end
|
|
432
532
|
end
|
|
433
533
|
rescue NameError => e
|
|
@@ -451,7 +551,28 @@ module AppQuery
|
|
|
451
551
|
def select_one(s = nil, binds: {}, cast: self.cast)
|
|
452
552
|
with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
|
|
453
553
|
end
|
|
454
|
-
|
|
554
|
+
|
|
555
|
+
def first(...) = select_one(...)
|
|
556
|
+
|
|
557
|
+
# Executes the query and returns the last row.
|
|
558
|
+
#
|
|
559
|
+
# Uses OFFSET to skip to the last row without changing the query order.
|
|
560
|
+
# Note: This requires counting all rows first, so it's less efficient
|
|
561
|
+
# than {#first} for large result sets.
|
|
562
|
+
#
|
|
563
|
+
# @param s [String, nil] optional SELECT to apply before fetching
|
|
564
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
565
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
566
|
+
# @return [Hash, nil] the last row as a hash, or nil if no results
|
|
567
|
+
#
|
|
568
|
+
# @example
|
|
569
|
+
# AppQuery("SELECT * FROM users ORDER BY created_at").last
|
|
570
|
+
# # => {"id" => 42, "name" => "Zoe"}
|
|
571
|
+
#
|
|
572
|
+
# @see #first
|
|
573
|
+
def last(s = nil, binds: {}, cast: self.cast)
|
|
574
|
+
take_last(1, s, binds:, cast:).first
|
|
575
|
+
end
|
|
455
576
|
|
|
456
577
|
# Executes the query and returns the first n rows.
|
|
457
578
|
#
|
|
@@ -471,6 +592,31 @@ module AppQuery
|
|
|
471
592
|
end
|
|
472
593
|
alias_method :limit, :take
|
|
473
594
|
|
|
595
|
+
# Executes the query and returns the last n rows.
|
|
596
|
+
#
|
|
597
|
+
# Uses OFFSET to skip to the last n rows without changing the query order.
|
|
598
|
+
# Note: This requires counting all rows first, so it's less efficient
|
|
599
|
+
# than {#take} for large result sets.
|
|
600
|
+
#
|
|
601
|
+
# @param n [Integer] the number of rows to return
|
|
602
|
+
# @param s [String, nil] optional SELECT to apply before taking
|
|
603
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
604
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
605
|
+
# @return [Array<Hash>] the last n rows as an array of hashes
|
|
606
|
+
#
|
|
607
|
+
# @example
|
|
608
|
+
# AppQuery("SELECT * FROM users ORDER BY created_at").take_last(5)
|
|
609
|
+
# # => [{"id" => 38, ...}, {"id" => 39, ...}, ...]
|
|
610
|
+
#
|
|
611
|
+
# @see #last
|
|
612
|
+
def take_last(n, s = nil, binds: {}, cast: self.cast)
|
|
613
|
+
offset_expr = greatest("(SELECT COUNT(*) FROM :_) - #{n.to_i}", "0")
|
|
614
|
+
with_select(s).select_all(
|
|
615
|
+
"SELECT * FROM :_ LIMIT #{n.to_i} OFFSET #{offset_expr}",
|
|
616
|
+
binds:, cast:
|
|
617
|
+
).entries
|
|
618
|
+
end
|
|
619
|
+
|
|
474
620
|
# Executes the query and returns the first value of the first row.
|
|
475
621
|
#
|
|
476
622
|
# @param binds [Hash, nil] named bind parameters
|
|
@@ -567,9 +713,19 @@ module AppQuery
|
|
|
567
713
|
# @example Extract unique values
|
|
568
714
|
# AppQuery("SELECT * FROM products").column(:category, unique: true)
|
|
569
715
|
# # => ["Electronics", "Clothing", "Home"]
|
|
716
|
+
#
|
|
717
|
+
# @raise [ArgumentError] if the column doesn't exist in the (optionally
|
|
718
|
+
# selected) query. Pre-validating catches typos consistently across
|
|
719
|
+
# databases — e.g. without this, SQLite's "double-quoted strings"
|
|
720
|
+
# quirk would silently return rows of the column-name as a string.
|
|
570
721
|
def column(c, s = nil, binds: {}, unique: false)
|
|
571
|
-
|
|
572
|
-
|
|
722
|
+
available = column_names(s, binds:)
|
|
723
|
+
unless available.include?(c.to_s)
|
|
724
|
+
raise ArgumentError, "Unknown column #{c.inspect}. Available: #{available.inspect}."
|
|
725
|
+
end
|
|
726
|
+
quoted = quote_column(c)
|
|
727
|
+
select_expr = unique ? "DISTINCT #{quoted}" : quoted
|
|
728
|
+
with_select(s).select_all("SELECT #{select_expr} AS column FROM :_", binds:).column("column")
|
|
573
729
|
end
|
|
574
730
|
|
|
575
731
|
# Returns the column names from the query without fetching any rows.
|
|
@@ -581,13 +737,13 @@ module AppQuery
|
|
|
581
737
|
# @return [Array<String>] the column names
|
|
582
738
|
#
|
|
583
739
|
# @example Get column names
|
|
584
|
-
# AppQuery("SELECT id, name, email FROM users").
|
|
740
|
+
# AppQuery("SELECT id, name, email FROM users").column_names
|
|
585
741
|
# # => ["id", "name", "email"]
|
|
586
742
|
#
|
|
587
743
|
# @example From a CTE
|
|
588
|
-
# AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").
|
|
744
|
+
# AppQuery("WITH t(a, b) AS (VALUES (1, 2)) SELECT * FROM t").column_names
|
|
589
745
|
# # => ["a", "b"]
|
|
590
|
-
def
|
|
746
|
+
def column_names(s = nil, binds: {})
|
|
591
747
|
with_select(s).select_all("SELECT * FROM :_ LIMIT 0", binds:).columns
|
|
592
748
|
end
|
|
593
749
|
|
|
@@ -761,6 +917,9 @@ module AppQuery
|
|
|
761
917
|
# end
|
|
762
918
|
# end
|
|
763
919
|
#
|
|
920
|
+
# @example Rails runner
|
|
921
|
+
# bin/rails runner "puts Export::ProductsQuery.new.copy_to" > tmp/products.csv
|
|
922
|
+
#
|
|
764
923
|
# @raise [AppQuery::Error] if adapter is not PostgreSQL
|
|
765
924
|
def copy_to(s = nil, format: :csv, header: true, delimiter: nil, dest: nil, binds: {})
|
|
766
925
|
raw_conn = ActiveRecord::Base.connection.raw_connection
|
|
@@ -991,8 +1150,7 @@ module AppQuery
|
|
|
991
1150
|
unless cte_names.include?(name)
|
|
992
1151
|
raise ArgumentError, "Unknown CTE #{name.inspect}. Available: #{cte_names.inspect}"
|
|
993
1152
|
end
|
|
994
|
-
|
|
995
|
-
with_select("SELECT * FROM #{quoted}")
|
|
1153
|
+
with_select("SELECT * FROM #{quote_table(name)}")
|
|
996
1154
|
end
|
|
997
1155
|
|
|
998
1156
|
# Prepends a CTE to the beginning of the WITH clause.
|
|
@@ -1124,6 +1282,26 @@ module AppQuery
|
|
|
1124
1282
|
def to_s
|
|
1125
1283
|
@sql
|
|
1126
1284
|
end
|
|
1285
|
+
|
|
1286
|
+
private
|
|
1287
|
+
|
|
1288
|
+
def quote_table(name)
|
|
1289
|
+
AppQuery.quote_table(name)
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
def quote_column(name)
|
|
1293
|
+
AppQuery.quote_column(name)
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
# Returns SQL for max(a, b) that works across adapters.
|
|
1297
|
+
# PostgreSQL uses GREATEST, SQLite uses MAX for scalar comparison.
|
|
1298
|
+
def greatest(a, b)
|
|
1299
|
+
if /sqlite/i.match?(ActiveRecord::Base.connection.adapter_name)
|
|
1300
|
+
"MAX(#{a}, #{b})"
|
|
1301
|
+
else
|
|
1302
|
+
"GREATEST(#{a}, #{b})"
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1127
1305
|
end
|
|
1128
1306
|
end
|
|
1129
1307
|
|
data/mise.local.toml.example
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: appquery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: appraisal
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,6 +57,7 @@ files:
|
|
|
43
57
|
- ".irbrc"
|
|
44
58
|
- ".rspec"
|
|
45
59
|
- ".standard.yml"
|
|
60
|
+
- ".worktree.yml.example"
|
|
46
61
|
- ".yard/templates/default/fulldoc/html/css/dark.css"
|
|
47
62
|
- ".yard/templates/default/fulldoc/html/js/app.js"
|
|
48
63
|
- ".yard/templates/default/fulldoc/html/setup.rb"
|
|
@@ -52,6 +67,7 @@ files:
|
|
|
52
67
|
- Appraisals
|
|
53
68
|
- CHANGELOG.md
|
|
54
69
|
- LICENSE.txt
|
|
70
|
+
- Procfile.yard
|
|
55
71
|
- README.md
|
|
56
72
|
- Rakefile
|
|
57
73
|
- assets/banner-dark.svg
|
|
@@ -93,6 +109,7 @@ metadata:
|
|
|
93
109
|
homepage_uri: https://github.com/eval/appquery
|
|
94
110
|
source_code_uri: https://github.com/eval/appquery
|
|
95
111
|
changelog_uri: https://github.com/eval/appquery/blob/main/CHANGELOG.md
|
|
112
|
+
rubygems_mfa_required: 'true'
|
|
96
113
|
rdoc_options: []
|
|
97
114
|
require_paths:
|
|
98
115
|
- lib
|