activerecord-trino-adapter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +151 -0
- data/lib/active_record/connection_adapters/trino/column.rb +22 -0
- data/lib/active_record/connection_adapters/trino/database_statements.rb +120 -0
- data/lib/active_record/connection_adapters/trino/quoting.rb +78 -0
- data/lib/active_record/connection_adapters/trino/read_only.rb +60 -0
- data/lib/active_record/connection_adapters/trino/safety_belts.rb +27 -0
- data/lib/active_record/connection_adapters/trino/schema_statements.rb +80 -0
- data/lib/active_record/connection_adapters/trino/type_map.rb +95 -0
- data/lib/active_record/connection_adapters/trino_adapter.rb +143 -0
- data/lib/active_record/trino/config.rb +58 -0
- data/lib/active_record/trino/diagnostics.rb +64 -0
- data/lib/active_record/trino/errors.rb +13 -0
- data/lib/active_record/trino/type/json.rb +25 -0
- data/lib/active_record/trino/type/timestamp_with_zone.rb +25 -0
- data/lib/active_record/trino/type/unsupported.rb +26 -0
- data/lib/active_record/trino/version.rb +7 -0
- data/lib/active_record/trino.rb +41 -0
- metadata +216 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b38f0bee0dc9764f269632d9b138de7aff8c4fb3fb696c8862bd8de23a34999e
|
|
4
|
+
data.tar.gz: 8a24ba5209ee11e82ee43d67182798599b96b1a9f1a621b4f60edf0a2ae3c0ed
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 83cba7a9d4c291d021d94729bd2e1fbb37e101ec19749163b4eebcee99969360cd46e3831e0d952eb63770d6db53e46abce9627fa82208f3103b38361c15148b
|
|
7
|
+
data.tar.gz: fd39bfe9b1025178a0a95e35b03bbd9617a3859931e7eea02af7a8ce96a8cf02dd7090469fe08ceca19208a1c9f53a20d83345ca78229e2adb426408102fa624
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright Power Home Remodeling Group, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# activerecord-trino-adapter
|
|
2
|
+
|
|
3
|
+
A read-only ActiveRecord SQL adapter for [Trino](https://trino.io/), built on top of the [`trino-client`](https://rubygems.org/gems/trino-client) gem.
|
|
4
|
+
|
|
5
|
+
Lets a Rails application query a Trino data warehouse using familiar ActiveRecord scopes and `where` chains while preventing accidental writes. Designed for analytical use cases where the warehouse is the source of truth and the application only needs to read from it.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Read-only by design.** All write paths (`insert`, `update`, `delete`, transactions, migrations, schema changes) raise `ActiveRecord::Trino::ReadOnlyError`.
|
|
10
|
+
- **ActiveRecord-native.** Plugs into Rails 7.1+ multi-database via `database.yml` and `connects_to`.
|
|
11
|
+
- **Opinionated safety belts.** `find_each` / `find_in_batches` are banned (they don't fit Trino's pagination model); hard query timeouts default to 150 seconds; slow queries emit `ActiveSupport::Notifications` for any subscriber to pick up.
|
|
12
|
+
- **SQL-injection conscious.** Trino has no parameterized queries, so every literal flows through a tight, fuzz-tested `quote` implementation.
|
|
13
|
+
- **Schema introspection** via Trino's `information_schema.columns`, with a small but practical type map (varchar, integer, decimal, boolean, date, timestamp, timestamp with time zone, json, etc.).
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add this line to your application's Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "activerecord-trino-adapter"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then in your `config/database.yml`:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
warehouse:
|
|
27
|
+
adapter: trino
|
|
28
|
+
host: <%= ENV["TRINO_HOST"] %>
|
|
29
|
+
port: <%= ENV.fetch("TRINO_PORT", 8080) %>
|
|
30
|
+
ssl: <%= ENV.fetch("TRINO_SSL", "false") %>
|
|
31
|
+
user: <%= ENV["TRINO_USER"] %>
|
|
32
|
+
password: <%= ENV["TRINO_PASSWORD"] %>
|
|
33
|
+
catalog: <%= ENV["TRINO_CATALOG"] %>
|
|
34
|
+
schema: <%= ENV["TRINO_SCHEMA"] %>
|
|
35
|
+
query_timeout: 150
|
|
36
|
+
plan_timeout: 30
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
And an abstract record that connects to it:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
class WarehouseRecord < ActiveRecord::Base
|
|
43
|
+
self.abstract_class = true
|
|
44
|
+
connects_to database: { reading: :warehouse }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class SalesByDay < WarehouseRecord
|
|
48
|
+
self.table_name = "sales_by_day"
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Now standard AR queries work against Trino:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
SalesByDay.where(month: "2026-04").order(:territory_id).limit(100)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What is and is not supported
|
|
59
|
+
|
|
60
|
+
Supported:
|
|
61
|
+
- `where`, `order`, `limit`, `offset`, `select`, `pluck`, `find_by`, `count`, `sum`, `average`
|
|
62
|
+
- Scopes, including chained scopes
|
|
63
|
+
- Type-cast reads for varchar, integer (all widths), real/double, decimal, boolean, date, timestamp, timestamp with time zone, time, json, uuid
|
|
64
|
+
|
|
65
|
+
Not supported (raises):
|
|
66
|
+
- Any write path: `save`, `update`, `delete`, `destroy`, `insert`, `create_table`, transactions, savepoints
|
|
67
|
+
- `find_each` / `find_in_batches` — Trino's pagination model is incompatible; use explicit `LIMIT`/`OFFSET` or `pluck` aggregates
|
|
68
|
+
- Trino composite types (`array`, `map`, `row`) in result casting — select scalar columns or extract via Trino SQL (`element_at`, dot access)
|
|
69
|
+
|
|
70
|
+
Out of design scope:
|
|
71
|
+
- `joins` are not a design goal. The adapter passes SQL through to Trino, so a join across two Trino-backed models technically works, but it is not tested and the intended usage pattern is to query flat denormalized warehouse tables.
|
|
72
|
+
- Cross-database joins (e.g., a MySQL model joined to a Trino model) do not work — Rails 7.1+ disallows joins across connection handles.
|
|
73
|
+
|
|
74
|
+
## Configuration options
|
|
75
|
+
|
|
76
|
+
All keys are read from the `database.yml` entry:
|
|
77
|
+
|
|
78
|
+
| Key | Default | Description |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `host` | _required_ | Trino server hostname (e.g. `trino.example.com`) — no scheme, no port |
|
|
81
|
+
| `port` | `8080` (HTTP) / `443` (HTTPS) | Trino server port |
|
|
82
|
+
| `ssl` | `false` | Whether to use HTTPS. When `true`, also passing `password` requires the connection to be HTTPS (trino-client policy) |
|
|
83
|
+
| `user` | _required_ | Trino user |
|
|
84
|
+
| `password` | _nil_ | Optional basic-auth password |
|
|
85
|
+
| `catalog` | _required_ | Default Trino catalog |
|
|
86
|
+
| `schema` | _required_ | Default Trino schema |
|
|
87
|
+
| `query_timeout` | `150` | Hard ceiling on query duration, in seconds. Cap lower for user-facing paths and higher for backfills |
|
|
88
|
+
| `plan_timeout` | `30` | Ceiling on Trino query-planning phase, in seconds |
|
|
89
|
+
| `slow_query_threshold_seconds` | `20` | Threshold above which an `active_record_trino.slow_query` notification is emitted |
|
|
90
|
+
|
|
91
|
+
## Instrumentation
|
|
92
|
+
|
|
93
|
+
The adapter uses ActiveRecord's standard `AbstractAdapter#log` for query instrumentation, so any `ActiveSupport::Notifications` subscriber on `sql.active_record` picks up Trino queries automatically.
|
|
94
|
+
|
|
95
|
+
In addition, queries exceeding `slow_query_threshold_seconds` emit an `active_record_trino.slow_query` notification with payload `{ sql:, duration:, query_id:, info_uri: }`. The `info_uri` deep-links to the query's stats page in the Trino web UI, which is handy for diagnosing slow paths.
|
|
96
|
+
|
|
97
|
+
## Diagnostics
|
|
98
|
+
|
|
99
|
+
For one-off latency investigation, `ActiveRecord::Trino::Diagnostics.profile(model_class)` runs a sample query and reports where the time went, broken down between the Ruby/AR side and Trino's own per-query stats:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
ActiveRecord::Trino::Diagnostics.profile(SalesByDay)
|
|
103
|
+
# => {
|
|
104
|
+
# schema_time: 0.45, # Ruby-side seconds for information_schema.columns
|
|
105
|
+
# query_time: 1.12, # Ruby-side seconds for the sample SELECT
|
|
106
|
+
# query_id: "20260520_...", # Trino query_id (deep-link via info_uri)
|
|
107
|
+
# info_uri: "https://...", # URL to the query's stats page
|
|
108
|
+
# queued_time_ms: 50, # Trino-side: queued waiting for resources
|
|
109
|
+
# elapsed_time_ms: 800, # Trino-side: total wall clock
|
|
110
|
+
# cpu_time_ms: 200, # Trino-side: CPU time spent
|
|
111
|
+
# wall_time_ms: 750, # Trino-side: execution wall time
|
|
112
|
+
# state: "FINISHED"
|
|
113
|
+
# }
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The same metadata is available on the connection after any query:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
SalesByDay.first
|
|
120
|
+
connection = SalesByDay.connection
|
|
121
|
+
connection.last_query_id # Trino query_id of the most recent query
|
|
122
|
+
connection.last_query_info_uri # Direct URL to the Trino UI for that query
|
|
123
|
+
connection.last_query_stats # Hash of state, queued_time_millis, elapsed_time_millis, etc.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
1. Clone the repository.
|
|
129
|
+
2. Install dependencies: `bundle install`.
|
|
130
|
+
3. Run the test suite: `bundle exec rspec`.
|
|
131
|
+
4. Run the linter: `bundle exec rubocop`.
|
|
132
|
+
|
|
133
|
+
### Testing across Rails versions
|
|
134
|
+
|
|
135
|
+
Use Appraisal to run the suite against each supported Rails minor:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
bundle exec appraisal install
|
|
139
|
+
bundle exec appraisal rspec
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Testing changes locally in another app
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# In the consuming application's Gemfile
|
|
146
|
+
gem "activerecord-trino-adapter", path: "path/to/activerecord-trino-adapter"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
This project is licensed under the MIT License — see the [LICENSE.txt](LICENSE.txt) file for details.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record/connection_adapters/column"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module ConnectionAdapters
|
|
7
|
+
module Trino
|
|
8
|
+
class Column < ActiveRecord::ConnectionAdapters::Column
|
|
9
|
+
def initialize(name:, sql_type:, type:, null: true)
|
|
10
|
+
metadata = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(
|
|
11
|
+
sql_type: sql_type,
|
|
12
|
+
type: type.type
|
|
13
|
+
)
|
|
14
|
+
super(name.to_s, nil, metadata, null)
|
|
15
|
+
@cast_type = type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :cast_type
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Trino
|
|
6
|
+
module DatabaseStatements
|
|
7
|
+
SLOW_QUERY_NOTIFICATION = "active_record_trino.slow_query"
|
|
8
|
+
|
|
9
|
+
STAT_FIELDS = %i[
|
|
10
|
+
state
|
|
11
|
+
queued_time_millis
|
|
12
|
+
elapsed_time_millis
|
|
13
|
+
cpu_time_millis
|
|
14
|
+
wall_time_millis
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
InternalResult = Struct.new(:column_names, :rows, :column_types, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
def execute(sql, name = nil, **_kwargs)
|
|
20
|
+
log(sql, name) { run_trino_query(sql) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Rails 7.1 public path.
|
|
24
|
+
def exec_query(sql, name = "SQL", binds = [], prepare: false, async: false)
|
|
25
|
+
internal_exec_query(sql, name, binds, prepare: prepare, async: async)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Rails 7.2+/8.0 canonical path that AR's select_all/select route through.
|
|
29
|
+
# rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists
|
|
30
|
+
def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false)
|
|
31
|
+
unless binds.empty?
|
|
32
|
+
raise ActiveRecord::Trino::Error,
|
|
33
|
+
"activerecord-trino-adapter: bind variables are not supported; got #{binds.size} bind(s)"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
internal = log(sql, name) { run_trino_query(sql) }
|
|
37
|
+
ActiveRecord::Result.new(internal.column_names, internal.rows, internal.column_types)
|
|
38
|
+
end
|
|
39
|
+
# rubocop:enable Lint/UnusedMethodArgument, Metrics/ParameterLists
|
|
40
|
+
|
|
41
|
+
def select_value(arel, name = nil, binds = [])
|
|
42
|
+
result = select_all(arel, name, binds)
|
|
43
|
+
result.rows.first&.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def select_values(arel, name = nil, binds = [])
|
|
47
|
+
result = select_all(arel, name, binds)
|
|
48
|
+
result.rows.map(&:first)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# rubocop:disable Metrics/AbcSize
|
|
54
|
+
def run_trino_query(sql)
|
|
55
|
+
start = monotonic_now
|
|
56
|
+
query = client.query(sql)
|
|
57
|
+
internal = consume_query(query)
|
|
58
|
+
capture_query_metadata(query)
|
|
59
|
+
notify_slow_query(sql, monotonic_now - start)
|
|
60
|
+
internal
|
|
61
|
+
rescue ::Trino::Client::TrinoQueryTimeoutError => e
|
|
62
|
+
raise ActiveRecord::StatementTimeout.new(e.message, sql: sql)
|
|
63
|
+
rescue ::Trino::Client::TrinoQueryError => e
|
|
64
|
+
raise ActiveRecord::StatementInvalid.new(e.message, sql: sql)
|
|
65
|
+
rescue ::Trino::Client::TrinoHttpError => e
|
|
66
|
+
raise ActiveRecord::ConnectionFailed, e.message
|
|
67
|
+
ensure
|
|
68
|
+
query&.close if defined?(query) && query
|
|
69
|
+
end
|
|
70
|
+
# rubocop:enable Metrics/AbcSize
|
|
71
|
+
|
|
72
|
+
def consume_query(query)
|
|
73
|
+
columns = query.columns || []
|
|
74
|
+
rows = []
|
|
75
|
+
query.each_row_chunk { |chunk| rows.concat(chunk) if chunk }
|
|
76
|
+
|
|
77
|
+
column_names = columns.map(&:name)
|
|
78
|
+
column_types = columns.to_h { |c| [c.name, type_map.lookup(c.type)] }
|
|
79
|
+
InternalResult.new(column_names: column_names, rows: rows, column_types: column_types)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Pull the Trino-side query metadata off of the final QueryResults
|
|
83
|
+
# page so callers can cross-reference in the Trino UI. Defensive
|
|
84
|
+
# against minor model differences across trino-client versions.
|
|
85
|
+
def capture_query_metadata(query)
|
|
86
|
+
results = query.current_results
|
|
87
|
+
return unless results
|
|
88
|
+
|
|
89
|
+
@last_query_id = results.id if results.respond_to?(:id)
|
|
90
|
+
@last_query_info_uri = results.info_uri if results.respond_to?(:info_uri)
|
|
91
|
+
@last_query_stats = extract_stats(results.stats) if results.respond_to?(:stats)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_stats(stats)
|
|
95
|
+
return {} unless stats
|
|
96
|
+
|
|
97
|
+
STAT_FIELDS.each_with_object({}) do |field, acc|
|
|
98
|
+
acc[field] = stats.public_send(field) if stats.respond_to?(field)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def notify_slow_query(sql, duration)
|
|
103
|
+
return if duration < @slow_query_threshold
|
|
104
|
+
|
|
105
|
+
ActiveSupport::Notifications.instrument(
|
|
106
|
+
SLOW_QUERY_NOTIFICATION,
|
|
107
|
+
sql: sql,
|
|
108
|
+
duration: duration,
|
|
109
|
+
query_id: @last_query_id,
|
|
110
|
+
info_uri: @last_query_info_uri
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def monotonic_now
|
|
115
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module ActiveRecord
|
|
8
|
+
module ConnectionAdapters
|
|
9
|
+
module Trino
|
|
10
|
+
module Quoting
|
|
11
|
+
QUOTED_TRUE = "true"
|
|
12
|
+
QUOTED_FALSE = "false"
|
|
13
|
+
QUOTED_NULL = "NULL"
|
|
14
|
+
|
|
15
|
+
TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S.%3N"
|
|
16
|
+
DATE_FORMAT = "%Y-%m-%d"
|
|
17
|
+
|
|
18
|
+
NUL_BYTE = /\x00/
|
|
19
|
+
|
|
20
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Lint/DuplicateBranch
|
|
21
|
+
def quote(value)
|
|
22
|
+
case value
|
|
23
|
+
when nil then QUOTED_NULL
|
|
24
|
+
when true then QUOTED_TRUE
|
|
25
|
+
when false then QUOTED_FALSE
|
|
26
|
+
when BigDecimal then value.to_s("F")
|
|
27
|
+
when Numeric then value.to_s
|
|
28
|
+
when ::Date then "DATE '#{value.strftime(DATE_FORMAT)}'"
|
|
29
|
+
when ::Time, ::DateTime then quote_time(value)
|
|
30
|
+
when Symbol, String then quote_string_literal(value.to_s)
|
|
31
|
+
else quote_string_literal(value.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Lint/DuplicateBranch
|
|
35
|
+
|
|
36
|
+
def quote_string(string)
|
|
37
|
+
str = string.to_s
|
|
38
|
+
if str.match?(NUL_BYTE)
|
|
39
|
+
raise ActiveRecord::Trino::Error,
|
|
40
|
+
"activerecord-trino-adapter: NUL byte detected in literal; refusing to quote"
|
|
41
|
+
end
|
|
42
|
+
str.gsub("'", "''")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def quote_column_name(name)
|
|
46
|
+
%("#{name.to_s.gsub('"', '""')}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def quote_table_name(name)
|
|
50
|
+
name.to_s.split(".").map { |part| quote_column_name(part) }.join(".")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def quoted_true
|
|
54
|
+
QUOTED_TRUE
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def quoted_false
|
|
58
|
+
QUOTED_FALSE
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def quoted_date(value)
|
|
62
|
+
value.strftime(DATE_FORMAT)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def quote_string_literal(string)
|
|
68
|
+
"'#{quote_string(string)}'"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def quote_time(value)
|
|
72
|
+
time = value.respond_to?(:utc) ? value.utc : value
|
|
73
|
+
"TIMESTAMP '#{time.strftime(TIMESTAMP_FORMAT)}'"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Trino
|
|
6
|
+
module ReadOnly
|
|
7
|
+
WRITE_METHODS = %i[
|
|
8
|
+
insert
|
|
9
|
+
update
|
|
10
|
+
delete
|
|
11
|
+
exec_insert
|
|
12
|
+
exec_update
|
|
13
|
+
exec_delete
|
|
14
|
+
insert_fixture
|
|
15
|
+
insert_fixtures_set
|
|
16
|
+
truncate
|
|
17
|
+
truncate_tables
|
|
18
|
+
begin_db_transaction
|
|
19
|
+
commit_db_transaction
|
|
20
|
+
rollback_db_transaction
|
|
21
|
+
exec_rollback_db_transaction
|
|
22
|
+
begin_isolated_db_transaction
|
|
23
|
+
create_savepoint
|
|
24
|
+
exec_rollback_to_savepoint
|
|
25
|
+
release_savepoint
|
|
26
|
+
create_table
|
|
27
|
+
drop_table
|
|
28
|
+
create_join_table
|
|
29
|
+
drop_join_table
|
|
30
|
+
rename_table
|
|
31
|
+
add_column
|
|
32
|
+
remove_column
|
|
33
|
+
change_column
|
|
34
|
+
change_column_default
|
|
35
|
+
change_column_null
|
|
36
|
+
rename_column
|
|
37
|
+
add_index
|
|
38
|
+
remove_index
|
|
39
|
+
rename_index
|
|
40
|
+
add_foreign_key
|
|
41
|
+
remove_foreign_key
|
|
42
|
+
add_reference
|
|
43
|
+
remove_reference
|
|
44
|
+
add_belongs_to
|
|
45
|
+
remove_belongs_to
|
|
46
|
+
add_check_constraint
|
|
47
|
+
remove_check_constraint
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
WRITE_METHODS.each do |method|
|
|
51
|
+
define_method(method) do |*_args, **_kwargs, &_block|
|
|
52
|
+
raise ActiveRecord::Trino::ReadOnlyError,
|
|
53
|
+
"activerecord-trino-adapter: #{method} is not supported by the Trino adapter " \
|
|
54
|
+
"(activerecord-trino-adapter is read-only)"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Trino
|
|
6
|
+
# Behaviors that constrain AR's typical OLTP-shaped query patterns so they
|
|
7
|
+
# don't accidentally hammer a Trino warehouse. Currently:
|
|
8
|
+
#
|
|
9
|
+
# - `find_each` / `find_in_batches` raise: Trino's pagination model does
|
|
10
|
+
# not fit AR's batching cursor.
|
|
11
|
+
#
|
|
12
|
+
# Timeouts and slow-query notifications live in DatabaseStatements; they
|
|
13
|
+
# apply regardless of caller pattern.
|
|
14
|
+
module SafetyBelts
|
|
15
|
+
BATCH_METHODS = %i[find_each find_in_batches in_batches].freeze
|
|
16
|
+
|
|
17
|
+
BATCH_METHODS.each do |method|
|
|
18
|
+
define_method(method) do |*_args, **_kwargs, &_block|
|
|
19
|
+
raise ActiveRecord::Trino::Error,
|
|
20
|
+
"activerecord-trino-adapter: #{method} is not supported on Trino-backed models. " \
|
|
21
|
+
"Use explicit LIMIT/OFFSET pagination or pluck aggregates instead."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Trino
|
|
6
|
+
module SchemaStatements
|
|
7
|
+
def columns(table_name)
|
|
8
|
+
rows = run_trino_query(columns_query(table_name.to_s)).rows
|
|
9
|
+
rows.map do |name, data_type, is_nullable|
|
|
10
|
+
Trino::Column.new(
|
|
11
|
+
name: name,
|
|
12
|
+
sql_type: data_type,
|
|
13
|
+
type: type_map.lookup(data_type),
|
|
14
|
+
null: nullable?(is_nullable)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def data_sources
|
|
20
|
+
run_trino_query("SHOW TABLES").rows.map(&:first)
|
|
21
|
+
end
|
|
22
|
+
alias tables data_sources
|
|
23
|
+
|
|
24
|
+
def table_exists?(table_name)
|
|
25
|
+
data_sources.include?(table_name.to_s)
|
|
26
|
+
end
|
|
27
|
+
alias data_source_exists? table_exists?
|
|
28
|
+
|
|
29
|
+
def primary_key(_table_name)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def indexes(_table_name)
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def foreign_keys(_table_name)
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def views
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def view_exists?(_view_name)
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def schema_cache
|
|
50
|
+
@schema_cache ||= ActiveRecord::ConnectionAdapters::SchemaCache.new(self)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def columns_query(table_name)
|
|
56
|
+
<<~SQL.strip
|
|
57
|
+
SELECT column_name, data_type, is_nullable
|
|
58
|
+
FROM information_schema.columns
|
|
59
|
+
WHERE table_catalog = #{quote(trino_catalog)}
|
|
60
|
+
AND table_schema = #{quote(trino_schema)}
|
|
61
|
+
AND table_name = #{quote(table_name)}
|
|
62
|
+
ORDER BY ordinal_position
|
|
63
|
+
SQL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def trino_catalog
|
|
67
|
+
@client_options[:catalog]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def trino_schema
|
|
71
|
+
@client_options[:schema]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def nullable?(value)
|
|
75
|
+
value.to_s.casecmp?("yes")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module ConnectionAdapters
|
|
7
|
+
module Trino
|
|
8
|
+
class TypeMap
|
|
9
|
+
DECIMAL_PATTERN = /\Adecimal\((\d+)\s*(?:,\s*(\d+))?\)\z/i
|
|
10
|
+
TIMESTAMP_TZ_PATTERN = /\Atimestamp(?:\(\d+\))?\s+with\s+time\s+zone\z/i
|
|
11
|
+
COMPOSITE_PATTERN = /\A(?:array|map|row)\(/i
|
|
12
|
+
|
|
13
|
+
def self.build
|
|
14
|
+
new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@cache = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def lookup(trino_type)
|
|
22
|
+
@cache[trino_type] ||= build_type(trino_type.to_s)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_type(sql_type)
|
|
28
|
+
normalized = sql_type.strip.downcase
|
|
29
|
+
|
|
30
|
+
string_type(normalized) ||
|
|
31
|
+
integer_type(normalized) ||
|
|
32
|
+
float_type(normalized) ||
|
|
33
|
+
date_time_type(normalized) ||
|
|
34
|
+
decimal_type(normalized) ||
|
|
35
|
+
scalar_type(normalized) ||
|
|
36
|
+
unsupported_type(normalized)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def string_type(normalized)
|
|
40
|
+
case normalized
|
|
41
|
+
when "varchar", /\Avarchar\(\d+\)\z/,
|
|
42
|
+
"char", /\Achar\(\d+\)\z/,
|
|
43
|
+
"varbinary", /\Avarbinary\(\d+\)\z/,
|
|
44
|
+
"uuid", "ipaddress", "hyperloglog", "qdigest"
|
|
45
|
+
ActiveModel::Type::String.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def integer_type(normalized)
|
|
50
|
+
case normalized
|
|
51
|
+
when "tinyint" then ActiveModel::Type::Integer.new(limit: 1)
|
|
52
|
+
when "smallint" then ActiveModel::Type::Integer.new(limit: 2)
|
|
53
|
+
when "integer", "int" then ActiveModel::Type::Integer.new(limit: 4)
|
|
54
|
+
when "bigint" then ActiveModel::Type::Integer.new(limit: 8)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def float_type(normalized)
|
|
59
|
+
case normalized
|
|
60
|
+
when "real", "double" then ActiveModel::Type::Float.new
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def date_time_type(normalized)
|
|
65
|
+
case normalized
|
|
66
|
+
when "date" then ActiveModel::Type::Date.new
|
|
67
|
+
when "time", /\Atime\(\d+\)\z/ then ActiveModel::Type::Time.new
|
|
68
|
+
when TIMESTAMP_TZ_PATTERN then ActiveRecord::Trino::Type::TimestampWithZone.new
|
|
69
|
+
when "timestamp", /\Atimestamp\(\d+\)\z/ then ActiveModel::Type::DateTime.new
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def decimal_type(normalized)
|
|
74
|
+
return unless (match = DECIMAL_PATTERN.match(normalized))
|
|
75
|
+
|
|
76
|
+
ActiveModel::Type::Decimal.new(
|
|
77
|
+
precision: match[1].to_i,
|
|
78
|
+
scale: (match[2] || 0).to_i
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def scalar_type(normalized)
|
|
83
|
+
case normalized
|
|
84
|
+
when "boolean" then ActiveModel::Type::Boolean.new
|
|
85
|
+
when "json" then ActiveRecord::Trino::Type::Json.new
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def unsupported_type(normalized)
|
|
90
|
+
ActiveRecord::Trino::Type::Unsupported.new(normalized)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_record/connection_adapters/abstract_adapter"
|
|
5
|
+
require "trino-client"
|
|
6
|
+
|
|
7
|
+
require "active_record/trino"
|
|
8
|
+
|
|
9
|
+
require_relative "trino/quoting"
|
|
10
|
+
require_relative "trino/type_map"
|
|
11
|
+
require_relative "trino/column"
|
|
12
|
+
require_relative "trino/database_statements"
|
|
13
|
+
require_relative "trino/schema_statements"
|
|
14
|
+
require_relative "trino/read_only"
|
|
15
|
+
require_relative "trino/safety_belts"
|
|
16
|
+
|
|
17
|
+
module ActiveRecord
|
|
18
|
+
module ConnectionAdapters
|
|
19
|
+
class TrinoAdapter < AbstractAdapter
|
|
20
|
+
ADAPTER_NAME = "Trino"
|
|
21
|
+
|
|
22
|
+
include Trino::Quoting
|
|
23
|
+
include Trino::DatabaseStatements
|
|
24
|
+
include Trino::SchemaStatements
|
|
25
|
+
include Trino::ReadOnly
|
|
26
|
+
|
|
27
|
+
def initialize(...)
|
|
28
|
+
super
|
|
29
|
+
@client_options = ActiveRecord::Trino::Config.client_options(@config)
|
|
30
|
+
@slow_query_threshold = ActiveRecord::Trino::Config.slow_query_threshold(@config)
|
|
31
|
+
@client = build_client
|
|
32
|
+
install_safety_belts!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def adapter_name
|
|
36
|
+
ADAPTER_NAME
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def active?
|
|
40
|
+
!@client.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def reconnect!
|
|
44
|
+
disconnect!
|
|
45
|
+
@client = build_client
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def disconnect!
|
|
49
|
+
@client = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def supports_transactions?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def supports_savepoints?
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def supports_lazy_transactions?
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def supports_advisory_locks?
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def supports_explain?
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def supports_migrations?
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def supports_ddl_transactions?
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def supports_views?
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ActiveRecord's adapter API expects this method name without a question mark.
|
|
85
|
+
def prepared_statements # rubocop:disable Naming/PredicateMethod
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def requires_reloading?
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def native_database_types
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def lookup_cast_type(sql_type)
|
|
98
|
+
type_map.lookup(sql_type)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def lookup_cast_type_from_column(column)
|
|
102
|
+
column.cast_type
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
attr_reader :client, :last_query_id, :last_query_info_uri, :last_query_stats
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def build_client
|
|
110
|
+
::Trino::Client.new(@client_options)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def type_map
|
|
114
|
+
@type_map ||= Trino::TypeMap.build
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def install_safety_belts!
|
|
118
|
+
return unless defined?(::ActiveRecord::Relation)
|
|
119
|
+
|
|
120
|
+
return if ::ActiveRecord::Relation.include?(RelationSafetyBelts)
|
|
121
|
+
|
|
122
|
+
::ActiveRecord::Relation.prepend(RelationSafetyBelts)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The safety belts override AR::Relation batch methods only when the relation's
|
|
126
|
+
# connection is a TrinoAdapter, so they don't leak into other databases used in
|
|
127
|
+
# the same process.
|
|
128
|
+
module RelationSafetyBelts
|
|
129
|
+
Trino::SafetyBelts::BATCH_METHODS.each do |method|
|
|
130
|
+
define_method(method) do |*args, **kwargs, &block|
|
|
131
|
+
if connection.is_a?(::ActiveRecord::ConnectionAdapters::TrinoAdapter)
|
|
132
|
+
raise ActiveRecord::Trino::Error,
|
|
133
|
+
"activerecord-trino-adapter: #{method} is not supported on Trino-backed models. " \
|
|
134
|
+
"Use explicit LIMIT/OFFSET pagination or pluck aggregates instead."
|
|
135
|
+
else
|
|
136
|
+
super(*args, **kwargs, &block)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Trino
|
|
5
|
+
module Config
|
|
6
|
+
DEFAULT_QUERY_TIMEOUT = 150
|
|
7
|
+
DEFAULT_PLAN_TIMEOUT = 30
|
|
8
|
+
DEFAULT_SLOW_QUERY_THRESHOLD_SECONDS = 20
|
|
9
|
+
DEFAULT_HTTP_PORT = 8080
|
|
10
|
+
DEFAULT_HTTPS_PORT = 443
|
|
11
|
+
|
|
12
|
+
REQUIRED_KEYS = %i[host user catalog schema].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def client_options(config)
|
|
17
|
+
symbolized = symbolize(config)
|
|
18
|
+
validate!(symbolized)
|
|
19
|
+
ssl = symbolized.fetch(:ssl, false)
|
|
20
|
+
port = symbolized.fetch(:port, default_port(ssl))
|
|
21
|
+
{
|
|
22
|
+
server: "#{symbolized[:host]}:#{port}",
|
|
23
|
+
user: symbolized[:user],
|
|
24
|
+
password: symbolized[:password],
|
|
25
|
+
catalog: symbolized[:catalog],
|
|
26
|
+
schema: symbolized[:schema],
|
|
27
|
+
ssl: ssl,
|
|
28
|
+
http_proxy: symbolized[:http_proxy],
|
|
29
|
+
time_zone: symbolized[:time_zone],
|
|
30
|
+
query_timeout: symbolized.fetch(:query_timeout, DEFAULT_QUERY_TIMEOUT),
|
|
31
|
+
plan_timeout: symbolized.fetch(:plan_timeout, DEFAULT_PLAN_TIMEOUT),
|
|
32
|
+
}.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_port(ssl)
|
|
36
|
+
ssl ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def slow_query_threshold(config)
|
|
40
|
+
symbolized = symbolize(config)
|
|
41
|
+
symbolized.fetch(:slow_query_threshold_seconds, DEFAULT_SLOW_QUERY_THRESHOLD_SECONDS).to_f
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate!(config)
|
|
45
|
+
symbolized = symbolize(config)
|
|
46
|
+
missing = REQUIRED_KEYS.reject { |k| symbolized[k] && !symbolized[k].to_s.empty? }
|
|
47
|
+
return if missing.empty?
|
|
48
|
+
|
|
49
|
+
raise ActiveRecord::Trino::ConfigurationError,
|
|
50
|
+
"activerecord-trino-adapter: missing required config keys: #{missing.join(', ')}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def symbolize(config)
|
|
54
|
+
config.to_h.transform_keys(&:to_sym)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "benchmark"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Trino
|
|
7
|
+
# Diagnostic helpers for Trino-backed models. Useful when
|
|
8
|
+
# investigating why a Trino-backed query is slow.
|
|
9
|
+
module Diagnostics
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Profile a Trino-backed AR model's first-query latency. Resets
|
|
13
|
+
# the column cache to force a schema re-fetch, times it, then runs
|
|
14
|
+
# a sample SELECT and pulls the per-query breakdown from Trino's
|
|
15
|
+
# own response.
|
|
16
|
+
#
|
|
17
|
+
# Returns a Hash with:
|
|
18
|
+
# :schema_time Float seconds spent on information_schema.columns
|
|
19
|
+
# :query_time Float seconds spent on the sample SELECT (Ruby-side)
|
|
20
|
+
# :query_id Trino query_id (cross-reference in the Trino UI)
|
|
21
|
+
# :info_uri URL to the query's stats page in the Trino UI
|
|
22
|
+
# :queued_time_ms Trino-side: time the query spent queued
|
|
23
|
+
# :elapsed_time_ms Trino-side: total wall clock (queued + execution)
|
|
24
|
+
# :cpu_time_ms Trino-side: CPU time spent on the query
|
|
25
|
+
# :wall_time_ms Trino-side: wall-clock execution time (planning + exec)
|
|
26
|
+
# :state "FINISHED" / "FAILED" / etc.
|
|
27
|
+
#
|
|
28
|
+
# Note: the Trino 351 StatementStats model does not expose
|
|
29
|
+
# planning_time as a discrete field — it is folded into wall_time.
|
|
30
|
+
# If you need the planning slice specifically, subtract wall_time
|
|
31
|
+
# from elapsed_time as a rough approximation.
|
|
32
|
+
def profile(model_class)
|
|
33
|
+
connection = model_class.connection
|
|
34
|
+
model_class.reset_column_information
|
|
35
|
+
schema_time = ::Benchmark.realtime { model_class.columns }
|
|
36
|
+
|
|
37
|
+
# Issue the sample SELECT through the adapter directly so it is the
|
|
38
|
+
# last query touching the connection — AR's higher-level paths can
|
|
39
|
+
# fire follow-up bookkeeping queries (SHOW TABLES, etc.) that would
|
|
40
|
+
# otherwise overwrite last_query_* before we read them.
|
|
41
|
+
table = connection.quote_table_name(model_class.table_name)
|
|
42
|
+
sql = "SELECT * FROM #{table} LIMIT 1"
|
|
43
|
+
query_time = ::Benchmark.realtime { connection.exec_query(sql) }
|
|
44
|
+
|
|
45
|
+
build_result(connection, schema_time, query_time)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_result(connection, schema_time, query_time)
|
|
49
|
+
stats = connection.last_query_stats || {}
|
|
50
|
+
{
|
|
51
|
+
schema_time: schema_time,
|
|
52
|
+
query_time: query_time,
|
|
53
|
+
query_id: connection.last_query_id,
|
|
54
|
+
info_uri: connection.last_query_info_uri,
|
|
55
|
+
queued_time_ms: stats[:queued_time_millis],
|
|
56
|
+
elapsed_time_ms: stats[:elapsed_time_millis],
|
|
57
|
+
cpu_time_ms: stats[:cpu_time_millis],
|
|
58
|
+
wall_time_ms: stats[:wall_time_millis],
|
|
59
|
+
state: stats[:state],
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
module Trino
|
|
8
|
+
module Type
|
|
9
|
+
class Json < ActiveModel::Type::Value
|
|
10
|
+
def type
|
|
11
|
+
:json
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cast(value)
|
|
15
|
+
return value if value.nil? || value.is_a?(::Hash) || value.is_a?(::Array)
|
|
16
|
+
return value unless value.is_a?(::String)
|
|
17
|
+
|
|
18
|
+
::JSON.parse(value)
|
|
19
|
+
rescue ::JSON::ParserError
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module ActiveRecord
|
|
7
|
+
module Trino
|
|
8
|
+
module Type
|
|
9
|
+
class TimestampWithZone < ActiveModel::Type::Value
|
|
10
|
+
def type
|
|
11
|
+
:datetime
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cast(value)
|
|
15
|
+
return value if value.nil? || value.is_a?(::Time) || value.is_a?(::DateTime)
|
|
16
|
+
return value unless value.is_a?(::String)
|
|
17
|
+
|
|
18
|
+
::Time.parse(value)
|
|
19
|
+
rescue ::ArgumentError
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
module Trino
|
|
7
|
+
module Type
|
|
8
|
+
class Unsupported < ActiveModel::Type::Value
|
|
9
|
+
def initialize(trino_type = nil)
|
|
10
|
+
super()
|
|
11
|
+
@trino_type = trino_type
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def type
|
|
15
|
+
:unsupported
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def cast(_value)
|
|
19
|
+
raise ActiveRecord::Trino::UnsupportedTypeError,
|
|
20
|
+
"activerecord-trino-adapter: cannot cast Trino type #{@trino_type.inspect}; " \
|
|
21
|
+
"select scalar columns explicitly or extract values via Trino SQL"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support"
|
|
5
|
+
require "active_support/notifications"
|
|
6
|
+
require "trino-client"
|
|
7
|
+
|
|
8
|
+
require_relative "trino/version"
|
|
9
|
+
require_relative "trino/errors"
|
|
10
|
+
require_relative "trino/config"
|
|
11
|
+
require_relative "trino/type/json"
|
|
12
|
+
require_relative "trino/type/timestamp_with_zone"
|
|
13
|
+
require_relative "trino/type/unsupported"
|
|
14
|
+
require_relative "trino/diagnostics"
|
|
15
|
+
|
|
16
|
+
if ActiveRecord::ConnectionAdapters.respond_to?(:register)
|
|
17
|
+
ActiveRecord::ConnectionAdapters.register(
|
|
18
|
+
"trino",
|
|
19
|
+
"ActiveRecord::ConnectionAdapters::TrinoAdapter",
|
|
20
|
+
"active_record/connection_adapters/trino_adapter"
|
|
21
|
+
)
|
|
22
|
+
else
|
|
23
|
+
# Rails 7.1 uses the legacy adapter_method pattern: ActiveRecord::Base needs
|
|
24
|
+
# a trino_connection class method that returns a new TrinoAdapter. Rails 7.2+
|
|
25
|
+
# replaced this with ConnectionAdapters.register above.
|
|
26
|
+
require "active_record/connection_adapters/trino_adapter"
|
|
27
|
+
ActiveRecord::Base.singleton_class.define_method(:trino_connection) do |config|
|
|
28
|
+
ActiveRecord::ConnectionAdapters::TrinoAdapter.new(config)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module ActiveRecord
|
|
33
|
+
module Trino
|
|
34
|
+
def self.reset_schema_cache!(model_class)
|
|
35
|
+
model_class.reset_column_information
|
|
36
|
+
return unless model_class.connection.respond_to?(:schema_cache)
|
|
37
|
+
|
|
38
|
+
model_class.connection.schema_cache.clear!
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-trino-adapter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Garett Arrowood
|
|
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: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.1'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.1'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.1'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: activesupport
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '7.1'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '8.1'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '7.1'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '8.1'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: trino-client
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - '='
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 2.2.4
|
|
59
|
+
type: :runtime
|
|
60
|
+
prerelease: false
|
|
61
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - '='
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: 2.2.4
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: appraisal
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - '='
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: 2.5.0
|
|
73
|
+
type: :development
|
|
74
|
+
prerelease: false
|
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - '='
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: 2.5.0
|
|
80
|
+
- !ruby/object:Gem::Dependency
|
|
81
|
+
name: license_finder
|
|
82
|
+
requirement: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - '='
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: 7.2.1
|
|
87
|
+
type: :development
|
|
88
|
+
prerelease: false
|
|
89
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - '='
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: 7.2.1
|
|
94
|
+
- !ruby/object:Gem::Dependency
|
|
95
|
+
name: pry-byebug
|
|
96
|
+
requirement: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - '='
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: 3.10.1
|
|
101
|
+
type: :development
|
|
102
|
+
prerelease: false
|
|
103
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - '='
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: 3.10.1
|
|
108
|
+
- !ruby/object:Gem::Dependency
|
|
109
|
+
name: rspec
|
|
110
|
+
requirement: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - '='
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: 3.13.2
|
|
115
|
+
type: :development
|
|
116
|
+
prerelease: false
|
|
117
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - '='
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: 3.13.2
|
|
122
|
+
- !ruby/object:Gem::Dependency
|
|
123
|
+
name: rubocop
|
|
124
|
+
requirement: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - '='
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: 1.82.1
|
|
129
|
+
type: :development
|
|
130
|
+
prerelease: false
|
|
131
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
132
|
+
requirements:
|
|
133
|
+
- - '='
|
|
134
|
+
- !ruby/object:Gem::Version
|
|
135
|
+
version: 1.82.1
|
|
136
|
+
- !ruby/object:Gem::Dependency
|
|
137
|
+
name: rubocop-powerhome
|
|
138
|
+
requirement: !ruby/object:Gem::Requirement
|
|
139
|
+
requirements:
|
|
140
|
+
- - ">="
|
|
141
|
+
- !ruby/object:Gem::Version
|
|
142
|
+
version: '0'
|
|
143
|
+
type: :development
|
|
144
|
+
prerelease: false
|
|
145
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
146
|
+
requirements:
|
|
147
|
+
- - ">="
|
|
148
|
+
- !ruby/object:Gem::Version
|
|
149
|
+
version: '0'
|
|
150
|
+
- !ruby/object:Gem::Dependency
|
|
151
|
+
name: webmock
|
|
152
|
+
requirement: !ruby/object:Gem::Requirement
|
|
153
|
+
requirements:
|
|
154
|
+
- - '='
|
|
155
|
+
- !ruby/object:Gem::Version
|
|
156
|
+
version: 3.26.2
|
|
157
|
+
type: :development
|
|
158
|
+
prerelease: false
|
|
159
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
160
|
+
requirements:
|
|
161
|
+
- - '='
|
|
162
|
+
- !ruby/object:Gem::Version
|
|
163
|
+
version: 3.26.2
|
|
164
|
+
description: A read-only ActiveRecord SQL adapter for Trino, built on top of the trino-client
|
|
165
|
+
gem. Lets Rails applications query a Trino data warehouse using familiar ActiveRecord
|
|
166
|
+
scopes, where clauses, and joins, while preventing accidental writes.
|
|
167
|
+
email:
|
|
168
|
+
- garettarrowood@gmail.com
|
|
169
|
+
executables: []
|
|
170
|
+
extensions: []
|
|
171
|
+
extra_rdoc_files: []
|
|
172
|
+
files:
|
|
173
|
+
- LICENSE.txt
|
|
174
|
+
- README.md
|
|
175
|
+
- lib/active_record/connection_adapters/trino/column.rb
|
|
176
|
+
- lib/active_record/connection_adapters/trino/database_statements.rb
|
|
177
|
+
- lib/active_record/connection_adapters/trino/quoting.rb
|
|
178
|
+
- lib/active_record/connection_adapters/trino/read_only.rb
|
|
179
|
+
- lib/active_record/connection_adapters/trino/safety_belts.rb
|
|
180
|
+
- lib/active_record/connection_adapters/trino/schema_statements.rb
|
|
181
|
+
- lib/active_record/connection_adapters/trino/type_map.rb
|
|
182
|
+
- lib/active_record/connection_adapters/trino_adapter.rb
|
|
183
|
+
- lib/active_record/trino.rb
|
|
184
|
+
- lib/active_record/trino/config.rb
|
|
185
|
+
- lib/active_record/trino/diagnostics.rb
|
|
186
|
+
- lib/active_record/trino/errors.rb
|
|
187
|
+
- lib/active_record/trino/type/json.rb
|
|
188
|
+
- lib/active_record/trino/type/timestamp_with_zone.rb
|
|
189
|
+
- lib/active_record/trino/type/unsupported.rb
|
|
190
|
+
- lib/active_record/trino/version.rb
|
|
191
|
+
homepage: https://github.com/powerhome/activerecord-trino-adapter
|
|
192
|
+
licenses:
|
|
193
|
+
- MIT
|
|
194
|
+
metadata:
|
|
195
|
+
homepage_uri: https://github.com/powerhome/activerecord-trino-adapter
|
|
196
|
+
source_code_uri: https://github.com/powerhome/activerecord-trino-adapter
|
|
197
|
+
changelog_uri: https://github.com/powerhome/activerecord-trino-adapter/blob/main/CHANGELOG.md
|
|
198
|
+
rubygems_mfa_required: 'true'
|
|
199
|
+
rdoc_options: []
|
|
200
|
+
require_paths:
|
|
201
|
+
- lib
|
|
202
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
203
|
+
requirements:
|
|
204
|
+
- - ">="
|
|
205
|
+
- !ruby/object:Gem::Version
|
|
206
|
+
version: '3.2'
|
|
207
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
208
|
+
requirements:
|
|
209
|
+
- - ">="
|
|
210
|
+
- !ruby/object:Gem::Version
|
|
211
|
+
version: '0'
|
|
212
|
+
requirements: []
|
|
213
|
+
rubygems_version: 4.0.3
|
|
214
|
+
specification_version: 4
|
|
215
|
+
summary: Read-only ActiveRecord adapter for Trino
|
|
216
|
+
test_files: []
|