upkeep-rails 0.2.5-aarch64-linux-gnu
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 +244 -0
- data/docs/drafts/turbo-streams-is-cache-invalidation-you-write-by-hand.md +240 -0
- data/docs/how-it-works.md +329 -0
- data/docs/plans/sqlglot-active-record-migration.md +211 -0
- data/docs/plans/turbo-frame-subscription-composition.md +90 -0
- data/docs/spikes/SQLGLOT_VS_AREL_FINDINGS.md +136 -0
- data/docs/spikes/query-source-analysis-comparison/FINDINGS.md +169 -0
- data/docs/spikes/sqlglot-first-active-record/README.md +26 -0
- data/docs/spikes/sqlglot-query-analysis/FINDINGS.md +110 -0
- data/docs/spikes/sqlglot-query-analysis/PULSE_1802_FINDINGS.md +95 -0
- data/docs/spikes/sqlglot-semantic-bindings/README.md +29 -0
- data/lib/generators/upkeep/install/install_generator.rb +192 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +49 -0
- data/lib/generators/upkeep/install/templates/subscription.js +288 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +65 -0
- data/lib/upkeep/active_record_query.rb +248 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +454 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +48 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +339 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +600 -0
- data/lib/upkeep/herb/developer_report.rb +135 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +149 -0
- data/lib/upkeep/herb/template_manifest.rb +548 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +411 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +1007 -0
- data/lib/upkeep/rails/activation_token.rb +55 -0
- data/lib/upkeep/rails/cable/channel.rb +165 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +361 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +65 -0
- data/lib/upkeep/rails/cluster_guard.rb +57 -0
- data/lib/upkeep/rails/configuration.rb +252 -0
- data/lib/upkeep/rails/controller_runtime.rb +187 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/job_runtime.rb +43 -0
- data/lib/upkeep/rails/railtie.rb +44 -0
- data/lib/upkeep/rails/replay.rb +244 -0
- data/lib/upkeep/rails/testing.rb +259 -0
- data/lib/upkeep/rails.rb +466 -0
- data/lib/upkeep/replay.rb +462 -0
- data/lib/upkeep/runtime.rb +1276 -0
- data/lib/upkeep/shared_streams.rb +86 -0
- data/lib/upkeep/sql_dependency_analysis.rb +553 -0
- data/lib/upkeep/sqlglot/libsqlglot_rust.so +0 -0
- data/lib/upkeep/sqlglot/native.rb +121 -0
- data/lib/upkeep/sqlglot/native_library.rb +23 -0
- data/lib/upkeep/sqlglot.rb +367 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +398 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +411 -0
- data/lib/upkeep/subscriptions/active_registry.rb +80 -0
- data/lib/upkeep/subscriptions/base_store.rb +110 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +125 -0
- data/lib/upkeep/subscriptions/lookup_instrumentation.rb +32 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +228 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +313 -0
- data/lib/upkeep/subscriptions/shape.rb +117 -0
- data/lib/upkeep/subscriptions/store.rb +349 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +146 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +15 -0
- data/upkeep-rails.gemspec +66 -0
- metadata +327 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# SQLGlot-first vs Arel, and missing semantic bindings
|
|
2
|
+
|
|
3
|
+
## Outcome
|
|
4
|
+
|
|
5
|
+
A SQL-first decoder is viable enough to pursue. It can use one policy for both
|
|
6
|
+
Active Record-generated SQL and handwritten SQL, while lowering both into a
|
|
7
|
+
small generic dependency graph. The spike handles the cases where the current
|
|
8
|
+
Arel decoder succeeds and cases it intentionally rejects: raw predicates, raw
|
|
9
|
+
joins, correlated `EXISTS`, and CTEs.
|
|
10
|
+
|
|
11
|
+
This is not yet a drop-in replacement. The current Ruby gem exposes only the
|
|
12
|
+
parser-shaped JSON AST. The underlying Rust library has useful semantic APIs,
|
|
13
|
+
but v0.10.12 also has correctness gaps that Upkeep must test and either fix
|
|
14
|
+
upstream or normalize locally.
|
|
15
|
+
|
|
16
|
+
## Comparison
|
|
17
|
+
|
|
18
|
+
| Concern | Current Arel decoder | SQLGlot-first spike |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| Hash/Arel predicates | Structural, typed nodes | Parsed from final SQL |
|
|
21
|
+
| Raw predicate/order | Rejected as opaque | Analyzed |
|
|
22
|
+
| Raw join/source | Rejected as opaque | Analyzed |
|
|
23
|
+
| Correlated subquery | Becomes opaque when handwritten | Inner/outer columns and equality edge found |
|
|
24
|
+
| CTE | Depends on Arel shape | Physical CTE sources found; CTE name suppressed |
|
|
25
|
+
| Simple predicate values | Preserves Ruby values/binds | Recovers SQL literal values |
|
|
26
|
+
| Bind type fidelity | Strong | Lost after `to_sql` unless passed separately |
|
|
27
|
+
| Parser dependency | Rails/Arel internals | Native SQLGlot dependency |
|
|
28
|
+
| Decoder policy | Structured vs opaque branches | One SQL policy with explicit confidence/warnings |
|
|
29
|
+
|
|
30
|
+
The generic result is deliberately not a catalog of SQL features:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
physical source ── referenced column
|
|
34
|
+
│
|
|
35
|
+
├── equality edge ── other source.column
|
|
36
|
+
└── constant predicate (optional DNF group)
|
|
37
|
+
|
|
38
|
+
query shape: limit / offset / distinct / group / having
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
SQL constructs belong in the decoder. `DependencyPlan` should only consume the
|
|
42
|
+
graph above plus confidence/coverage. That keeps `Linked` source kinds generic.
|
|
43
|
+
|
|
44
|
+
## Ruby bindings worth adding
|
|
45
|
+
|
|
46
|
+
The Ruby `sqlglot` gem 0.1.1 currently binds parse, generate, transpile, and
|
|
47
|
+
version. The standalone binding proves these existing `sql-glot-rust` APIs are
|
|
48
|
+
directly useful:
|
|
49
|
+
|
|
50
|
+
1. `MappingSchema` + `qualify_columns`
|
|
51
|
+
- resolves unqualified columns using Active Record's schema;
|
|
52
|
+
- expands wildcards;
|
|
53
|
+
- makes alias handling less dependent on a Ruby AST walker.
|
|
54
|
+
2. `build_scope`
|
|
55
|
+
- exposes sources per query scope;
|
|
56
|
+
- separates child subqueries and CTE scopes;
|
|
57
|
+
- exposes all columns, including WHERE/JOIN columns;
|
|
58
|
+
- marks external columns and correlated subqueries.
|
|
59
|
+
3. `lineage`
|
|
60
|
+
- useful for selected/output values and derived expressions;
|
|
61
|
+
- **not** sufficient for invalidation because filter-only and join-only
|
|
62
|
+
sources are intentionally absent.
|
|
63
|
+
|
|
64
|
+
One JSON-returning `analyze` FFI entry point is preferable to many fine-grained
|
|
65
|
+
calls: parse, qualify, build scopes, and compute requested output lineages in
|
|
66
|
+
Rust, then cross the FFI boundary once.
|
|
67
|
+
|
|
68
|
+
## Challenging observations
|
|
69
|
+
|
|
70
|
+
- Correlated `EXISTS` works: the inner scope is correlated, includes
|
|
71
|
+
`efforts.milestone_id` and `efforts.status`, and reports
|
|
72
|
+
`milestones.id` as an external column.
|
|
73
|
+
- Output lineage for `SELECT milestones.id ... EXISTS (...)` contains
|
|
74
|
+
`milestones` but not `efforts`. The scope tree contains both. Upkeep should
|
|
75
|
+
derive invalidation inputs from scopes/predicates, not output lineage.
|
|
76
|
+
- Schema qualification resolves unqualified columns and expands selected
|
|
77
|
+
columns.
|
|
78
|
+
- CTE qualification works, and the CTE child scope points at its physical
|
|
79
|
+
table. However, v0.10.12 currently represents the root reference to the CTE
|
|
80
|
+
as `Source::Table("active_cards")`, not `Source::Scope`. This needs an
|
|
81
|
+
upstream fix or a narrowly tested normalizer.
|
|
82
|
+
- Parse failures cross the proposed FFI boundary as structured JSON errors,
|
|
83
|
+
rather than null pointers with lost diagnostics.
|
|
84
|
+
|
|
85
|
+
The earlier Pulse #1802 corpus remains relevant: parsing covers operator
|
|
86
|
+
expressions, functions, nested queries, aliases, and raw SQL more uniformly
|
|
87
|
+
than class-by-class Arel handling. It does not remove the need for semantic
|
|
88
|
+
tests around scope resolution and Rails-emitted dialect details.
|
|
89
|
+
|
|
90
|
+
## Performance
|
|
91
|
+
|
|
92
|
+
Warm macOS arm64 measurements over 5,000 analyses:
|
|
93
|
+
|
|
94
|
+
| Analyzer | Mean per analysis |
|
|
95
|
+
| --- | ---: |
|
|
96
|
+
| Arel, structured query | 18.3–18.6 µs |
|
|
97
|
+
| SQLGlot, same structured query | 47.7 µs |
|
|
98
|
+
| SQLGlot, raw join + predicate + order | 89.9–90.3 µs |
|
|
99
|
+
|
|
100
|
+
The SQL-first path is about 2.6× slower on the comparable structured query, but
|
|
101
|
+
still below 0.1 ms in this corpus. Schema metadata was cached; without that
|
|
102
|
+
cache, reflection dominated at roughly 0.6 ms.
|
|
103
|
+
|
|
104
|
+
This analysis should be cached by normalized query shape and schema version. It
|
|
105
|
+
should not run once per database change or once per dependent render during
|
|
106
|
+
fan-out. The fan-out cost is then graph matching, not SQL parsing.
|
|
107
|
+
|
|
108
|
+
The standalone release dylib is 3.1 MB on this machine. The complete local Cargo
|
|
109
|
+
release directory is larger (88 MB) but is build output, not shipped runtime.
|
|
110
|
+
|
|
111
|
+
## Recommendation
|
|
112
|
+
|
|
113
|
+
Proceed with a production-shaped SQL-first prototype behind the existing
|
|
114
|
+
`ActiveRecordQuery.analyze` contract:
|
|
115
|
+
|
|
116
|
+
1. Add the semantic `analyze` binding to the Ruby gem (preferably upstream).
|
|
117
|
+
2. Feed it schema data from Active Record's schema cache.
|
|
118
|
+
3. Lower its result into one generic dependency graph.
|
|
119
|
+
4. Keep Arel temporarily as a parity oracle and as the source of bind values/type
|
|
120
|
+
metadata, not as a second invalidation policy.
|
|
121
|
+
5. Run the full query corpus against both decoders and record false-negative,
|
|
122
|
+
conservative, and unsupported results.
|
|
123
|
+
6. Remove the Arel decoder only after SQL-first parity and performance targets
|
|
124
|
+
are met.
|
|
125
|
+
|
|
126
|
+
The key architectural distinction is “two decoders during migration,” not “two
|
|
127
|
+
policies forever.”
|
|
128
|
+
|
|
129
|
+
## Source references
|
|
130
|
+
|
|
131
|
+
- [`sql-glot-ruby` v0.1.1 native bindings](https://github.com/AccountAim/sql-glot-ruby/blob/v0.1.1/lib/sqlglot/native.rb)
|
|
132
|
+
- [`sql-glot-rust` v0.10.12 scope analysis](https://github.com/protegrity/sql-glot-rust/blob/v0.10.12/src/optimizer/scope_analysis.rs)
|
|
133
|
+
- [`sql-glot-rust` v0.10.12 column qualification](https://github.com/protegrity/sql-glot-rust/blob/v0.10.12/src/optimizer/qualify_columns.rs)
|
|
134
|
+
- [`sql-glot-rust` v0.10.12 lineage](https://github.com/protegrity/sql-glot-rust/blob/v0.10.12/src/optimizer/lineage.rs)
|
|
135
|
+
- [Active Record 8.1.3 query methods](https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/active_record/relation/query_methods.rb)
|
|
136
|
+
- [Arel 8.1.3 bound SQL literals](https://github.com/rails/rails/blob/v8.1.3/activerecord/lib/arel/nodes/bound_sql_literal.rb)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Opaque-query source analysis comparison
|
|
2
|
+
|
|
3
|
+
Date: 2026-07-24
|
|
4
|
+
|
|
5
|
+
## Decision
|
|
6
|
+
|
|
7
|
+
Use the Ruby `sqlglot` gem as Upkeep's first automatic fallback when Arel
|
|
8
|
+
contains opaque SQL. Keep an Upkeep-owned, scope-aware AST walker and extract
|
|
9
|
+
tables only.
|
|
10
|
+
|
|
11
|
+
Do not add user-selectable query policies. The runtime should follow one
|
|
12
|
+
resolution chain:
|
|
13
|
+
|
|
14
|
+
1. Use Arel for precise column and predicate coverage.
|
|
15
|
+
2. Use SQLGlot for conservative named-table coverage.
|
|
16
|
+
3. Resolve views through adapter schema metadata where practical.
|
|
17
|
+
4. Refuse the boundary when a source remains hidden or unresolvable.
|
|
18
|
+
|
|
19
|
+
Database-native inspection can improve individual adapters later, but it should
|
|
20
|
+
not be the primary cross-adapter mechanism.
|
|
21
|
+
|
|
22
|
+
## Corpus
|
|
23
|
+
|
|
24
|
+
The named-source corpus has 12 queries across PostgreSQL, MySQL, and SQLite:
|
|
25
|
+
|
|
26
|
+
- raw predicates and order expressions;
|
|
27
|
+
- raw and schema-qualified joins;
|
|
28
|
+
- correlated `EXISTS` and `IN` subqueries;
|
|
29
|
+
- derived tables;
|
|
30
|
+
- ordinary, shadowing, and recursive CTEs;
|
|
31
|
+
- PostgreSQL full-text search; and
|
|
32
|
+
- MySQL JSON expressions.
|
|
33
|
+
|
|
34
|
+
A separate four-case physical-source corpus tests sources hidden behind views
|
|
35
|
+
and SQL functions.
|
|
36
|
+
|
|
37
|
+
The correctness rule is strict: an approach fails if it omits any physical
|
|
38
|
+
table that could affect the result. Extra tables are conservative but still
|
|
39
|
+
recorded because they increase fanout.
|
|
40
|
+
|
|
41
|
+
## Results
|
|
42
|
+
|
|
43
|
+
Benchmarks are local single-process measurements on Apple Silicon. They compare
|
|
44
|
+
orders of magnitude, not production latency.
|
|
45
|
+
|
|
46
|
+
| Approach | Named-source correctness | Local cost | Portability | Finding |
|
|
47
|
+
| --- | ---: | ---: | --- | --- |
|
|
48
|
+
| Ruby SQLGlot 0.1.1 | 12/12 | ~67 μs/parse | PostgreSQL, MySQL, SQLite; CRuby native gem | Best overall trade-off |
|
|
49
|
+
| `sqlparser-rs` 0.62.0 | 12/12 | ~21 μs/parse | Multi-dialect Rust library | Fastest, but Upkeep would own a Ruby binding and native release matrix |
|
|
50
|
+
| Python SQLGlot 27.20.0 | 12/12 | ~501 μs/parse | Multi-dialect, separate Python runtime | Correct but operationally inappropriate for a Rails gem |
|
|
51
|
+
| `pg_query` 6.2.2 | 4/4 PostgreSQL | ~104 μs/parse | PostgreSQL and CRuby only | Mature and exact for PostgreSQL, but does not solve adapter portability |
|
|
52
|
+
| PostgreSQL 17.9 `EXPLAIN (FORMAT JSON)` | 4/4 PostgreSQL | ~82 μs/explain | PostgreSQL connection required | Correct baseline; expands views and stable SQL functions |
|
|
53
|
+
| SQLite 3.53 `EXPLAIN QUERY PLAN` text | 5/6 SQLite | ~12 μs/explain | SQLite only | Unsafe: reports aliases instead of physical tables |
|
|
54
|
+
| SQLite authorizer during prepare | 6/6 SQLite | ~15 μs/prepare | SQLite only | Correct baseline and exposed by the existing Ruby `sqlite3` gem |
|
|
55
|
+
|
|
56
|
+
MySQL `EXPLAIN FORMAT=JSON` was not executed because no local MySQL server was
|
|
57
|
+
available. Adding it would still leave Upkeep with three adapter-specific
|
|
58
|
+
implementations and three different result formats.
|
|
59
|
+
|
|
60
|
+
## Important failures
|
|
61
|
+
|
|
62
|
+
### Convenience metadata is not a correctness API
|
|
63
|
+
|
|
64
|
+
The Ruby SQLGlot wrapper's `Sqlglot::Query#tables` missed tables inside
|
|
65
|
+
correlated subqueries, `IN` subqueries, and derived tables. Its underlying AST
|
|
66
|
+
contained all sources. The scope-aware recursive extractor in the original
|
|
67
|
+
SQLGlot spike recovered all 12 cases.
|
|
68
|
+
|
|
69
|
+
### Syntax parsers cannot expand database objects
|
|
70
|
+
|
|
71
|
+
All four parsers return the named view or table function, not the physical
|
|
72
|
+
tables behind it:
|
|
73
|
+
|
|
74
|
+
| Query source | Parser result | Physical dependency |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| SQLite `published_posts` view | `published_posts` | `posts` |
|
|
77
|
+
| PostgreSQL `active_accounts` view | `active_accounts` | `accounts` |
|
|
78
|
+
| PostgreSQL `account_events(42)` | no table, or the function name | `audit.events` |
|
|
79
|
+
|
|
80
|
+
This is expected: a parser has SQL text but not the connected database schema.
|
|
81
|
+
|
|
82
|
+
### Query plans are useful but not complete
|
|
83
|
+
|
|
84
|
+
PostgreSQL JSON `EXPLAIN` expanded:
|
|
85
|
+
|
|
86
|
+
- the `active_accounts` view to `accounts`; and
|
|
87
|
+
- an inlineable `STABLE` SQL function to `audit.events`.
|
|
88
|
+
|
|
89
|
+
It did not expose the table read by an otherwise identical `VOLATILE` SQL
|
|
90
|
+
function. The plan contained a function scan with no underlying relation.
|
|
91
|
+
Therefore `EXPLAIN` cannot be treated as a universal proof of physical sources.
|
|
92
|
+
|
|
93
|
+
SQLite's textual plan reported `physical_posts`, a query alias, instead of the
|
|
94
|
+
real `posts` table. Its authorizer callback reported the physical table
|
|
95
|
+
correctly, but installing and restoring a connection-global authorizer safely
|
|
96
|
+
inside Rails requires concurrency and reentrancy work.
|
|
97
|
+
|
|
98
|
+
## Dependency and packaging comparison
|
|
99
|
+
|
|
100
|
+
- Ruby SQLGlot ships precompiled glibc Linux and macOS gems for x86-64 and
|
|
101
|
+
ARM64. Other platforms require Cargo and Git. The current arm64 macOS gem
|
|
102
|
+
includes a roughly 2.4 MB dylib.
|
|
103
|
+
- `pg_query` compiled a roughly 3.4 MB native extension locally and embeds the
|
|
104
|
+
real PostgreSQL parser.
|
|
105
|
+
- `sqlparser-rs` produced a roughly 7.1 MB standalone release executable in the
|
|
106
|
+
spike. A production choice would require a maintained Ruby extension or FFI
|
|
107
|
+
boundary.
|
|
108
|
+
- Python SQLGlot adds an entire second language runtime to a Rails process or
|
|
109
|
+
requires an out-of-process service.
|
|
110
|
+
|
|
111
|
+
The Ruby SQLGlot wrapper is young. Upkeep should pin its supported version and
|
|
112
|
+
keep contract tests for the AST node shapes it consumes.
|
|
113
|
+
|
|
114
|
+
## Proposed production contract
|
|
115
|
+
|
|
116
|
+
The fallback analyzer should return one of:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
Resolved.new(tables: ["posts", "users"], coverage: :tables)
|
|
120
|
+
Unresolved.new(reason: "table-valued function account_events")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
It must never silently convert an empty or partially understood source set into
|
|
124
|
+
the model's primary table. Specifically:
|
|
125
|
+
|
|
126
|
+
- normalize `public.accounts` and `main.posts` to the table names used by
|
|
127
|
+
Active Record change events;
|
|
128
|
+
- exclude CTE and derived-table aliases with query-scope awareness;
|
|
129
|
+
- validate extracted names against tables and views in the connected schema;
|
|
130
|
+
- expand views through adapter metadata or mark them unresolved;
|
|
131
|
+
- detect table-valued functions and other source nodes that do not resolve to
|
|
132
|
+
physical tables;
|
|
133
|
+
- mark every SQLGlot-derived collection non-appendable; and
|
|
134
|
+
- always replay the whole render site for table-coverage dependencies.
|
|
135
|
+
|
|
136
|
+
This leaves refusal as a correctness outcome, not a configurable policy:
|
|
137
|
+
ordinary raw SQL works automatically, while genuinely hidden database behavior
|
|
138
|
+
is rejected rather than guessed.
|
|
139
|
+
|
|
140
|
+
## Reproduction
|
|
141
|
+
|
|
142
|
+
Each directory is intentionally isolated from production dependencies:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
# Ruby SQLGlot
|
|
146
|
+
cd docs/spikes/query-source-analysis-comparison/ruby-sqlglot
|
|
147
|
+
mise exec -- ruby -S bundle install
|
|
148
|
+
mise exec -- ruby -rbundler/setup run.rb
|
|
149
|
+
|
|
150
|
+
# pg_query
|
|
151
|
+
cd docs/spikes/query-source-analysis-comparison/pg-query
|
|
152
|
+
mise exec -- ruby -S bundle install
|
|
153
|
+
mise exec -- ruby -rbundler/setup run.rb
|
|
154
|
+
|
|
155
|
+
# Python SQLGlot
|
|
156
|
+
python3 -m venv /tmp/upkeep-python-sqlglot
|
|
157
|
+
/tmp/upkeep-python-sqlglot/bin/pip install -r python-sqlglot/requirements.txt
|
|
158
|
+
/tmp/upkeep-python-sqlglot/bin/python python-sqlglot/run.py
|
|
159
|
+
|
|
160
|
+
# sqlparser-rs
|
|
161
|
+
cd docs/spikes/query-source-analysis-comparison/sqlparser-rs
|
|
162
|
+
mise x rust@stable -- cargo run --release
|
|
163
|
+
|
|
164
|
+
# SQLite native approaches
|
|
165
|
+
python3 docs/spikes/query-source-analysis-comparison/explain/sqlite.py
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The PostgreSQL runner expects a clean database through `DATABASE_URL`; it
|
|
169
|
+
creates schemas, tables, a view, and two functions in that database.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SQLGlot-first Active Record analysis spike
|
|
2
|
+
|
|
3
|
+
This spike asks whether `Relation#to_sql` plus database schema metadata can replace
|
|
4
|
+
the Arel decoder. The analyzer never calls `Relation#arel` and produces the same
|
|
5
|
+
small semantic contract Upkeep needs:
|
|
6
|
+
|
|
7
|
+
- physical tables and referenced columns;
|
|
8
|
+
- simple constant predicates in DNF groups;
|
|
9
|
+
- column-equality edges (joins and correlations);
|
|
10
|
+
- limit and appendability shape.
|
|
11
|
+
|
|
12
|
+
Run:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
mise x ruby@3.4.7 -- ruby sqlglot_active_record_query_test.rb
|
|
16
|
+
mise x ruby@3.4.7 -- ruby benchmark.rb
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The important comparison is not “can SQLGlot understand every SQL node?” It is
|
|
20
|
+
whether both structured Active Record and raw SQL lower into this generic contract.
|
|
21
|
+
The tests include raw predicates, raw joins, a correlated `EXISTS`, a CTE inside a
|
|
22
|
+
derived source, and collection-shape modifiers.
|
|
23
|
+
|
|
24
|
+
The analyzer caches schema metadata by connection for the benchmark. A real
|
|
25
|
+
integration should key that cache by Active Record's schema version and clear it
|
|
26
|
+
when Rails clears its schema cache.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SQLGlot query-analysis spike
|
|
2
|
+
|
|
3
|
+
Date: 2026-07-24
|
|
4
|
+
|
|
5
|
+
## Question
|
|
6
|
+
|
|
7
|
+
Can Upkeep use the `sqlglot` Ruby gem to recover table dependencies from
|
|
8
|
+
Active Record relations whose Arel nodes contain opaque SQL strings?
|
|
9
|
+
|
|
10
|
+
## Verdict
|
|
11
|
+
|
|
12
|
+
Yes, with one important constraint: use SQLGlot's parsed AST and a small
|
|
13
|
+
Upkeep-owned recursive table extractor. Do not use
|
|
14
|
+
`Sqlglot::Query#tables` as the correctness boundary.
|
|
15
|
+
|
|
16
|
+
The parser accepted every representative PostgreSQL, MySQL, and SQLite query
|
|
17
|
+
in the original corpus. Scope-aware recursive extraction from `Table` AST
|
|
18
|
+
nodes recovered every expected source table. The follow-up corpus derived from
|
|
19
|
+
Pulse #1802 found one important PostgreSQL extension gap: pg_trgm's custom `<%`
|
|
20
|
+
operator does not parse.
|
|
21
|
+
|
|
22
|
+
## Evidence
|
|
23
|
+
|
|
24
|
+
The corpus covers:
|
|
25
|
+
|
|
26
|
+
- raw predicates and order expressions;
|
|
27
|
+
- raw joins;
|
|
28
|
+
- correlated `EXISTS` subqueries;
|
|
29
|
+
- `IN` subqueries;
|
|
30
|
+
- derived tables in `FROM` and `JOIN`;
|
|
31
|
+
- CTEs;
|
|
32
|
+
- CTEs that shadow a physical table;
|
|
33
|
+
- recursive CTEs;
|
|
34
|
+
- PostgreSQL full-text search;
|
|
35
|
+
- PostgreSQL schema-qualified sources;
|
|
36
|
+
- MySQL JSON expressions; and
|
|
37
|
+
- SQLite quoting and syntax.
|
|
38
|
+
|
|
39
|
+
Results:
|
|
40
|
+
|
|
41
|
+
| Path | Result |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| SQLGlot parsing | 12/12 query cases parsed |
|
|
44
|
+
| `Sqlglot::Query#tables` | 4 failures in the original 10-case corpus |
|
|
45
|
+
| Scope-aware AST `Table` extraction | 12/12 table sets correct |
|
|
46
|
+
| Invalid SQL | Raised `Sqlglot::ParseError` |
|
|
47
|
+
| Local parse/extract benchmark | about 60 μs/query across 10,000 iterations |
|
|
48
|
+
|
|
49
|
+
The [Pulse #1802 follow-up](PULSE_1802_FINDINGS.md) adds ten parseable,
|
|
50
|
+
production-derived cases covering JSONB operators, nested boolean ranges,
|
|
51
|
+
self-correlated and aggregate subqueries, repeated `EXISTS`, aggregate
|
|
52
|
+
`CASE`, joins, projections, and pg_search expressions. It also preserves the
|
|
53
|
+
realistic pg_search `<%` failure as an expected regression test.
|
|
54
|
+
|
|
55
|
+
The wrapper helper missed physical tables inside correlated subqueries, `IN`
|
|
56
|
+
subqueries, and derived tables. The underlying AST contained those tables in
|
|
57
|
+
regular nested `Table` nodes, so a generic recursive walk recovered them.
|
|
58
|
+
|
|
59
|
+
Run the spike:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
cd docs/spikes/sqlglot-query-analysis
|
|
63
|
+
mise exec -- ruby -S bundle install
|
|
64
|
+
mise exec -- ruby -rbundler/setup sqlglot_query_analysis_test.rb
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Proposed Upkeep boundary
|
|
68
|
+
|
|
69
|
+
Keep the existing Arel analyzer as the precise path. When it encounters an
|
|
70
|
+
opaque SQL predicate, order, join, or source:
|
|
71
|
+
|
|
72
|
+
1. Parse the relation's final SQL using the dialect selected from the Active
|
|
73
|
+
Record adapter.
|
|
74
|
+
2. Recursively collect physical `Table` nodes.
|
|
75
|
+
3. Remove CTE names from the collected set.
|
|
76
|
+
4. Register table-coverage dependencies.
|
|
77
|
+
5. Disable append, prepend, remove, and member-replace proofs.
|
|
78
|
+
6. Re-run and update the entire render site when any collected table changes.
|
|
79
|
+
|
|
80
|
+
Only table extraction should ship initially. SQLGlot exposes columns, but
|
|
81
|
+
unqualified columns, aliases, projections, and derived outputs require semantic
|
|
82
|
+
resolution. They are unnecessary for a correct conservative fallback.
|
|
83
|
+
|
|
84
|
+
## Risks
|
|
85
|
+
|
|
86
|
+
- The Ruby wrapper is new (`0.1.1`). Upkeep needs contract tests for the AST
|
|
87
|
+
node shapes it consumes.
|
|
88
|
+
- Precompiled gems currently cover glibc Linux and macOS on x86-64 and ARM64.
|
|
89
|
+
Other platforms build the Rust library from source and require Cargo and Git.
|
|
90
|
+
- A SQL parser sees named SQL sources, not tables hidden behind database views,
|
|
91
|
+
stored functions, or extensions. Those queries still need a database/schema
|
|
92
|
+
resolver or refusal.
|
|
93
|
+
- PostgreSQL permits extension-defined operators. SQLGlot 0.1.1 rejects
|
|
94
|
+
pg_trgm's `<%` word-similarity operator even though it accepts the surrounding
|
|
95
|
+
pg_search tsearch and ranking expressions.
|
|
96
|
+
- An extracted schema-qualified name must be normalized to the table names used
|
|
97
|
+
by Upkeep change events.
|
|
98
|
+
- Parser success is not proof that every named source corresponds to an
|
|
99
|
+
observed Active Record table. Upkeep must validate extracted names against
|
|
100
|
+
the connected schema before registering them.
|
|
101
|
+
|
|
102
|
+
## Recommendation
|
|
103
|
+
|
|
104
|
+
Proceed with SQLGlot as an experimental fallback analyzer behind internal code,
|
|
105
|
+
not a user-selectable policy. Keep refusal only as the terminal outcome when
|
|
106
|
+
parsing fails or source names cannot be reconciled with Upkeep's observed
|
|
107
|
+
schema.
|
|
108
|
+
|
|
109
|
+
Before merging production integration, expand the corpus with SQL captured from
|
|
110
|
+
the benchmark applications and run it on PostgreSQL, MySQL, and SQLite CI.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Pulse #1802 SQLGlot edge-case findings
|
|
2
|
+
|
|
3
|
+
Date: 2026-07-24
|
|
4
|
+
|
|
5
|
+
Source: [fetchly/Pulse#1802](https://github.com/fetchly/Pulse/pull/1802)
|
|
6
|
+
|
|
7
|
+
## Result
|
|
8
|
+
|
|
9
|
+
SQLGlot can eliminate most of the SQL-to-Arel and SQL-to-Ruby rewrites in this
|
|
10
|
+
PR if Upkeep accepts conservative table-level dependencies and replays the
|
|
11
|
+
whole render site.
|
|
12
|
+
|
|
13
|
+
The Ruby SQLGlot 0.1.1 parser plus the scope-aware extractor handled all ten
|
|
14
|
+
parseable Pulse-derived cases. One realistic `pg_search` query remains
|
|
15
|
+
unparseable because SQLGlot does not recognize pg_trgm's custom `<%`
|
|
16
|
+
word-similarity operator.
|
|
17
|
+
|
|
18
|
+
The same minimal `<%` query also fails with Python SQLGlot 27.20.0. PostgreSQL's
|
|
19
|
+
`@@` full-text-search operator, casts, `to_tsquery`, `ts_rank`, and the
|
|
20
|
+
`word_similarity(...)` function all parse; `<%` is the isolated failure.
|
|
21
|
+
|
|
22
|
+
Run the corpus:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
cd docs/spikes/sqlglot-query-analysis
|
|
26
|
+
mise exec -- ruby -rbundler/setup pulse_1802_edge_cases_test.rb
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Observed result:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
13 runs, 15 assertions, 0 failures, 0 errors
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The expected `<%` parse failure is asserted as part of that result.
|
|
36
|
+
|
|
37
|
+
## PR coverage
|
|
38
|
+
|
|
39
|
+
| PR query shape | Parse / table extraction | Tables recovered | What this means for Pulse |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| `Assignment.current`, `overlapping`, and `current_and_upcoming` nested date predicates | Full | `assignments` | The string predicates did not need Arel rewrites for conservative reactivity. |
|
|
42
|
+
| `Effort::Filterable.not_overdue` and `not_design_efforts_types` disjunctions | Full | `efforts` | Ordinary `NULL`, comparison, `AND`, and `OR` predicates are routine. |
|
|
43
|
+
| `Project::Staffable#requirements_change_summary` overlapping-range predicate | Full | `project_team_requirements` | This could stay in SQL instead of loading and filtering every requirement in Ruby. |
|
|
44
|
+
| `Effort::Filterable.by_blockers` self-correlated `EXISTS` with an alias | Full | `efforts` | The extractor deduplicates the outer and aliased inner physical table. |
|
|
45
|
+
| Audit JSONB containment `@>` plus `::jsonb` | Full | `audits` | The helper did not need an `Arel::Nodes::InfixOperation` for table dependency recovery. |
|
|
46
|
+
| Audit JSON extraction `-> ... IS NOT NULL` | Full | `audits` | PostgreSQL JSON operators do not block the table-level fallback. |
|
|
47
|
+
| `Effort::Filterable.over_budget` correlated `COALESCE(SUM(...), 0)` scalar subquery | Full | `efforts`, `time_logs` | Both the result table and aggregate input table are recovered. |
|
|
48
|
+
| `Milestone.status_case_sql` with `CASE`, repeated correlated `EXISTS` / `NOT EXISTS`, and `IN` | Full with custom AST walk | `milestones`, `efforts` | The original SQL can be tracked conservatively. `Sqlglot::Query#tables` alone misses `efforts`. |
|
|
49
|
+
| `active_requirements_count` aggregate `SUM(CASE ...)` over a join | Full | `project_team_requirements`, `positions` | It could remain a database aggregate; a change to either table triggers full replay. |
|
|
50
|
+
| Two-column `pluck` across a left join | Full | `assignments`, `teams` | Raw projection strings are irrelevant to table discovery. The Arel projection rewrite is not required by this fallback. |
|
|
51
|
+
| `pg_search` tsearch-only query and trigram ranking function | Full | `projects` | SQLGlot handles the functions, casts, rank expression, and `@@`. |
|
|
52
|
+
| `pg_search` word-similarity predicate using `<%` | **Parse failure** | None | Upkeep must refuse, use a PostgreSQL-specific secondary parser, or add a narrowly tested normalization for custom operators. |
|
|
53
|
+
|
|
54
|
+
## What “covered” means
|
|
55
|
+
|
|
56
|
+
Coverage here is deliberately conservative. SQLGlot proves which named tables
|
|
57
|
+
can change the result; it does not prove which individual changes alter
|
|
58
|
+
membership, ordering, aggregates, or projected values.
|
|
59
|
+
|
|
60
|
+
For every SQLGlot-derived dependency, Upkeep should therefore:
|
|
61
|
+
|
|
62
|
+
- subscribe to every recovered table;
|
|
63
|
+
- rerun the complete render site on any change to one of those tables; and
|
|
64
|
+
- disable append, prepend, remove, and member-replace optimizations.
|
|
65
|
+
|
|
66
|
+
That is enough to preserve the original SQL for the range filters, JSONB
|
|
67
|
+
predicates, correlated subqueries, status `CASE`, aggregate, and `pluck` cases
|
|
68
|
+
above. It trades precision for correctness without introducing a second
|
|
69
|
+
user-maintained policy.
|
|
70
|
+
|
|
71
|
+
## What SQLGlot does not address in the PR
|
|
72
|
+
|
|
73
|
+
- The `<%` custom PostgreSQL operator prevents parsing the complete
|
|
74
|
+
`pg_search` relation. Materializing that relation remains necessary unless
|
|
75
|
+
Upkeep adds another safe fallback.
|
|
76
|
+
- SQLGlot cannot discover physical tables hidden behind views or stored
|
|
77
|
+
functions from SQL text alone.
|
|
78
|
+
- The milestone row-target and lazy-frame subscription fixes concern render
|
|
79
|
+
capture and subscription lifetime, not query analysis.
|
|
80
|
+
- Controller runtime installation and cross-controller invalidation are
|
|
81
|
+
runtime concerns, not parser concerns.
|
|
82
|
+
- Parsing an aggregate or `pluck` does not make its result incrementally
|
|
83
|
+
patchable. It only makes full replay safe.
|
|
84
|
+
|
|
85
|
+
## Recommendation
|
|
86
|
+
|
|
87
|
+
Use SQLGlot automatically after the precise Arel analyzer fails. For this PR,
|
|
88
|
+
that would cover every query rewrite except the full pg_search predicate. Keep
|
|
89
|
+
the scope-aware AST extractor: the convenience `Query#tables` API misses the
|
|
90
|
+
inner `efforts` and `time_logs` sources in the two hardest cases.
|
|
91
|
+
|
|
92
|
+
Treat a SQLGlot parse failure as unresolved. If pg_search is important enough
|
|
93
|
+
to justify broader coverage, the next focused spike should compare
|
|
94
|
+
PostgreSQL's real parser (`pg_query`) against a generic custom-operator
|
|
95
|
+
normalization; the latter must not silently alter source structure.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Missing SQLGlot semantic bindings spike
|
|
2
|
+
|
|
3
|
+
The `sqlglot` Ruby gem 0.1.1 binds only parse, generate, transpile, and version.
|
|
4
|
+
Its underlying `protegrity/sql-glot-rust` v0.10.12 already contains semantic
|
|
5
|
+
APIs that are useful to Upkeep:
|
|
6
|
+
|
|
7
|
+
- `MappingSchema` and `qualify_columns`;
|
|
8
|
+
- `build_scope`, including physical/scoped sources, columns, external columns,
|
|
9
|
+
and correlation;
|
|
10
|
+
- output-column `lineage`.
|
|
11
|
+
|
|
12
|
+
This spike adds one JSON-over-FFI call around those APIs and a small Ruby wrapper.
|
|
13
|
+
It does not fork or modify the upstream dependency.
|
|
14
|
+
|
|
15
|
+
Build and run:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
cargo build --release
|
|
19
|
+
mise x ruby@3.4.7 -- ruby binding_test.rb
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The output-lineage test is intentional: lineage follows a selected value back to
|
|
23
|
+
its source, while Upkeep also needs filter/join/order dependencies. Scope analysis
|
|
24
|
+
contains those columns, so `scope`, not output lineage alone, is the useful base
|
|
25
|
+
for a dependency decoder.
|
|
26
|
+
|
|
27
|
+
Current v0.10.12 caveat: a CTE's child scope is present and correct, but the
|
|
28
|
+
outer source with the CTE's name is still emitted as a table source. The test
|
|
29
|
+
locks this behavior down so an upstream fix is visible.
|