mirroring 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/CHANGELOG.md +21 -0
- data/MIT-LICENSE +20 -0
- data/README.md +88 -0
- data/app/models/access_file.rb +47 -0
- data/app/models/concerns/counted.rb +17 -0
- data/app/models/concerns/mirrored/incoming.rb +50 -0
- data/app/models/concerns/mirrored.rb +47 -0
- data/app/models/mirroring/current.rb +7 -0
- data/app/models/run.rb +33 -0
- data/db/migrate/20261016100000_create_runs.rb +14 -0
- data/lib/mirroring/engine.rb +11 -0
- data/lib/mirroring/macro.rb +18 -0
- data/lib/mirroring/test_helper.rb +20 -0
- data/lib/mirroring/version.rb +4 -0
- data/lib/mirroring.rb +8 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0bf6fa454cebfc45cd09e99e1e757558e7aaf3507625d2de1a154800146a2e47
|
|
4
|
+
data.tar.gz: 77d14645da4d8067e4f542cafa7dd8cacb775b04352e00b3940085cb8db7e293
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cd8b7cb0b7419c2b2edb503af3730024e95ae82ec7cf7dbbdb67b5335ae02bce90029c9b8e4555bcf8de32d93640f4cbe96f5330943f26e3f6ef5981d838f834
|
|
7
|
+
data.tar.gz: 29b603ff0536109c4601b68aa7f419e0849e8318cf15669357c3fea09787c20fae50b2fb510fc3ca5b3ea2478995aa5e3f60abac0e2a22b4af94f81365c95c57
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## 0.1.0 - 2026-10-16
|
|
10
|
+
|
|
11
|
+
* [FEATURE] `mirroring key:`, `Counted`, `Run` and `AccessFile`, out of drive 0.6
|
|
12
|
+
|
|
13
|
+
A model declares `mirroring key: :n_number`, `mirroring key: %i[ev_id aircraft_key]` or
|
|
14
|
+
`mirroring key: false`, in place of drive's `include Mirrored` and its `KEY` constant. A key
|
|
15
|
+
that is neither a field, some fields nor `false` raises `ArgumentError` when the class loads.
|
|
16
|
+
`Run.record`, `Model.mirror`, `include Counted`, `AccessFile#stage` and
|
|
17
|
+
`Mirroring::TestHelper#stage` work as drive's did. A mirror counts what it inserts, updates and
|
|
18
|
+
deletes in one query rather than three.
|
|
19
|
+
|
|
20
|
+
A host that took the runs' table from drive already has it: it skips the copied
|
|
21
|
+
`CreateRuns`, or deletes it.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 easyFAA
|
|
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
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Mirroring
|
|
2
|
+
|
|
3
|
+
A Rails engine that keeps a copy of the tables a source publishes, changing only the rows the
|
|
4
|
+
source did and counting them.
|
|
5
|
+
|
|
6
|
+
- **`mirroring key:`**: a table copying one of the source's tables field for field, as text. A
|
|
7
|
+
refresh stages the source's table, then inserts, updates and deletes only the rows the source did.
|
|
8
|
+
- **`Counted`**: 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, with how many rows it
|
|
10
|
+
inserted, updated and deleted.
|
|
11
|
+
- **`AccessFile`**: stages a table of a Microsoft Access file, read with `mdb-export`.
|
|
12
|
+
|
|
13
|
+
It needs PostgreSQL.
|
|
14
|
+
|
|
15
|
+
## How to install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gem install mirroring
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Gemfile
|
|
23
|
+
gem 'mirroring', '~> 0.1.0'
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`~> 0.1.0` takes every 0.1.x and stops short of 0.2, which may break what 0.1 does.
|
|
27
|
+
|
|
28
|
+
Then copy its migration, of runs, and run it:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bin/rails mirroring:install:migrations db:migrate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## How to use
|
|
35
|
+
|
|
36
|
+
A mirrored table has the source's fields as text columns, plus `source`, `row_key` and
|
|
37
|
+
`row_hash`, unique on `source` and `row_key`. Its model says which fields tell a row apart:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
class Engine < ApplicationRecord
|
|
41
|
+
mirroring key: :code
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class Finding < ApplicationRecord
|
|
45
|
+
mirroring key: %i[ev_id aircraft_key finding_no]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class State < ApplicationRecord
|
|
49
|
+
mirroring key: false # the source names no key: a row is told apart by all it says
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A refresh stages the source's table and mirrors it, inside a run:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
Run.record 'engines' do
|
|
57
|
+
staged = AccessFile.new('ENGINE.accdb').stage('ENGINE', Engine.connection)
|
|
58
|
+
Engine.mirror staged, source: 'ENGINE'
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A table written a record at a time counts each save and delete in the run at hand:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class Publication < ApplicationRecord
|
|
66
|
+
include Counted
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Runs commit one at a time, so another app reading the tables directly sees either the last
|
|
71
|
+
run's rows or the one before's, never a mix.
|
|
72
|
+
|
|
73
|
+
In tests, `Mirroring::TestHelper#stage` stages rows as a source's file would:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
ActiveSupport.on_load(:active_support_test_case) { include Mirroring::TestHelper }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bin/setup # the bundle, and the dummy app's test database
|
|
83
|
+
bundle exec rake # the suite at 100% line coverage, RuboCop and file_length
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
[MIT](MIT-LICENSE).
|
|
@@ -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,17 @@
|
|
|
1
|
+
# A table written a record at a time, each save and delete counted in the current run.
|
|
2
|
+
module Counted
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
after_save :count_upsert, if: :saved_changes?
|
|
7
|
+
after_destroy :count_delete
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def count_upsert
|
|
13
|
+
Run.current.tally(**(previously_new_record? ? { inserted: 1 } : { updated: 1 }))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def count_delete = Run.current.tally(deleted: 1)
|
|
17
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# What a source publishes as a mirrored table keeps it, each row with its key and its hash, in a
|
|
2
|
+
# temporary table dropped when the run commits.
|
|
3
|
+
class Mirrored::Incoming
|
|
4
|
+
# The temporary table, made anew for each table mirrored.
|
|
5
|
+
TABLE = 'mirror_incoming'
|
|
6
|
+
|
|
7
|
+
# Stages the rows of a staged table, the fields a mirrored table has, one row per key.
|
|
8
|
+
# @param staged [String] temporary table holding what the source publishes.
|
|
9
|
+
# @param fields [Array<String>] the mirrored table's fields; one the source lacks is NULL.
|
|
10
|
+
# @param key [Array<String>] fields that tell one row from another; none, and it is all of them.
|
|
11
|
+
# @param connection [ActiveRecord::ConnectionAdapters::PostgreSQLAdapter] inside a transaction.
|
|
12
|
+
def initialize(staged, fields:, key:, connection:)
|
|
13
|
+
@connection = connection
|
|
14
|
+
present = connection.columns(staged).map(&:name)
|
|
15
|
+
picked = fields.map { |field| present.include?(field) ? %(s."#{field}") : 'NULL::text' }
|
|
16
|
+
hash = "md5(ROW(#{picked.join ', '})::text)"
|
|
17
|
+
keys = key.map { %(s."#{it}") }
|
|
18
|
+
create <<~SQL.squish
|
|
19
|
+
SELECT DISTINCT ON (row_key) * FROM (
|
|
20
|
+
SELECT #{picked.zip(fields).map { |value, field| %(#{value} AS "#{field}") }.join ', '},
|
|
21
|
+
#{keys.any? ? "concat_ws('|', #{keys.join ', '})" : hash} AS row_key,
|
|
22
|
+
#{hash} AS row_hash
|
|
23
|
+
FROM #{staged} s
|
|
24
|
+
) rows ORDER BY row_key, row_hash
|
|
25
|
+
SQL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param table [String] the mirrored table, quoted.
|
|
29
|
+
# @param source [String] file or document type the rows come from, quoted.
|
|
30
|
+
# @return [Hash{Symbol => Integer}] how many of the source's rows the table would insert, update
|
|
31
|
+
# and delete to match these.
|
|
32
|
+
def changes(table:, source:)
|
|
33
|
+
@connection.select_one(<<~SQL.squish).to_h { |name, count| [name.to_sym, count.to_i] }
|
|
34
|
+
SELECT count(*) FILTER (WHERE t.row_hash IS NULL) AS inserted,
|
|
35
|
+
count(*) FILTER (WHERE t.row_hash <> i.row_hash) AS updated,
|
|
36
|
+
count(*) FILTER (WHERE i.row_hash IS NULL) AS deleted
|
|
37
|
+
FROM #{TABLE} i
|
|
38
|
+
FULL JOIN (SELECT row_key, row_hash FROM #{table} WHERE source = #{source}) t USING (row_key)
|
|
39
|
+
SQL
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def create(rows)
|
|
45
|
+
@connection.execute "DROP TABLE IF EXISTS #{TABLE}"
|
|
46
|
+
@connection.execute "CREATE TEMP TABLE #{TABLE} ON COMMIT DROP AS #{rows}"
|
|
47
|
+
@connection.execute "CREATE INDEX ON #{TABLE} (row_key)"
|
|
48
|
+
@connection.execute "ANALYZE #{TABLE}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# A table copying a source's table field for field as text, changing only the rows the source did.
|
|
2
|
+
# A model declares it with `mirroring key:`, which says how rows are told apart.
|
|
3
|
+
module Mirrored
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
# The columns this gem adds to what the source published.
|
|
7
|
+
OWN = %w[id source row_key row_hash created_at updated_at].freeze
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
# A source's own `type` column is data, not Rails' single-table inheritance.
|
|
11
|
+
self.inheritance_column = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
# Brings the rows from one source in line with a table staged from it, in the current run.
|
|
16
|
+
# @param staged [String] temporary table holding what the source publishes.
|
|
17
|
+
# @param source [String] file or document type the rows come from.
|
|
18
|
+
# @return [Hash{Symbol => Integer}] how many rows were inserted, updated and deleted.
|
|
19
|
+
def mirror(staged, source:)
|
|
20
|
+
incoming = Mirrored::Incoming.new(staged, fields:, key: @mirroring_key, connection:)
|
|
21
|
+
counts = incoming.changes table: quoted_table_name, source: connection.quote(source)
|
|
22
|
+
apply source
|
|
23
|
+
counts.tap { Run.current.tally(**it) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def fields = column_names - OWN
|
|
29
|
+
|
|
30
|
+
def apply(source)
|
|
31
|
+
quoted = fields.map { connection.quote_column_name it }
|
|
32
|
+
connection.delete <<~SQL.squish
|
|
33
|
+
DELETE FROM #{quoted_table_name} t WHERE t.source = #{connection.quote source}
|
|
34
|
+
AND NOT EXISTS (SELECT 1 FROM #{Mirrored::Incoming::TABLE} n WHERE n.row_key = t.row_key)
|
|
35
|
+
SQL
|
|
36
|
+
connection.execute <<~SQL.squish
|
|
37
|
+
INSERT INTO #{quoted_table_name} AS t
|
|
38
|
+
(source, row_key, row_hash, #{quoted.join ', '}, created_at, updated_at)
|
|
39
|
+
SELECT #{connection.quote source}, row_key, row_hash, #{quoted.join ', '}, now(), now()
|
|
40
|
+
FROM #{Mirrored::Incoming::TABLE}
|
|
41
|
+
ON CONFLICT (source, row_key) DO UPDATE SET row_hash = excluded.row_hash,
|
|
42
|
+
#{quoted.map { "#{it} = excluded.#{it}" }.join ', '}, updated_at = now()
|
|
43
|
+
WHERE t.row_hash <> excluded.row_hash
|
|
44
|
+
SQL
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/app/models/run.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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.
|
|
4
|
+
LOCK = 1_729_001
|
|
5
|
+
|
|
6
|
+
# Refreshes a source inside one transaction, the only one running, recording what it changed.
|
|
7
|
+
# @param source [String] what is refreshed, such as "registry".
|
|
8
|
+
# @return [Run] finished run.
|
|
9
|
+
def self.record(source)
|
|
10
|
+
transaction do
|
|
11
|
+
connection.execute "SELECT pg_advisory_xact_lock(#{LOCK})"
|
|
12
|
+
run = create! source:, started_at: Time.current
|
|
13
|
+
Mirroring::Current.set(run:) { yield run }
|
|
14
|
+
run.tap { it.update! finished_at: Time.current }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [Run] run of the refresh at hand.
|
|
19
|
+
def self.current = Mirroring::Current.run
|
|
20
|
+
|
|
21
|
+
# Adds what a table's refresh did to this run's counts.
|
|
22
|
+
# @param inserted [Integer] rows inserted.
|
|
23
|
+
# @param updated [Integer] rows updated.
|
|
24
|
+
# @param deleted [Integer] rows deleted.
|
|
25
|
+
def tally(inserted: 0, updated: 0, deleted: 0)
|
|
26
|
+
self.inserted += inserted
|
|
27
|
+
self.updated += updated
|
|
28
|
+
self.deleted += deleted
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String] source and start, as the runs' table names a run.
|
|
32
|
+
def to_s = "#{source} #{started_at.to_fs :db}"
|
|
33
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Each refresh of a source (a run): when it started and finished, and how many rows it changed.
|
|
2
|
+
class CreateRuns < ActiveRecord::Migration[8.1]
|
|
3
|
+
def change
|
|
4
|
+
create_table :runs do |t|
|
|
5
|
+
t.text :source, null: false
|
|
6
|
+
t.integer :inserted, default: 0, null: false
|
|
7
|
+
t.integer :updated, default: 0, null: false
|
|
8
|
+
t.integer :deleted, default: 0, null: false
|
|
9
|
+
t.datetime :started_at, null: false
|
|
10
|
+
t.datetime :finished_at
|
|
11
|
+
t.virtual :name, type: :text, as: "source || ' #' || id", stored: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Mirroring
|
|
2
|
+
# Lends the host its models (Run, Mirrored, Counted, AccessFile), `mirroring key:` on every model,
|
|
3
|
+
# and the migration of runs (`bin/rails mirroring:install:migrations`).
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
engine_name 'mirroring'
|
|
6
|
+
|
|
7
|
+
initializer 'mirroring.macro' do
|
|
8
|
+
ActiveSupport.on_load(:active_record) { extend Mirroring::Macro }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Mirroring
|
|
2
|
+
# What every model can declare: `mirroring key: :n_number`.
|
|
3
|
+
module Macro
|
|
4
|
+
# Makes the model's table a mirror of a source's (Mirrored), its rows told apart by the key.
|
|
5
|
+
# @param key [Symbol, String, Array<Symbol, String>, false] field or fields that tell one row
|
|
6
|
+
# from another, or false where the source names none: a row is then told apart by all it says.
|
|
7
|
+
# @raise [ArgumentError] when the key is neither a field, some fields nor false.
|
|
8
|
+
def mirroring(key:)
|
|
9
|
+
fields = Array(key)
|
|
10
|
+
unless key == false || (fields.any? && fields.all? { it in Symbol | String })
|
|
11
|
+
raise ArgumentError, "key: is a field, some fields or false, not #{key.inspect}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
include Mirrored
|
|
15
|
+
@mirroring_key = key ? fields.map(&:to_s) : []
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Mirroring
|
|
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/mirroring.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mirroring
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- claudiob
|
|
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: '8.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '8.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: csv
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: pg
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: railties
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '8.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '8.1'
|
|
68
|
+
description: 'A Rails engine: a model declared with `mirroring key:` copies a table
|
|
69
|
+
a source publishes field for field, and each refresh inserts, updates and deletes
|
|
70
|
+
only the rows the source did, counting them in one run.'
|
|
71
|
+
email:
|
|
72
|
+
- claudiob@users.noreply.github.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- CHANGELOG.md
|
|
78
|
+
- MIT-LICENSE
|
|
79
|
+
- README.md
|
|
80
|
+
- app/models/access_file.rb
|
|
81
|
+
- app/models/concerns/counted.rb
|
|
82
|
+
- app/models/concerns/mirrored.rb
|
|
83
|
+
- app/models/concerns/mirrored/incoming.rb
|
|
84
|
+
- app/models/mirroring/current.rb
|
|
85
|
+
- app/models/run.rb
|
|
86
|
+
- db/migrate/20261016100000_create_runs.rb
|
|
87
|
+
- lib/mirroring.rb
|
|
88
|
+
- lib/mirroring/engine.rb
|
|
89
|
+
- lib/mirroring/macro.rb
|
|
90
|
+
- lib/mirroring/test_helper.rb
|
|
91
|
+
- lib/mirroring/version.rb
|
|
92
|
+
homepage: https://github.com/easyfaa/mirroring
|
|
93
|
+
licenses:
|
|
94
|
+
- MIT
|
|
95
|
+
metadata:
|
|
96
|
+
source_code_uri: https://github.com/easyfaa/mirroring
|
|
97
|
+
changelog_uri: https://github.com/easyfaa/mirroring/blob/main/CHANGELOG.md
|
|
98
|
+
documentation_uri: https://rubydoc.info/gems/mirroring
|
|
99
|
+
rdoc_options: []
|
|
100
|
+
require_paths:
|
|
101
|
+
- lib
|
|
102
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: '3.4'
|
|
107
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '0'
|
|
112
|
+
requirements: []
|
|
113
|
+
rubygems_version: 4.0.20
|
|
114
|
+
specification_version: 4
|
|
115
|
+
summary: Mirrors a source table for table, changing only the rows the source did.
|
|
116
|
+
test_files: []
|