drive 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +13 -3
- data/MIT-LICENSE +20 -0
- data/README.md +68 -48
- data/app/controllers/concerns/rate_limited.rb +9 -0
- data/app/controllers/feeds_controller.rb +25 -0
- data/app/models/access_file.rb +47 -0
- data/app/models/change.rb +19 -0
- data/app/models/concerns/logged.rb +26 -0
- data/app/models/concerns/mirrored.rb +99 -0
- data/app/models/drive/current.rb +7 -0
- data/app/models/run.rb +36 -0
- data/db/migrate/20261005100000_create_runs_and_changes.rb +28 -0
- data/lib/drive/engine.rb +7 -0
- data/lib/drive/test_helper.rb +20 -0
- data/lib/drive/version.rb +4 -0
- data/lib/drive.rb +7 -3
- metadata +31 -72
- data/CLAUDE.md +0 -690
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -32
- data/STYLE.md +0 -435
- data/app/controllers/recourses_controller.rb +0 -96
- data/app/javascript/recourse/phone_controller.js +0 -33
- data/app/views/layouts/application.html.erb +0 -69
- data/app/views/recourses/_breadcrumb.html.erb +0 -18
- data/app/views/recourses/_combobox.html.erb +0 -23
- data/app/views/recourses/_fields.html.erb +0 -3
- data/app/views/recourses/_flash.html.erb +0 -13
- data/app/views/recourses/_form.html.erb +0 -9
- data/app/views/recourses/_none.html.erb +0 -1
- data/app/views/recourses/_row.html.erb +0 -4
- data/app/views/recourses/_sidebar.html.erb +0 -10
- data/app/views/recourses/_table.html.erb +0 -30
- data/app/views/recourses/edit.html.erb +0 -3
- data/app/views/recourses/index.html.erb +0 -14
- data/app/views/recourses/new.html.erb +0 -3
- data/lib/recourse/controllers.rb +0 -13
- data/lib/recourse/engine.rb +0 -27
- data/lib/recourse/helpers/cells.rb +0 -44
- data/lib/recourse/helpers/comboboxes.rb +0 -33
- data/lib/recourse/helpers/constraints.rb +0 -94
- data/lib/recourse/helpers/examples.rb +0 -35
- data/lib/recourse/helpers/fields.rb +0 -54
- data/lib/recourse/helpers/navigation.rb +0 -68
- data/lib/recourse/helpers/references.rb +0 -89
- data/lib/recourse/helpers.rb +0 -60
- data/lib/recourse/icons.rb +0 -21
- data/lib/recourse/recoursive.rb +0 -19
- data/lib/recourse/routes.rb +0 -14
- data/lib/recourse/version.rb +0 -4
- data/lib/recourse.rb +0 -31
- data/vendor/recourse/bootstrap-icons.min.css +0 -5
- data/vendor/recourse/bootstrap.bundle.min.js +0 -9
- data/vendor/recourse/bootstrap.min.css +0 -2
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +0 -2563
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5bcf5e0361ac44acb57bedda1f651a948abd3ec6bc43cd214080556a142d4b72
|
|
4
|
+
data.tar.gz: 8c0e23b04b48848ee631f8d08a8f2c97cf15263a4ac5c4c6f58c14654de6fe1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a671f1e9f52a5a4422cbef14c40ab75d968be2bfe3423b9fccf3c225b6fd0227b5bfe8c301d9acd49036433b4e8efbf389a8b3068492f918aaff9b8139c15bd
|
|
7
|
+
data.tar.gz: de0351f9c2748aa969ef3fb1e2fbbf027dec38d689b733911f366f4d6e5b1e1f87ec6c7beaf365fa1cd2ab24bbd72f8e7ff42563964e37ebbbabeefd71b780cb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
For more information about changelogs, check [Keep a Changelog](http://keepachangelog.com).
|
|
6
|
+
|
|
1
7
|
## [Unreleased]
|
|
2
8
|
|
|
3
|
-
##
|
|
9
|
+
## 0.2.0 - 2026-10-05
|
|
10
|
+
|
|
11
|
+
* [BREAKING] `drive` is a new gem: a Rails engine for apps that mirror what a source publishes
|
|
4
12
|
|
|
5
|
-
|
|
6
|
-
|
|
13
|
+
The name was once a gem of resource screens, which went on as `recourse`; nothing of it is
|
|
14
|
+
left. This one keeps a copy of a source's tables (`Mirrored`, `Logged`), records each refresh
|
|
15
|
+
(`Run`) and every row it changed (`Change`), and answers them as a feed (`FeedsController`).
|
|
16
|
+
`AccessFile` names an Access table's own `ID` column `access_id`, apart from the row's id.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Claudio Baccigalupo
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,77 +1,97 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Drive
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A Rails engine for an app that keeps a copy of what somebody else publishes — a government's
|
|
4
|
+
files, a monthly database — and tells another app exactly which rows changed.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
- **`Mirrored`**: a table copying one of the source's tables field for field, as text. A refresh
|
|
7
|
+
stages the source's table, then inserts, updates and deletes only the rows the source did.
|
|
8
|
+
- **`Logged`**: a table written a record at a time, such as a document fetched by URL.
|
|
9
|
+
- **`Run`**: one refresh, in one transaction, the only one running.
|
|
10
|
+
- **`Change`**: every row a run inserted, updated or deleted, with the row as it is now.
|
|
11
|
+
- **`FeedsController`**: `GET /feed?after=ID&kind=TABLE`, the next 5,000 changes, as JSON.
|
|
12
|
+
- **`AccessFile`**: stages a table of a Microsoft Access file, read with `mdb-export`.
|
|
13
|
+
- **`RateLimited`**: 20 requests a minute and 200 a day per address, for public pages.
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
> work; `show`, `destroy` and the eject generator are not implemented yet.
|
|
15
|
+
It needs PostgreSQL.
|
|
11
16
|
|
|
12
|
-
##
|
|
17
|
+
## How to install
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
```bash
|
|
20
|
+
gem install drive
|
|
21
|
+
```
|
|
15
22
|
|
|
16
23
|
```ruby
|
|
17
|
-
|
|
24
|
+
# Gemfile
|
|
25
|
+
gem 'drive', '~> 0.2.0'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`~> 0.2.0` takes every 0.2.x and stops short of 0.3, which may break what 0.2 does.
|
|
29
|
+
|
|
30
|
+
Then copy its migration, of runs and changes, and run it:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bin/rails drive:install:migrations db:migrate
|
|
18
34
|
```
|
|
19
35
|
|
|
20
|
-
|
|
36
|
+
## How to use
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
A mirrored table has the source's fields as text columns, plus `source`, `row_key` and
|
|
39
|
+
`row_hash`, unique on `source` and `row_key`. Its model says which fields tell a row apart:
|
|
23
40
|
|
|
24
41
|
```ruby
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
class Engine < ApplicationRecord
|
|
43
|
+
include Mirrored
|
|
44
|
+
|
|
45
|
+
KEY = %w[code].freeze # [] where the source names none: a row is then told apart by all it says
|
|
28
46
|
end
|
|
29
47
|
```
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
A refresh stages the source's table and mirrors it, inside a run:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Run.record 'engines' do
|
|
53
|
+
staged = AccessFile.new('ENGINE.accdb').stage('ENGINE', Engine.connection)
|
|
54
|
+
Engine.mirror staged, source: 'ENGINE'
|
|
55
|
+
end
|
|
56
|
+
```
|
|
33
57
|
|
|
34
|
-
|
|
35
|
-
and Recourse leaves it alone; add `app/views/contacts/index.html.erb` and Rails
|
|
36
|
-
renders yours instead of the one the gem ships.
|
|
58
|
+
A table written a record at a time names its key column and where its records come from:
|
|
37
59
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
```ruby
|
|
61
|
+
class Publication < ApplicationRecord
|
|
62
|
+
include Logged
|
|
41
63
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<th scope='col'>Contact</th>
|
|
46
|
-
<% else %>
|
|
47
|
-
<td data-cell='Contact'><%= recourse.name %></td>
|
|
48
|
-
<% end %>
|
|
64
|
+
KEY = 'document_number'.freeze
|
|
65
|
+
SOURCE = 'federal_register'.freeze
|
|
66
|
+
end
|
|
49
67
|
```
|
|
50
68
|
|
|
51
|
-
The
|
|
52
|
-
record, then once per record with `heading: false`. It builds the cells only —
|
|
53
|
-
the table, the pagination and the layout stay Recourse's.
|
|
69
|
+
The feed is one line in `config/routes.rb`:
|
|
54
70
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
title with `content_for :title`, so put `yield :title` in that layout's `<title>`
|
|
59
|
-
to see it.
|
|
71
|
+
```ruby
|
|
72
|
+
resource :feed, only: :show
|
|
73
|
+
```
|
|
60
74
|
|
|
61
|
-
|
|
75
|
+
Where `FEED_TOKEN` is set, it asks for that token (`Authorization: Bearer …`). Runs commit one
|
|
76
|
+
at a time, so a reader that remembers the last id it applied never misses a change.
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
The log is meant to hold about a month: call `Change.prune` after each night's refresh, and it
|
|
79
|
+
deletes the changes of runs that started over 30 days ago. A reader that falls further behind
|
|
80
|
+
copies the tables afresh.
|
|
66
81
|
|
|
67
|
-
|
|
82
|
+
In tests, `Drive::TestHelper#stage` stages rows as a source's file would:
|
|
68
83
|
|
|
69
|
-
|
|
84
|
+
```ruby
|
|
85
|
+
ActiveSupport.on_load(:active_support_test_case) { include Drive::TestHelper }
|
|
86
|
+
```
|
|
70
87
|
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
bin/setup # the bundle, and the dummy app's databases
|
|
92
|
+
bundle exec rake # the suite at 100% line coverage, and RuboCop
|
|
93
|
+
```
|
|
73
94
|
|
|
74
95
|
## License
|
|
75
96
|
|
|
76
|
-
|
|
77
|
-
[MIT License](https://opensource.org/licenses/MIT).
|
|
97
|
+
[MIT](MIT-LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The limits of a page anybody may ask for, by the address asking: 20 a minute, 200 a day.
|
|
2
|
+
module RateLimited
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
rate_limit to: 20, within: 1.minute, name: 'minute'
|
|
7
|
+
rate_limit to: 200, within: 1.day, name: 'day'
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# The changes after one another app has read, a page at a time, for it to keep its copy current.
|
|
2
|
+
class FeedsController < ApplicationController
|
|
3
|
+
# How many changes a page holds.
|
|
4
|
+
PAGE = 5_000
|
|
5
|
+
|
|
6
|
+
# The fields of a change the feed answers.
|
|
7
|
+
FIELDS = %w[id operation kind source row_key record created_at].freeze
|
|
8
|
+
|
|
9
|
+
before_action :authenticate, if: -> { ENV['FEED_TOKEN'].present? }
|
|
10
|
+
|
|
11
|
+
# Answers the changes after `after`, of one `kind` (table) or all, as JSON.
|
|
12
|
+
def show
|
|
13
|
+
changes = Change.after(params[:after]).limit PAGE
|
|
14
|
+
changes = changes.where(kind: params[:kind]) if params[:kind]
|
|
15
|
+
render json: changes.map { it.attributes.slice(*FIELDS) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def authenticate
|
|
21
|
+
authenticate_or_request_with_http_token do |token|
|
|
22
|
+
ActiveSupport::SecurityUtils.secure_compare token, ENV.fetch('FEED_TOKEN')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require 'csv'
|
|
2
|
+
|
|
3
|
+
# A Microsoft Access database, as DRS and the NTSB publish theirs, read with mdb-export (mdbtools).
|
|
4
|
+
class AccessFile
|
|
5
|
+
# The mdb-export command, writing dates with their century, which it leaves out by default.
|
|
6
|
+
EXPORT = ['mdb-export', '-D', '%Y-%m-%d', '-T', '%Y-%m-%d %H:%M:%S'].freeze
|
|
7
|
+
|
|
8
|
+
# @param path [String] .accdb or .mdb file.
|
|
9
|
+
def initialize(path)
|
|
10
|
+
@path = path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Copies a table into a temporary one of text columns, dropped when the transaction commits:
|
|
14
|
+
# "AD Number" is ad_number, and the table's own id is access_id.
|
|
15
|
+
# @param table [String] table name in the Access file, such as AD_DATA or events.
|
|
16
|
+
# @param connection [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter] inside a transaction.
|
|
17
|
+
# @return [String] temporary table.
|
|
18
|
+
def stage(table, connection)
|
|
19
|
+
staged = "staged_#{column table}"
|
|
20
|
+
IO.popen [*EXPORT, @path, table], 'rb' do |io|
|
|
21
|
+
columns = CSV.parse_line(io.gets.force_encoding(Encoding::UTF_8)).map { column it }
|
|
22
|
+
connection.execute "DROP TABLE IF EXISTS #{staged}"
|
|
23
|
+
connection.execute <<~SQL.squish
|
|
24
|
+
CREATE TEMP TABLE #{staged} (#{columns.map { %("#{it}" text) }.join ', '}) ON COMMIT DROP
|
|
25
|
+
SQL
|
|
26
|
+
copy io, staged, connection.raw_connection
|
|
27
|
+
end
|
|
28
|
+
staged
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def column(name)
|
|
34
|
+
column = name.strip.downcase.gsub(/[^a-z0-9]+/, '_').delete_prefix('_').delete_suffix('_')
|
|
35
|
+
# An Access table's own counter, named apart from the id Rails gives every row.
|
|
36
|
+
column == 'id' ? 'access_id' : column
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def copy(io, staged, raw)
|
|
40
|
+
raw.copy_data "COPY #{staged} FROM STDIN WITH (FORMAT csv)" do
|
|
41
|
+
while (chunk = io.read(1 << 20))
|
|
42
|
+
# Some narratives carry null bytes, which PostgreSQL cannot keep in text.
|
|
43
|
+
raw.put_copy_data chunk.delete("\x00")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# A row a run inserted, changed or deleted, which another app follows by id to keep its copy.
|
|
2
|
+
class Change < ApplicationRecord
|
|
3
|
+
enum :operation, %w[upsert delete].index_by(&:itself), prefix: true
|
|
4
|
+
|
|
5
|
+
belongs_to :run
|
|
6
|
+
|
|
7
|
+
# The changes after one another app has already read, in the order they were made.
|
|
8
|
+
scope :after, ->(id) { where(id: (id.to_i + 1)..).order(:id) }
|
|
9
|
+
|
|
10
|
+
# Deletes the changes of runs that started before a time, so the log holds about a month.
|
|
11
|
+
# @param before [Time] start of the oldest run whose changes are kept.
|
|
12
|
+
# @return [Integer] changes deleted.
|
|
13
|
+
def self.prune(before: 30.days.ago)
|
|
14
|
+
where(run_id: Run.where(started_at: ...before).select(:id)).delete_all
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] operation, table and row key, as the changes' pages name a change.
|
|
18
|
+
def to_s = "#{operation} #{kind} #{row_key}"
|
|
19
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# A table written a record at a time, each save and delete logged as a change of the current run.
|
|
2
|
+
module Logged
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
after_save :log_upsert, if: :saved_changes?
|
|
7
|
+
after_destroy :log_delete
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def log_upsert
|
|
13
|
+
log :upsert, attributes.except('id', 'created_at', 'updated_at')
|
|
14
|
+
Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_delete
|
|
18
|
+
log :delete, nil
|
|
19
|
+
Run.current.tally deleted: 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def log(operation, record)
|
|
23
|
+
Change.create! operation:, kind: self.class.table_name, source: self.class::SOURCE,
|
|
24
|
+
row_key: self[self.class::KEY], run: Run.current, record:
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# A table copying a source's table field for field as text, changing only the rows the source did.
|
|
2
|
+
module Mirrored
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
# The columns this app adds to what the source published.
|
|
6
|
+
OWN = %w[id source row_key row_hash created_at updated_at].freeze
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
# A source's own `type` column is data, not Rails' single-table inheritance.
|
|
10
|
+
self.inheritance_column = nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
# Brings the rows from one source in line with a table staged from it, in the current run.
|
|
15
|
+
# @param staged [String] temporary table holding what the source publishes.
|
|
16
|
+
# @param source [String] file or document type the rows come from.
|
|
17
|
+
# @return [Hash{Symbol => Integer}] how many rows were inserted, updated and deleted.
|
|
18
|
+
def mirror(staged, source:)
|
|
19
|
+
stage_incoming staged
|
|
20
|
+
counts = {
|
|
21
|
+
inserted: log_inserted(source), updated: log_updated(source), deleted: log_deleted(source),
|
|
22
|
+
}
|
|
23
|
+
apply source
|
|
24
|
+
counts.tap { Run.current.tally(**it) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def fields = column_names - OWN
|
|
30
|
+
|
|
31
|
+
def stage_incoming(staged)
|
|
32
|
+
present = connection.columns(staged).map(&:name)
|
|
33
|
+
picked = fields.map { |field| present.include?(field) ? %(s."#{field}") : 'NULL::text' }
|
|
34
|
+
hash = "md5(ROW(#{picked.join ', '})::text)"
|
|
35
|
+
keys = self::KEY.map { %(s."#{it}") }
|
|
36
|
+
connection.execute 'DROP TABLE IF EXISTS mirror_incoming'
|
|
37
|
+
connection.execute <<~SQL.squish
|
|
38
|
+
CREATE TEMP TABLE mirror_incoming ON COMMIT DROP AS
|
|
39
|
+
SELECT DISTINCT ON (row_key) * FROM (
|
|
40
|
+
SELECT #{picked.zip(fields).map { |value, field| %(#{value} AS "#{field}") }.join ', '},
|
|
41
|
+
#{keys.any? ? "concat_ws('|', #{keys.join ', '})" : hash} AS row_key,
|
|
42
|
+
#{hash} AS row_hash
|
|
43
|
+
FROM #{staged} s
|
|
44
|
+
) rows ORDER BY row_key, row_hash
|
|
45
|
+
SQL
|
|
46
|
+
connection.execute 'CREATE INDEX ON mirror_incoming (row_key)'
|
|
47
|
+
connection.execute 'ANALYZE mirror_incoming'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def log_inserted(source)
|
|
51
|
+
log "'upsert'", "to_jsonb(i) - 'row_key' - 'row_hash'", source, <<~SQL.squish
|
|
52
|
+
FROM mirror_incoming i
|
|
53
|
+
WHERE NOT EXISTS (SELECT 1 FROM #{quoted_table_name} t
|
|
54
|
+
WHERE t.source = #{connection.quote source} AND t.row_key = i.row_key)
|
|
55
|
+
SQL
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def log_updated(source)
|
|
59
|
+
log "'upsert'", "to_jsonb(i) - 'row_key' - 'row_hash'", source, <<~SQL.squish
|
|
60
|
+
FROM mirror_incoming i JOIN #{quoted_table_name} t USING (row_key)
|
|
61
|
+
WHERE t.source = #{connection.quote source} AND t.row_hash <> i.row_hash
|
|
62
|
+
SQL
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def log_deleted(source)
|
|
66
|
+
log "'delete'", 'NULL', source, <<~SQL.squish
|
|
67
|
+
FROM #{quoted_table_name} i
|
|
68
|
+
WHERE i.source = #{connection.quote source}
|
|
69
|
+
AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = i.row_key)
|
|
70
|
+
SQL
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def log(operation, record, source, from)
|
|
74
|
+
connection.update <<~SQL.squish
|
|
75
|
+
INSERT INTO changes (operation, kind, source, row_key, run_id, record, created_at)
|
|
76
|
+
SELECT #{operation}, #{connection.quote table_name}, #{connection.quote source}, i.row_key,
|
|
77
|
+
#{Run.current.id}, #{record}, now()
|
|
78
|
+
#{from}
|
|
79
|
+
SQL
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply(source)
|
|
83
|
+
quoted = fields.map { connection.quote_column_name it }
|
|
84
|
+
connection.delete <<~SQL.squish
|
|
85
|
+
DELETE FROM #{quoted_table_name} t WHERE t.source = #{connection.quote source}
|
|
86
|
+
AND NOT EXISTS (SELECT 1 FROM mirror_incoming n WHERE n.row_key = t.row_key)
|
|
87
|
+
SQL
|
|
88
|
+
connection.execute <<~SQL.squish
|
|
89
|
+
INSERT INTO #{quoted_table_name} AS t
|
|
90
|
+
(source, row_key, row_hash, #{quoted.join ', '}, created_at, updated_at)
|
|
91
|
+
SELECT #{connection.quote source}, row_key, row_hash, #{quoted.join ', '}, now(), now()
|
|
92
|
+
FROM mirror_incoming
|
|
93
|
+
ON CONFLICT (source, row_key) DO UPDATE SET row_hash = excluded.row_hash,
|
|
94
|
+
#{quoted.map { "#{it} = excluded.#{it}" }.join ', '}, updated_at = now()
|
|
95
|
+
WHERE t.row_hash <> excluded.row_hash
|
|
96
|
+
SQL
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/app/models/run.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# One refresh of a source: when it started and finished, and how many rows it changed.
|
|
2
|
+
class Run < ApplicationRecord
|
|
3
|
+
# The lock every refresh takes, so runs commit one at a time and their changes in id order.
|
|
4
|
+
LOCK = 1_729_001
|
|
5
|
+
|
|
6
|
+
# Named apart from Active Record’s own `changes`, which says what an unsaved record changed.
|
|
7
|
+
has_many :row_changes, class_name: 'Change', dependent: :delete_all
|
|
8
|
+
|
|
9
|
+
# Refreshes a source inside one transaction, the only one running, recording what it changed.
|
|
10
|
+
# @param source [String] what is refreshed, such as "registry".
|
|
11
|
+
# @return [Run] finished run.
|
|
12
|
+
def self.record(source)
|
|
13
|
+
transaction do
|
|
14
|
+
connection.execute "SELECT pg_advisory_xact_lock(#{LOCK})"
|
|
15
|
+
run = create! source:, started_at: Time.current
|
|
16
|
+
Drive::Current.set(run:) { yield run }
|
|
17
|
+
run.tap { it.update! finished_at: Time.current }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Run] run of the refresh at hand.
|
|
22
|
+
def self.current = Drive::Current.run
|
|
23
|
+
|
|
24
|
+
# Adds what a table's refresh did to this run's counts.
|
|
25
|
+
# @param inserted [Integer] rows inserted.
|
|
26
|
+
# @param updated [Integer] rows updated.
|
|
27
|
+
# @param deleted [Integer] rows deleted.
|
|
28
|
+
def tally(inserted: 0, updated: 0, deleted: 0)
|
|
29
|
+
self.inserted += inserted
|
|
30
|
+
self.updated += updated
|
|
31
|
+
self.deleted += deleted
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] source and start, as the runs' table names a run.
|
|
35
|
+
def to_s = "#{source} #{started_at.to_fs :db}"
|
|
36
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Each refresh of a source (a run), and every row it inserted, changed or deleted (a change),
|
|
2
|
+
# which is the feed another app follows by the change's id.
|
|
3
|
+
class CreateRunsAndChanges < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_enum :operation, %w[ upsert delete ]
|
|
6
|
+
|
|
7
|
+
create_table :runs do |t|
|
|
8
|
+
t.text :source, null: false
|
|
9
|
+
t.integer :inserted, default: 0, null: false
|
|
10
|
+
t.integer :updated, default: 0, null: false
|
|
11
|
+
t.integer :deleted, default: 0, null: false
|
|
12
|
+
t.datetime :started_at, null: false
|
|
13
|
+
t.datetime :finished_at
|
|
14
|
+
t.virtual :name, type: :text, as: "source || ' #' || id", stored: true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
create_table :changes do |t|
|
|
18
|
+
t.enum :operation, enum_type: :operation, null: false
|
|
19
|
+
t.text :kind, null: false
|
|
20
|
+
t.text :source, null: false
|
|
21
|
+
t.text :row_key, null: false
|
|
22
|
+
t.references :run, null: false, foreign_key: true
|
|
23
|
+
t.jsonb :record
|
|
24
|
+
t.datetime :created_at, null: false
|
|
25
|
+
t.index %i[ kind row_key ]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/drive/engine.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Drive
|
|
2
|
+
# What a host's tests need to mirror rows without a source's file.
|
|
3
|
+
module TestHelper
|
|
4
|
+
# Stages rows as a source's file would, a text column per field, for a model to mirror.
|
|
5
|
+
# @param table [String] temporary table to create.
|
|
6
|
+
# @param rows [Array<Hash{String => String}>] the rows, each by field.
|
|
7
|
+
# @return [String] the table.
|
|
8
|
+
def stage(table, rows)
|
|
9
|
+
columns = rows.flat_map(&:keys).uniq
|
|
10
|
+
connection = ActiveRecord::Base.connection
|
|
11
|
+
connection.execute "DROP TABLE IF EXISTS #{table}"
|
|
12
|
+
connection.execute "CREATE TEMP TABLE #{table} (#{columns.map { %("#{it}" text) }.join ', '})"
|
|
13
|
+
rows.each do |row|
|
|
14
|
+
values = columns.map { connection.quote row[it] }
|
|
15
|
+
connection.execute "INSERT INTO #{table} VALUES (#{values.join ', '})"
|
|
16
|
+
end
|
|
17
|
+
table
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/drive.rb
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
require 'drive/version'
|
|
2
|
+
require 'drive/engine'
|
|
3
|
+
|
|
4
|
+
# Keeps a copy of the tables another source publishes, changing only the rows the source did,
|
|
5
|
+
# and logs every change for another app to follow (Mirrored, Logged, Run, Change, the feed).
|
|
6
|
+
module Drive
|
|
7
|
+
end
|