appquery 0.2.0 → 0.4.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/.irbrc +20 -0
- data/Appraisals +25 -0
- data/LICENSE.txt +1 -1
- data/README.md +135 -9
- data/lib/app_query/base.rb +45 -0
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +308 -23
- data/mise.local.toml.example +5 -0
- data/mise.toml +6 -0
- data/rakelib/gem.rake +25 -0
- metadata +25 -11
- data/.envrc +0 -6
- data/.envrc.private.example +0 -2
- data/tmp/.gitkeep +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 585ecafd973b1dd9fc8c944b93c64998a8b983438250cbd1ec81317d9e2362d4
|
|
4
|
+
data.tar.gz: 8197dc68853e7299266a0f8c2dd7fa102bbbf7e743680786bce4c7c2eedcd719
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 37b5baf0f42dbad9ee1f356e0541b5677cb93a934d38ad851931142f79a041337f77cbbd4f56c1e880b4db9140460237afa8627eb0f6f9c73461fc1f6b3843cb
|
|
7
|
+
data.tar.gz: 3edc3e28e09a648a3d14634beab10cdde1be8d1b31c6c8163fd2f19844fab31a5ecf762ddcef9311f18d1356fe6e2c765f99dd2d08706284c687e94d21c36261
|
data/.irbrc
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
puts "Loading #{__FILE__}"
|
|
2
|
+
# put overrides/additions in '_irbrc'
|
|
3
|
+
|
|
4
|
+
IRB.conf[:HISTORY_FILE] = "#{ENV["PROJECT_ROOT"]}/tmp/.irb_history"
|
|
5
|
+
|
|
6
|
+
# Custom IRB prompt showing database adapter
|
|
7
|
+
db_indicator = begin
|
|
8
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
9
|
+
"\e[33m[#{adapter}]\e[0m "
|
|
10
|
+
rescue ActiveRecord::ConnectionNotEstablished, NameError
|
|
11
|
+
"\e[34m[no-db]\e[0m "
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
IRB.conf[:PROMPT][:APPQUERY] = {
|
|
15
|
+
PROMPT_I: "#{db_indicator}appquery> ",
|
|
16
|
+
PROMPT_S: "#{db_indicator}appquery%l ",
|
|
17
|
+
PROMPT_C: "#{db_indicator}appquery* ",
|
|
18
|
+
RETURN: "=> %s\n"
|
|
19
|
+
}
|
|
20
|
+
IRB.conf[:PROMPT_MODE] = :APPQUERY
|
data/Appraisals
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
appraise "rails-70" do
|
|
2
|
+
gem "rails", "~> 7.0.0"
|
|
3
|
+
gem "sqlite3", "~> 1.4"
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
appraise "rails-71" do
|
|
7
|
+
gem "rails", "~> 7.1.0"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
appraise "rails-72" do
|
|
11
|
+
gem "rails", "~> 7.2.0"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
appraise "rails-80" do
|
|
15
|
+
gem "rails", "~> 8.0.0"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
appraise "rails-81" do
|
|
19
|
+
gem "rails", "~> 8.1.0"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
appraise "rails-head" do
|
|
23
|
+
gem "activerecord", github: "rails/rails"
|
|
24
|
+
gem "rails", github: "rails/rails"
|
|
25
|
+
end
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/appquery)
|
|
4
4
|
|
|
5
|
-
A Rubygem :gem: that makes working with raw SQL queries in Rails projects more convenient.
|
|
5
|
+
A Rubygem :gem: that makes working with raw SQL (READ) queries in Rails projects more convenient.
|
|
6
6
|
Specifically it provides:
|
|
7
7
|
- **...a dedicated folder for queries**
|
|
8
8
|
e.g. `app/queries/reports/weekly.sql` is instantiated via `AppQuery["reports/weekly"]`.
|
|
@@ -13,6 +13,31 @@ Specifically it provides:
|
|
|
13
13
|
invoke rspec
|
|
14
14
|
create spec/queries/reports/weekly_query_spec.rb
|
|
15
15
|
```
|
|
16
|
+
- **...ERB templating**
|
|
17
|
+
Simple ERB templating with helper-functions:
|
|
18
|
+
```sql
|
|
19
|
+
-- app/queries/contracts.sql.erb
|
|
20
|
+
SELECT * FROM contracts
|
|
21
|
+
<%= order_by(order) %>
|
|
22
|
+
```
|
|
23
|
+
```ruby
|
|
24
|
+
AppQuery["contracts.sql.erb"].render(order: {year: :desc, month: :desc}).select_all
|
|
25
|
+
```
|
|
26
|
+
- **...positional and named binds**
|
|
27
|
+
Intuitive binds:
|
|
28
|
+
```ruby
|
|
29
|
+
AppQuery(%{select now() - (:interval)::interval as some_date}).select_value(binds: {interval: '1 day'})
|
|
30
|
+
AppQuery(<<~SQL).select_all(binds: [2.day.ago, Time.now, '5 minutes']).column("series")
|
|
31
|
+
select generate_series($1::timestamp, $2::timestamp, $3::interval) as series
|
|
32
|
+
SQL
|
|
33
|
+
```
|
|
34
|
+
- **...casting**
|
|
35
|
+
Automatic and custom casting:
|
|
36
|
+
```ruby
|
|
37
|
+
AppQuery(%{select array[1,2]}).select_value #=> [1,2]
|
|
38
|
+
cast = {"data" => ActiveRecord::Type::Json.new}
|
|
39
|
+
AppQuery(%{select '{"a": 1}' as data}).select_value(cast:)
|
|
40
|
+
```
|
|
16
41
|
- **...helpers to rewrite a query for introspection during development and testing**
|
|
17
42
|
See what a CTE yields: `query.select_all(select: "SELECT * FROM some_cte")`.
|
|
18
43
|
Query the end result: `query.select_one(select: "SELECT COUNT(*) FROM _ WHERE ...")`.
|
|
@@ -38,7 +63,12 @@ Specifically it provides:
|
|
|
38
63
|
|
|
39
64
|
> [!IMPORTANT]
|
|
40
65
|
> **Status**: alpha. API might change. See the CHANGELOG for breaking changes when upgrading.
|
|
41
|
-
>
|
|
66
|
+
>
|
|
67
|
+
|
|
68
|
+
## Rationale
|
|
69
|
+
|
|
70
|
+
Sometimes ActiveRecord doesn't cut it, and you'd rather use raw SQL to get the right data out. That, however, introduces some new problems. First of all, you'll run into the not-so-intuitive use of [select_(all|one|value)](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-select_all) — for example, how they differ with respect to type casting, and how their behavior can vary between ActiveRecord versions. Then there's the testability, introspection, and maintainability of the resulting SQL queries.
|
|
71
|
+
This library aims to alleviate all of these issues by providing a consistent interface across select_* methods and ActiveRecord versions. It should make inspecting and testing queries easier—especially when they're built from CTEs.
|
|
42
72
|
|
|
43
73
|
## Installation
|
|
44
74
|
|
|
@@ -53,7 +83,88 @@ bundle add appquery
|
|
|
53
83
|
> [!NOTE]
|
|
54
84
|
> The following (trivial) examples are not meant to convince you to ditch your ORM, but just to show how this gem handles raw SQL queries.
|
|
55
85
|
|
|
56
|
-
###
|
|
86
|
+
### ...from console
|
|
87
|
+
|
|
88
|
+
Testdriving can be easily done from the console. Either by cloning this repository (recommended, see `Development`-section) or installing the gem in an existing Rails project.
|
|
89
|
+
<details>
|
|
90
|
+
<summary>Database setup (the `bin/console`-script does this for your)</summary>
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
94
|
+
ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
|
|
95
|
+
```
|
|
96
|
+
</details>
|
|
97
|
+
|
|
98
|
+
The prompt indicates what adapter the example uses:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
# showing select_(all|one|value)
|
|
102
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all.to_a
|
|
103
|
+
=> [{"today" => "2025-05-10"}]
|
|
104
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
105
|
+
=> {"today" => "2025-05-10"}
|
|
106
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_value
|
|
107
|
+
=> "2025-05-10"
|
|
108
|
+
|
|
109
|
+
# binds
|
|
110
|
+
# positional binds
|
|
111
|
+
[postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
|
|
112
|
+
# named binds
|
|
113
|
+
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
114
|
+
|
|
115
|
+
# casting
|
|
116
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
|
|
117
|
+
=> [{"today" => Sat, 10 May 2025}]
|
|
118
|
+
|
|
119
|
+
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
120
|
+
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
121
|
+
=> {"today" => "2025-05-12"}
|
|
122
|
+
## Providing per-column-casts fixes this:
|
|
123
|
+
casts = {"today" => ActiveRecord::Type::Date.new}
|
|
124
|
+
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
|
|
125
|
+
=> {"today" => Mon, 12 May 2025}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# rewriting queries (using CTEs)
|
|
129
|
+
[postgresql]> articles = [
|
|
130
|
+
[1, "Using my new static site generator", 2.months.ago.to_date],
|
|
131
|
+
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
132
|
+
[3, "Another article", 2.weeks.ago.to_date]
|
|
133
|
+
]
|
|
134
|
+
[postgresql]> q = AppQuery(<<~SQL, cast: {"published_on" => ActiveRecord::Type::Date.new}).render(articles:)
|
|
135
|
+
WITH articles(id,title,published_on) AS (<%= values(articles) %>)
|
|
136
|
+
select * from articles order by id DESC
|
|
137
|
+
SQL
|
|
138
|
+
|
|
139
|
+
## query the articles-CTE
|
|
140
|
+
[postgresql]> q.select_all(select: %{select * from articles where id < 2}).to_a
|
|
141
|
+
|
|
142
|
+
## query the end-result (available as the CTE named '_')
|
|
143
|
+
[postgresql]> q.select_one(select: %{select * from _ limit 1})
|
|
144
|
+
|
|
145
|
+
## ERB templating
|
|
146
|
+
# Extract a query from q that can be sorted dynamically:
|
|
147
|
+
[postgresql]> q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
|
|
148
|
+
[postgresql]> q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
|
|
149
|
+
|
|
150
|
+
# shows latest articles first, and titles sorted alphabetically
|
|
151
|
+
# for articles published on the same date.
|
|
152
|
+
# order_by raises when it's passed something that would result in just `ORDER BY`:
|
|
153
|
+
[postgresql]> q2.render(order: {})
|
|
154
|
+
|
|
155
|
+
# doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
|
|
156
|
+
[postgresql]> q2.select_all.entries
|
|
157
|
+
|
|
158
|
+
# NOTE you can use both `order` and `@order`: local variables like `order` are required,
|
|
159
|
+
# while instance variables like `@order` are optional.
|
|
160
|
+
# To skip the order-part when provided:
|
|
161
|
+
<%= @order.presence && order_by(order) %>
|
|
162
|
+
# or use a default when order-part is always wanted but not always provided:
|
|
163
|
+
<%= order_by(@order || {id: :desc}) %>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
### ...in a Rails project
|
|
57
168
|
|
|
58
169
|
> [!NOTE]
|
|
59
170
|
> The included [example Rails app](./examples/ror) contains all data and queries described below.
|
|
@@ -143,7 +254,8 @@ AppQuery[:recent_articles].select_all.entries
|
|
|
143
254
|
# we can provide a different cut off date via binds^1:
|
|
144
255
|
AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
|
|
145
256
|
|
|
146
|
-
1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for
|
|
257
|
+
1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for
|
|
258
|
+
$1 and $2 (which our query can deal with).
|
|
147
259
|
For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
|
|
148
260
|
```
|
|
149
261
|
|
|
@@ -461,18 +573,33 @@ query.replace_cte("recent_articles as (select values(1, 'Some article'))")
|
|
|
461
573
|
## Compatibility
|
|
462
574
|
|
|
463
575
|
- 💾 tested with **SQLite** and **PostgreSQL**
|
|
464
|
-
- 🚆 tested with Rails
|
|
465
|
-
- 💎 requires Ruby
|
|
576
|
+
- 🚆 tested with Rails v7.x and v8.x (might still work with v6.1, but is no longer included in the test-matrix)
|
|
577
|
+
- 💎 requires Ruby **>=v3.2**
|
|
466
578
|
Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
|
|
467
579
|
|
|
468
580
|
## Development
|
|
469
581
|
|
|
470
582
|
After checking out the repo, run `bin/setup` to install dependencies. **Make sure to check it exits with status code 0.**
|
|
471
583
|
|
|
472
|
-
Using [
|
|
584
|
+
Using [mise](https://mise.jdx.dev/) for env-vars recommended.
|
|
585
|
+
|
|
586
|
+
### console
|
|
587
|
+
|
|
588
|
+
The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
|
|
589
|
+
```bash
|
|
590
|
+
$ bin/console sqlite3::memory:
|
|
591
|
+
$ bin/console postgres://localhost:5432/some_db
|
|
592
|
+
|
|
593
|
+
# more details
|
|
594
|
+
$ bin/console -h
|
|
473
595
|
|
|
596
|
+
# when needing an appraisal, use bin/run (this ensures signals are handled correctly):
|
|
597
|
+
$ bin/run rails_head console
|
|
598
|
+
```
|
|
474
599
|
|
|
475
|
-
|
|
600
|
+
### various
|
|
601
|
+
|
|
602
|
+
Run `rake spec` to run the tests.
|
|
476
603
|
|
|
477
604
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
478
605
|
|
|
@@ -483,4 +610,3 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/eval/a
|
|
|
483
610
|
## License
|
|
484
611
|
|
|
485
612
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
486
|
-
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module AppQuery
|
|
2
|
+
class Base
|
|
3
|
+
class_attribute :_cast, default: true, instance_predicate: false
|
|
4
|
+
class_attribute :_default_binds, default: {}, instance_predicate: false
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
def run(build_only: false, binds: {}, vars: {}, cast: self.cast, select: nil, **)
|
|
8
|
+
_build(binds:, vars:, cast:, select:).then do
|
|
9
|
+
build_only ? _1 : _1.select_all
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(**opts)
|
|
14
|
+
run(build_only: true, **opts)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def default_binds(v = nil)
|
|
18
|
+
return _default_binds if v.nil?
|
|
19
|
+
self._default_binds = v
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cast(v = nil)
|
|
23
|
+
return _cast if v.nil?
|
|
24
|
+
self._cast = v
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def query_name
|
|
28
|
+
derive_query_name unless defined?(@query_name)
|
|
29
|
+
@query_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_writer :query_name
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def _build(cast:, binds: {}, select: nil, vars: {})
|
|
37
|
+
AppQuery[query_name, binds:, cast:].render(vars).with_select(select)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def derive_query_name
|
|
41
|
+
self.query_name = name.underscore.sub(/_query$/, "")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -7,6 +7,8 @@ require "active_record"
|
|
|
7
7
|
module AppQuery
|
|
8
8
|
class Error < StandardError; end
|
|
9
9
|
|
|
10
|
+
class UnrenderedQueryError < StandardError; end
|
|
11
|
+
|
|
10
12
|
Configuration = Struct.new(:query_path)
|
|
11
13
|
|
|
12
14
|
def self.configuration
|
|
@@ -24,10 +26,14 @@ module AppQuery
|
|
|
24
26
|
end
|
|
25
27
|
reset_configuration!
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# Examples:
|
|
30
|
+
# AppQuery[:invoices] # looks for invoices.sql
|
|
31
|
+
# AppQuery["reports/weekly"]
|
|
32
|
+
# AppQuery["invoices.sql.erb"]
|
|
33
|
+
def self.[](query_name, **opts)
|
|
34
|
+
filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
|
|
35
|
+
full_path = (Pathname.new(configuration.query_path) / filename).expand_path
|
|
36
|
+
Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
class Result < ActiveRecord::Result
|
|
@@ -50,6 +56,10 @@ module AppQuery
|
|
|
50
56
|
rows.map { _1[ix] }
|
|
51
57
|
end
|
|
52
58
|
|
|
59
|
+
def size
|
|
60
|
+
count
|
|
61
|
+
end
|
|
62
|
+
|
|
53
63
|
def self.from_ar_result(r, cast = nil)
|
|
54
64
|
if r.empty?
|
|
55
65
|
EMPTY
|
|
@@ -73,7 +83,7 @@ module AppQuery
|
|
|
73
83
|
# => [["{1,2}"]]
|
|
74
84
|
# > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
|
|
75
85
|
# => [[1, 2]]
|
|
76
|
-
rows = rows.
|
|
86
|
+
rows = rows.zip if r.columns.one?
|
|
77
87
|
new(r.columns, rows, overrides, cast: true)
|
|
78
88
|
end
|
|
79
89
|
end
|
|
@@ -88,27 +98,282 @@ module AppQuery
|
|
|
88
98
|
end
|
|
89
99
|
|
|
90
100
|
class Q
|
|
91
|
-
attr_reader :name, :sql
|
|
101
|
+
attr_reader :name, :sql, :binds, :cast
|
|
92
102
|
|
|
93
|
-
def initialize(sql, name: nil)
|
|
103
|
+
def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
|
|
94
104
|
@sql = sql
|
|
95
105
|
@name = name
|
|
106
|
+
@filename = filename
|
|
107
|
+
@binds = binds
|
|
108
|
+
@cast = cast
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def deep_dup
|
|
112
|
+
super.send(:reset!)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def reset!
|
|
116
|
+
(instance_variables - %i[@sql @filename @name @binds @cast]).each do
|
|
117
|
+
instance_variable_set(_1, nil)
|
|
118
|
+
end
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
private :reset!
|
|
122
|
+
|
|
123
|
+
def render(vars)
|
|
124
|
+
vars ||= {}
|
|
125
|
+
helper = render_helper(vars)
|
|
126
|
+
sql = to_erb.result(helper.get_binding)
|
|
127
|
+
collected = helper.collected_binds
|
|
128
|
+
|
|
129
|
+
with_sql(sql).tap do |q|
|
|
130
|
+
# Merge collected binds with existing binds (convert array to hash if needed)
|
|
131
|
+
existing = @binds.is_a?(Hash) ? @binds : {}
|
|
132
|
+
new_binds = existing.merge(collected)
|
|
133
|
+
q.instance_variable_set(:@binds, new_binds) if new_binds.any?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def to_erb
|
|
138
|
+
ERB.new(sql, trim_mode: "-").tap { _1.location = [@filename, 0] if @filename }
|
|
139
|
+
end
|
|
140
|
+
private :to_erb
|
|
141
|
+
|
|
142
|
+
def render_helper(vars)
|
|
143
|
+
Module.new do
|
|
144
|
+
extend self
|
|
145
|
+
|
|
146
|
+
@collected_binds = {}
|
|
147
|
+
@placeholder_counter = 0
|
|
148
|
+
|
|
149
|
+
vars.each do |k, v|
|
|
150
|
+
define_method(k) { v }
|
|
151
|
+
instance_variable_set(:"@#{k}", v)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def collect_bind(value)
|
|
155
|
+
@placeholder_counter += 1
|
|
156
|
+
key = :"b#{@placeholder_counter}"
|
|
157
|
+
@collected_binds[key] = value
|
|
158
|
+
":#{key}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
attr_reader :collected_binds
|
|
162
|
+
|
|
163
|
+
# Examples
|
|
164
|
+
# quote("Let's learn Ruby") #=> 'Let''s learn Ruby'
|
|
165
|
+
def quote(...)
|
|
166
|
+
ActiveRecord::Base.connection.quote(...)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Examples
|
|
170
|
+
# <%= bind(title) %> #=> :b1 (with title added to binds)
|
|
171
|
+
def bind(value)
|
|
172
|
+
collect_bind(value)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Examples
|
|
176
|
+
# <%= values([[1, "Some video"], [2, "Another video"]]) %>
|
|
177
|
+
# #=> VALUES (:b1, :b2), (:b3, :b4) with binds {b1: 1, b2: "Some video", ...}
|
|
178
|
+
#
|
|
179
|
+
# <%= values([{id: 1, title: "Some video"}]) %>
|
|
180
|
+
# #=> (id, title) VALUES (:b1, :b2) with binds {b1: 1, b2: "Some video"}
|
|
181
|
+
#
|
|
182
|
+
# <%= values([{title: "A"}, {title: "B", published_on: "2024-01-01"}]) %>
|
|
183
|
+
# #=> (title, published_on) VALUES (:b1, NULL), (:b2, :b3)
|
|
184
|
+
#
|
|
185
|
+
# Skip column names (e.g. for UNION ALL or CTEs):
|
|
186
|
+
# with articles as(
|
|
187
|
+
# <%= values([[1, "title"]], skip_columns: true) %>
|
|
188
|
+
# )
|
|
189
|
+
# #=> with articles as (VALUES (:b1, :b2))
|
|
190
|
+
#
|
|
191
|
+
# With block (mix bind() and quote()):
|
|
192
|
+
# <%= values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] } %>
|
|
193
|
+
# #=> VALUES (:b1, 'Some title', now()), (:b2, 'Other title', now())
|
|
194
|
+
def values(coll, skip_columns: false, &block)
|
|
195
|
+
first = coll.first
|
|
196
|
+
|
|
197
|
+
# For hash collections, collect all unique keys
|
|
198
|
+
if first.is_a?(Hash) && !block
|
|
199
|
+
all_keys = coll.flat_map(&:keys).uniq
|
|
200
|
+
|
|
201
|
+
rows = coll.map do |row|
|
|
202
|
+
vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
|
|
203
|
+
"(#{vals.join(", ")})"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
|
|
207
|
+
"#{columns}VALUES #{rows.join(",\n")}"
|
|
208
|
+
else
|
|
209
|
+
# Arrays or block - current behavior
|
|
210
|
+
rows = coll.map do |item|
|
|
211
|
+
vals = if block
|
|
212
|
+
block.call(item)
|
|
213
|
+
elsif item.is_a?(Array)
|
|
214
|
+
item.map { |v| collect_bind(v) }
|
|
215
|
+
else
|
|
216
|
+
[collect_bind(item)]
|
|
217
|
+
end
|
|
218
|
+
"(#{vals.join(", ")})"
|
|
219
|
+
end
|
|
220
|
+
"VALUES #{rows.join(",\n")}"
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Examples
|
|
225
|
+
# <%= order_by({year: :desc, month: :desc}) %>
|
|
226
|
+
# #=> ORDER BY year DESC, month DESC
|
|
227
|
+
#
|
|
228
|
+
# Using variable:
|
|
229
|
+
# <%= order_by(ordering) %>
|
|
230
|
+
# NOTE Raises when ordering not provided or when blank.
|
|
231
|
+
#
|
|
232
|
+
# Make it optional:
|
|
233
|
+
# <%= @ordering.presence && order_by(ordering) %>
|
|
234
|
+
#
|
|
235
|
+
def order_by(hash)
|
|
236
|
+
raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
|
|
237
|
+
"ORDER BY " + hash.map do |k, v|
|
|
238
|
+
v.nil? ? k : [k, v.upcase].join(" ")
|
|
239
|
+
end.join(", ")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def get_binding
|
|
243
|
+
binding
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
private :render_helper
|
|
248
|
+
|
|
249
|
+
# TODO: have aliases for common casts: select_all(cast: {"today" => :date})
|
|
250
|
+
def select_all(binds: nil, select: nil, cast: self.cast)
|
|
251
|
+
with_select(select).render({}).then do |aq|
|
|
252
|
+
# Support both positional (array) and named (hash) binds
|
|
253
|
+
if binds.is_a?(Array)
|
|
254
|
+
if @binds.is_a?(Hash) && @binds.any?
|
|
255
|
+
raise ArgumentError, "Cannot use positional binds (Array) when query has collected named binds from values()/bind() helpers. Use named binds (Hash) instead."
|
|
256
|
+
end
|
|
257
|
+
# Positional binds using $1, $2, etc.
|
|
258
|
+
ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
|
|
259
|
+
Result.from_ar_result(result, cast)
|
|
260
|
+
end
|
|
261
|
+
else
|
|
262
|
+
# Named binds - merge collected binds with explicitly passed binds
|
|
263
|
+
merged_binds = (@binds.is_a?(Hash) ? @binds : {}).merge(binds || {})
|
|
264
|
+
if merged_binds.any?
|
|
265
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
266
|
+
Arel.sql(aq.to_s, **merged_binds)
|
|
267
|
+
else
|
|
268
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **merged_binds])
|
|
269
|
+
end
|
|
270
|
+
ActiveRecord::Base.connection.select_all(sql, name).then do |result|
|
|
271
|
+
Result.from_ar_result(result, cast)
|
|
272
|
+
end
|
|
273
|
+
else
|
|
274
|
+
ActiveRecord::Base.connection.select_all(aq.to_s, name).then do |result|
|
|
275
|
+
Result.from_ar_result(result, cast)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
rescue NameError => e
|
|
281
|
+
# Prevent any subclasses, e.g. NoMethodError
|
|
282
|
+
raise e unless e.instance_of?(NameError)
|
|
283
|
+
raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
|
|
96
284
|
end
|
|
97
285
|
|
|
98
|
-
def
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
286
|
+
def select_one(binds: nil, select: nil, cast: self.cast)
|
|
287
|
+
select_all(binds:, select:, cast:).first
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def select_value(binds: nil, select: nil, cast: self.cast)
|
|
291
|
+
select_one(binds:, select:, cast:)&.values&.first
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Examples
|
|
295
|
+
# AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
|
|
296
|
+
# INSERT INTO videos(title, created_at, updated_at) values($1, now(), now())
|
|
297
|
+
# SQL
|
|
298
|
+
#
|
|
299
|
+
# articles = [
|
|
300
|
+
# {title: "First article"}
|
|
301
|
+
# ].map { it.merge(created_at: Time.current)}
|
|
302
|
+
# AppQuery(<<~SQL).render(articles:)
|
|
303
|
+
# INSERT INTO articles(title, created_at) <%= values(articles) %>
|
|
304
|
+
# SQL
|
|
305
|
+
def insert(binds: [], returning: nil)
|
|
306
|
+
# ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
|
|
307
|
+
if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
|
|
308
|
+
raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
binds = binds.presence || @binds
|
|
312
|
+
render({}).then do |aq|
|
|
313
|
+
if binds.is_a?(Hash)
|
|
314
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
315
|
+
Arel.sql(aq.to_s, **binds)
|
|
316
|
+
else
|
|
317
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
|
|
318
|
+
end
|
|
319
|
+
if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
320
|
+
ActiveRecord::Base.connection.insert(sql, name, returning:)
|
|
321
|
+
else
|
|
322
|
+
ActiveRecord::Base.connection.insert(sql, name)
|
|
323
|
+
end
|
|
324
|
+
elsif ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
325
|
+
# pk is the less flexible returning
|
|
326
|
+
ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning:)
|
|
327
|
+
else
|
|
328
|
+
ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
|
|
102
329
|
end
|
|
103
330
|
end
|
|
331
|
+
rescue NameError => e
|
|
332
|
+
# Prevent any subclasses, e.g. NoMethodError
|
|
333
|
+
raise e unless e.instance_of?(NameError)
|
|
334
|
+
raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
|
|
104
335
|
end
|
|
105
336
|
|
|
106
|
-
|
|
107
|
-
|
|
337
|
+
# Examples:
|
|
338
|
+
# AppQuery("UPDATE videos SET title = 'New' WHERE id = :id").update(binds: {id: 1})
|
|
339
|
+
def update(binds: [])
|
|
340
|
+
binds = binds.presence || @binds
|
|
341
|
+
render({}).then do |aq|
|
|
342
|
+
if binds.is_a?(Hash)
|
|
343
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
344
|
+
Arel.sql(aq.to_s, **binds)
|
|
345
|
+
else
|
|
346
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
|
|
347
|
+
end
|
|
348
|
+
ActiveRecord::Base.connection.update(sql, name)
|
|
349
|
+
else
|
|
350
|
+
ActiveRecord::Base.connection.update(aq.to_s, name, binds)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
rescue NameError => e
|
|
354
|
+
raise e unless e.instance_of?(NameError)
|
|
355
|
+
raise UnrenderedQueryError, "Query is ERB. Use #render before updating."
|
|
108
356
|
end
|
|
109
357
|
|
|
110
|
-
|
|
111
|
-
|
|
358
|
+
# Examples:
|
|
359
|
+
# AppQuery("DELETE FROM videos WHERE id = :id").delete(binds: {id: 1})
|
|
360
|
+
def delete(binds: [])
|
|
361
|
+
binds = binds.presence || @binds
|
|
362
|
+
render({}).then do |aq|
|
|
363
|
+
if binds.is_a?(Hash)
|
|
364
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
365
|
+
Arel.sql(aq.to_s, **binds)
|
|
366
|
+
else
|
|
367
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
|
|
368
|
+
end
|
|
369
|
+
ActiveRecord::Base.connection.delete(sql, name)
|
|
370
|
+
else
|
|
371
|
+
ActiveRecord::Base.connection.delete(aq.to_s, name, binds)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
rescue NameError => e
|
|
375
|
+
raise e unless e.instance_of?(NameError)
|
|
376
|
+
raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
|
|
112
377
|
end
|
|
113
378
|
|
|
114
379
|
def tokens
|
|
@@ -123,13 +388,31 @@ module AppQuery
|
|
|
123
388
|
tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
|
|
124
389
|
end
|
|
125
390
|
|
|
391
|
+
def with_binds(binds)
|
|
392
|
+
deep_dup.tap do
|
|
393
|
+
_1.instance_variable_set(:@binds, binds)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def with_cast(cast)
|
|
398
|
+
deep_dup.tap do
|
|
399
|
+
_1.instance_variable_set(:@cast, cast)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def with_sql(sql)
|
|
404
|
+
deep_dup.tap do
|
|
405
|
+
_1.instance_variable_set(:@sql, sql)
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
126
409
|
def with_select(sql)
|
|
127
|
-
return self
|
|
410
|
+
return self if sql.nil?
|
|
128
411
|
if cte_names.include?("_")
|
|
129
|
-
|
|
412
|
+
with_sql(tokens.each_with_object([]) do |token, acc|
|
|
130
413
|
v = (token[:t] == "SELECT") ? sql : token[:v]
|
|
131
414
|
acc << v
|
|
132
|
-
end.join
|
|
415
|
+
end.join)
|
|
133
416
|
else
|
|
134
417
|
append_cte("_ as (\n #{select}\n)").with_select(sql)
|
|
135
418
|
end
|
|
@@ -152,10 +435,10 @@ module AppQuery
|
|
|
152
435
|
end
|
|
153
436
|
|
|
154
437
|
if cte_names.none?
|
|
155
|
-
|
|
438
|
+
with_sql("WITH #{cte}\n#{self}")
|
|
156
439
|
else
|
|
157
440
|
split_at_type = recursive? ? "RECURSIVE" : "WITH"
|
|
158
|
-
|
|
441
|
+
with_sql(tokens.map do |token|
|
|
159
442
|
if token[:t] == split_at_type
|
|
160
443
|
token[:v] + to_append.map { _1[:v] }.join
|
|
161
444
|
else
|
|
@@ -175,11 +458,11 @@ module AppQuery
|
|
|
175
458
|
end
|
|
176
459
|
|
|
177
460
|
if cte_names.none?
|
|
178
|
-
|
|
461
|
+
with_sql("WITH #{cte}\n#{self}")
|
|
179
462
|
else
|
|
180
463
|
nof_ctes = cte_names.size
|
|
181
464
|
|
|
182
|
-
|
|
465
|
+
with_sql(tokens.map do |token|
|
|
183
466
|
nof_ctes -= 1 if token[:t] == "CTE_SELECT"
|
|
184
467
|
|
|
185
468
|
if nof_ctes.zero?
|
|
@@ -212,7 +495,7 @@ module AppQuery
|
|
|
212
495
|
|
|
213
496
|
cte_found = false
|
|
214
497
|
|
|
215
|
-
|
|
498
|
+
with_sql(tokens.map do |token|
|
|
216
499
|
if cte_found ||= token[:t] == "CTE_IDENTIFIER" && token[:v] == cte_name
|
|
217
500
|
unless (cte_found = (token[:t] != "CTE_SELECT"))
|
|
218
501
|
next to_append.map { _1[:v] }.join
|
|
@@ -243,3 +526,5 @@ rescue LoadError
|
|
|
243
526
|
end
|
|
244
527
|
|
|
245
528
|
require_relative "app_query/rspec" if Object.const_defined? :RSpec
|
|
529
|
+
|
|
530
|
+
require "app_query/base" if defined?(ActiveRecord::Base)
|
data/mise.toml
ADDED
data/rakelib/gem.rake
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
namespace :gem do
|
|
2
|
+
task "write_version", [:version] do |_task, args|
|
|
3
|
+
if args[:version]
|
|
4
|
+
version = args[:version].split("=").last
|
|
5
|
+
version_file = File.expand_path("../../lib/app_query/version.rb", __FILE__)
|
|
6
|
+
|
|
7
|
+
system(<<~CMD, exception: true)
|
|
8
|
+
ruby -pi -e 'gsub(/VERSION = ".*"/, %{VERSION = "#{version}"})' #{version_file}
|
|
9
|
+
CMD
|
|
10
|
+
Bundler.ui.confirm "Version #{version} written to #{version_file}."
|
|
11
|
+
else
|
|
12
|
+
Bundler.ui.warn "No version provided, keeping version.rb as is."
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc "Build [version]"
|
|
17
|
+
task "build", [:version] => %w[write_version] do
|
|
18
|
+
Rake::Task["build"].invoke
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "Build and push [version] to rubygems"
|
|
22
|
+
task "release", [:version] => %w[build] do
|
|
23
|
+
Rake::Task["release:rubygem_push"].invoke
|
|
24
|
+
end
|
|
25
|
+
end
|
metadata
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: appquery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
12
|
-
dependencies:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: appraisal
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
13
26
|
description: "Improving introspection and testability of raw SQL queries in Rails\nThis
|
|
14
27
|
gem improves introspection and testability of raw SQL queries in Rails by:\n- ...providing
|
|
15
28
|
a separate query-folder and easy instantiation \n A query like `AppQuery[:some_query]`
|
|
@@ -27,15 +40,16 @@ executables: []
|
|
|
27
40
|
extensions: []
|
|
28
41
|
extra_rdoc_files: []
|
|
29
42
|
files:
|
|
30
|
-
- ".
|
|
31
|
-
- ".envrc.private.example"
|
|
43
|
+
- ".irbrc"
|
|
32
44
|
- ".rspec"
|
|
33
45
|
- ".standard.yml"
|
|
46
|
+
- Appraisals
|
|
34
47
|
- CHANGELOG.md
|
|
35
48
|
- LICENSE.txt
|
|
36
49
|
- README.md
|
|
37
50
|
- Rakefile
|
|
38
51
|
- lib/app_query.rb
|
|
52
|
+
- lib/app_query/base.rb
|
|
39
53
|
- lib/app_query/rspec.rb
|
|
40
54
|
- lib/app_query/rspec/helpers.rb
|
|
41
55
|
- lib/app_query/tokenizer.rb
|
|
@@ -46,8 +60,10 @@ files:
|
|
|
46
60
|
- lib/rails/generators/query/templates/query.sql.tt
|
|
47
61
|
- lib/rails/generators/rspec/query_generator.rb
|
|
48
62
|
- lib/rails/generators/rspec/templates/query_spec.rb.tt
|
|
63
|
+
- mise.local.toml.example
|
|
64
|
+
- mise.toml
|
|
65
|
+
- rakelib/gem.rake
|
|
49
66
|
- sig/appquery.rbs
|
|
50
|
-
- tmp/.gitkeep
|
|
51
67
|
homepage: https://github.com/eval/appquery
|
|
52
68
|
licenses:
|
|
53
69
|
- MIT
|
|
@@ -55,7 +71,6 @@ metadata:
|
|
|
55
71
|
homepage_uri: https://github.com/eval/appquery
|
|
56
72
|
source_code_uri: https://github.com/eval/appquery
|
|
57
73
|
changelog_uri: https://github.com/eval/gem-try/blob/main/CHANGELOG.md
|
|
58
|
-
post_install_message:
|
|
59
74
|
rdoc_options: []
|
|
60
75
|
require_paths:
|
|
61
76
|
- lib
|
|
@@ -63,15 +78,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
63
78
|
requirements:
|
|
64
79
|
- - ">="
|
|
65
80
|
- !ruby/object:Gem::Version
|
|
66
|
-
version: 3.
|
|
81
|
+
version: 3.2.0
|
|
67
82
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
83
|
requirements:
|
|
69
84
|
- - ">="
|
|
70
85
|
- !ruby/object:Gem::Version
|
|
71
86
|
version: '0'
|
|
72
87
|
requirements: []
|
|
73
|
-
rubygems_version: 3.
|
|
74
|
-
signing_key:
|
|
88
|
+
rubygems_version: 3.6.9
|
|
75
89
|
specification_version: 4
|
|
76
90
|
summary: "raw SQL \U0001F966, cooked \U0001F372 or: make working with raw SQL queries
|
|
77
91
|
in Rails convenient by improving their introspection and testability."
|
data/.envrc
DELETED
data/.envrc.private.example
DELETED
data/tmp/.gitkeep
DELETED
|
File without changes
|