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 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
@@ -0,0 +1,7 @@
1
+ module Mirroring
2
+ # The run of the refresh at hand, which every change belongs to.
3
+ class Current < ActiveSupport::CurrentAttributes
4
+ # The run every change of the refresh at hand belongs to.
5
+ attribute :run
6
+ end
7
+ 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
@@ -0,0 +1,4 @@
1
+ module Mirroring
2
+ # The released version, as RubyGems lists it.
3
+ VERSION = '0.1.0'
4
+ end
data/lib/mirroring.rb ADDED
@@ -0,0 +1,8 @@
1
+ require 'mirroring/version'
2
+ require 'mirroring/macro'
3
+ require 'mirroring/engine'
4
+
5
+ # A copy of the tables a source publishes, changing only the rows the source did: `mirroring key:`
6
+ # (Mirrored), Counted and Run.
7
+ module Mirroring
8
+ end
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: []