schema_ferry 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a34cb64f18cd33a93ebbc3636bd1e76e170279d98ec5d9c59b8ef14e8f141fd9
4
- data.tar.gz: f3ae1fbea45f7b7f5c4d5c79e5704fcc680955a77a9123a73975618c32752b8f
3
+ metadata.gz: e954e39c0309126d404ec1a7b95cfcaf062cbdf18525e6d179e8747c96d95aaf
4
+ data.tar.gz: 29c7495b19fb18d7cb9ea1a17fb64b8778dc7deeefeeee50858b0584adc57fe8
5
5
  SHA512:
6
- metadata.gz: 9fa8d644af36d128f1922258299fc96ba682a068f1195fced8e8446e64067c63e5d5ee81b3541f07e162d0e2588495946e111ca5076da608ab9054491f9a8cdd
7
- data.tar.gz: 18dd78144c3e9ccbba232da685b0248eb22be16a73ac55090edfe6638a7d93d8ef794bcf1babad248a95f324b9bf8a3e4bac4e3db19a2b4d735d09dd45a9cffe
6
+ metadata.gz: 939a4335e6ad17b7eabbde3d4f3979e09009493cef5b474ec033cfbf853f4def56fb505d82aa9ab4366ab6ff6158196aed1f9a4ea762470cc5e3938a148f181e
7
+ data.tar.gz: d7e983858d24f45938fbda3a8c76d4a0de06d496c949a91320903b0c49b1ff440450570440e7726860e6c2158bbddec080d3a92c78918f62182f84321ad21b42
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-06
11
+
12
+ ### Removed
13
+
14
+ - `add_index` — declaring a PostgreSQL-only index existed solely as a replacement path for skipped FULLTEXT indexes, but that undermined the tool's own "faithful mirror of MySQL" design: it was the only way to declare something with no MySQL counterpart. It also had its own bug — an index with a `where:` clause was dropped and re-created on every single run, because PostgreSQL rewrites predicates (operators, casts, parenthesization) when it stores them, so a declared clause can never reliably match what's on the target. A `pg_trgm` GIN index (or similar) is still the way to replace a FULLTEXT index, but only by hand, once you're fully cut over to PostgreSQL.
15
+
16
+ ### Changed
17
+
18
+ - `FULLTEXT`/`SPATIAL` indexes and spatial columns (`POINT`, `GEOMETRY`, `POLYGON`, `LINESTRING`, …) now raise `ConversionError` instead of printing a warning and silently excluding themselves. A warning is easy to miss in an unattended cron run's output; raising fails the same way any other error does — a non-zero exit status a monitor can catch. Exclude them explicitly with `ignore_index` / `ignore_column`.
19
+
20
+ ### Fixed
21
+
22
+ - `POINT` columns are now caught and raise `ConversionError`, like other unsupported spatial types (`GEOMETRY`/`POLYGON`/`LINESTRING`/etc.), instead of silently becoming a meaningless `integer NOT NULL` column. ActiveRecord's mysql2 adapter misreports `POINT` as plain `:integer` — the string "point" happens to match an unanchored `/int/i` pattern deep in ActiveRecord's generic type map — unlike the others, which already came through as an unrecognized type and were rejected.
23
+
24
+ ## [0.1.3] - 2026-07-06
25
+
26
+ ### Fixed
27
+
28
+ - The 0.1.2 fix only covered the two reported cases, not the underlying pattern: any type/option combination that PostgreSQL can't actually reproduce back to ActiveRecord causes `apply!` to re-run `change_column` forever, because ridgepole compares the declaration against a state it can never match.
29
+ - `DOUBLE` columns (`:float`) are read with `limit: 53` (the type's internal bit width), but a PostgreSQL column never reports a limit back. `limit` is no longer emitted for `:float` columns.
30
+ - Plain `DECIMAL` columns with a default (not just ones bumped from `BIGINT UNSIGNED`) now render their default as a string, matching the form ActiveRecord's schema dumper uses for decimal defaults — the same fix from 0.1.2, generalized to `TypeMapper` so it applies to every decimal column instead of only the unsigned-bigint conversion path.
31
+
10
32
  ## [0.1.2] - 2026-07-06
11
33
 
12
34
  ### Fixed
data/README.md CHANGED
@@ -6,7 +6,7 @@ You're migrating a production MySQL database to PostgreSQL. Moving the data take
6
6
  - **Sensible defaults, fully customizable** — built-in type mappings handle most cases; override anything with a few DSL rules
7
7
  - **Safe to iterate** — `dry_run` shows the exact changes that would be applied, before touching anything
8
8
 
9
- schema_ferry is designed to run repeatedly (e.g. via cron). Data migration is out of scope — pair it with [pgloader](https://pgloader.io/) (one-shot bulk copy) or CDC replication (AWS DMS, [Debezium](https://debezium.io/), …), which load rows into the tables schema_ferry has created.
9
+ schema_ferry is designed to run repeatedly (e.g. via cron). Data migration is out of scope — pair it with [pgloader](https://github.com/dimitri/pgloader) (one-shot bulk copy) or CDC replication (AWS DMS, [Debezium](https://github.com/debezium/debezium), …), which load rows into the tables schema_ferry keeps in sync.
10
10
 
11
11
  ## Requirements
12
12
 
@@ -43,7 +43,7 @@ pipeline.apply! # applies the schema to PostgreSQL
43
43
 
44
44
  There is also `pipeline.schemafile`, which returns the generated schema as a string without connecting to the target.
45
45
 
46
- `apply!` makes the target match the generated schema — including **dropping** columns and indexes that are not part of it. Before running against a target that holds data, read [Destructive changes](#destructive-changes) below.
46
+ `apply!` makes the target match the generated schema — including **dropping** columns and indexes from the target that are not part of it. Before running against a target that holds data, read [Coverage](#coverage) below.
47
47
 
48
48
  ### CLI
49
49
 
@@ -86,12 +86,24 @@ end
86
86
 
87
87
  The same rules work in a CLI definition file.
88
88
 
89
- ### Destructive changes
89
+ ### Coverage
90
90
 
91
- `apply!` delegates to [ridgepole](https://github.com/ridgepole/ridgepole), which makes the target match the generated schema. For tables it manages, **columns and indexes that are missing from the generated schema are dropped from the target** (e.g. a column excluded via `ignore_column`, or an index created by hand on the targetdeclare those with `add_index` instead). Tables absent from the generated schema are themselves left untouched.
91
+ schema_ferry syncs what can be done automatically exactly where possible, or as an approximation with a warning where it isn't and leaves the rest to add by hand, later. Where there's no reasonable equivalent at all, it raises instead of guessing. Add the column or index by hand once you're fully cut over to PostgreSQL not before, since `apply!` delegates to [ridgepole](https://github.com/ridgepole/ridgepole), which drops anything missing from the generated schema on a managed table, including something added by hand as a stand-in — but never drops a table that's absent from it entirely.
92
92
 
93
93
  Review `dry_run` output before your first `apply!` and whenever you change the conversion rules — those are the moments that introduce drops. Unattended runs in between only mirror changes made to the MySQL schema; if even those need review, schedule `dry-run` instead and apply by hand.
94
94
 
95
+ Normalized automatically, with a warning to stderr:
96
+
97
+ - **Index prefix lengths** (`KEY (col(10))`) are dropped silently — PostgreSQL indexes the full column.
98
+ - **Identifiers over 63 bytes** (MySQL allows 64): index and foreign key names are shortened deterministically (`first 54 bytes + _ + 8-char digest`), so repeated runs stay stable. Overlong table names are only warned about — rename those yourself.
99
+ - **Zero-date defaults** (`'0000-00-00 00:00:00'`) are invalid in PostgreSQL and are dropped.
100
+ - **BIGINT UNSIGNED columns on a foreign key** (either side) become signed `bigint` instead of `numeric(20)` — a numeric column cannot reference a bigint primary key. Values above 2⁶³−1 will not fit, the same trade-off as for `BIGINT UNSIGNED` primary keys.
101
+
102
+ Raises instead:
103
+
104
+ - **FULLTEXT indexes** — PostgreSQL has no equivalent construct (a `pg_trgm` GIN index is a common approximation, but it's not the same search semantics, so schema_ferry doesn't create one for you). Because of the drop behavior above, you can't pre-create a replacement during the sync period — add one once you're fully cut over to PostgreSQL. `ignore_index` them.
105
+ - **Spatial columns** (`POINT`, `GEOMETRY`, `POLYGON`, `LINESTRING`, …) — PostgreSQL has no built-in equivalent without PostGIS, which schema_ferry does not manage. `ignore_column` them.
106
+
95
107
  ## DSL reference
96
108
 
97
109
  ### Top-level
@@ -113,7 +125,6 @@ Review `dry_run` output before your first `apply!` and whenever you change the c
113
125
  | `map_column :col, type: :type, default: value` | …and give it an explicit default |
114
126
  | `ignore_column :col` | Exclude a column |
115
127
  | `ignore_index :index_name` | Exclude an index |
116
- | `add_index :col, ...options` | Declare a PostgreSQL-only index (options: `name`, `unique`, `using`, `opclass`, `where`, `order`) |
117
128
 
118
129
  Ignoring a column also drops indexes and foreign keys that reference it. Renaming tables or columns is out of scope — clean up names after the cutover with a regular migration.
119
130
 
@@ -128,7 +139,7 @@ Ignoring a column also drops indexes and foreign keys that reference it. Renamin
128
139
  | `TINYINT(1)` | `boolean` | see the caveat above if a column holds more than 0/1 |
129
140
  | `TINYINT`…`BIGINT` (signed) | `smallint` / `integer` / `bigint` | widths normalized to PostgreSQL's three integer sizes |
130
141
  | `TINYINT`…`INT` `UNSIGNED` | one size larger | e.g. `INT UNSIGNED` → `bigint` |
131
- | `BIGINT UNSIGNED` | `numeric(20)` | PostgreSQL has no unsigned 8-byte integer; emitted with a warning. Columns on a foreign key become signed `bigint` instead — see below |
142
+ | `BIGINT UNSIGNED` | `numeric(20)` | PostgreSQL has no unsigned 8-byte integer; emitted with a warning. Columns on a foreign key become signed `bigint` instead — see above |
132
143
  | `FLOAT` / `DOUBLE` | `double precision` | |
133
144
  | `DECIMAL(p,s)` | `numeric(p,s)` | |
134
145
  | `DATETIME` / `TIMESTAMP` | `timestamp` | use `map_type :datetime, to: :timestamptz` for `timestamptz` |
@@ -139,16 +150,6 @@ Ignoring a column also drops indexes and foreign keys that reference it. Renamin
139
150
 
140
151
  `map_type` / `map_column` take Rails-style abstract type symbols (`:string`, `:integer`, `:jsonb`, …), not raw SQL type names.
141
152
 
142
- ### Automatic adjustments (with warnings)
143
-
144
- Some MySQL constructs have no PostgreSQL equivalent. schema_ferry handles them and prints a `[schema_ferry]` warning to stderr:
145
-
146
- - **FULLTEXT / SPATIAL indexes** are skipped. Declare a replacement with `add_index` (e.g. `add_index :body, using: :gin, opclass: :gin_trgm_ops` — requires `CREATE EXTENSION pg_trgm` on the target, done once by hand) and silence the warning with `ignore_index`. Don't create replacement indexes by hand: anything not in the generated schema is dropped on the next run.
147
- - **Index prefix lengths** (`KEY (col(10))`) are dropped silently — PostgreSQL indexes the full column.
148
- - **Identifiers over 63 bytes** (MySQL allows 64): index and foreign key names are shortened deterministically (`first 54 bytes + _ + 8-char digest`), so repeated runs stay stable. Overlong table names are only warned about — rename those yourself.
149
- - **Zero-date defaults** (`'0000-00-00 00:00:00'`) are invalid in PostgreSQL and are dropped.
150
- - **BIGINT UNSIGNED columns on a foreign key** (either side) become signed `bigint` instead of `numeric(20)` — a numeric column cannot reference a bigint primary key. Values above 2⁶³−1 will not fit, the same trade-off as for `BIGINT UNSIGNED` primary keys.
151
-
152
153
  ## How it works
153
154
 
154
155
  Each run executes a three-stage pipeline:
@@ -45,11 +45,7 @@ module SchemaFerry
45
45
  else
46
46
  emit_warning "column #{raw[:name].inspect}: BIGINT UNSIGNED has no PostgreSQL " \
47
47
  "integer equivalent; mapped to decimal(20, 0)."
48
- # AR's schema dumper renders decimal defaults as a string (e.g.
49
- # `default: "0"`), not a numeric literal — match that representation
50
- # or ridgepole re-applies the default on every run.
51
- new_default = raw[:default]&.to_s
52
- raw.merge(type: :decimal, limit: nil, precision: 20, scale: 0, default: new_default)
48
+ raw.merge(type: :decimal, limit: nil, precision: 20, scale: 0)
53
49
  end
54
50
  end
55
51
 
@@ -8,6 +8,21 @@ module SchemaFerry
8
8
  # PostgreSQL has no FULLTEXT/SPATIAL equivalent that ridgepole can express.
9
9
  UNSUPPORTED_INDEX_TYPES = %i[fulltext spatial].freeze
10
10
 
11
+ # MySQL spatial column types have no PostgreSQL equivalent (that would
12
+ # require PostGIS, which schema_ferry does not manage). Most of these
13
+ # already raise TypeMapper's ConversionError, since ActiveRecord's
14
+ # mysql2 adapter reports them as an unrecognized type (nil). POINT is
15
+ # the sole exception: AR matches sql_type against an unanchored /int/i
16
+ # regex (see register_class_with_limit in
17
+ # ActiveRecord::ConnectionAdapters::AbstractAdapter#initialize_type_map),
18
+ # which matches "point" as a substring — so it's misreported as plain
19
+ # :integer instead. Without this check it would sail through as a
20
+ # meaningless integer column instead of raising. It must be caught
21
+ # here, by sql_type, and raised explicitly — the same failure mode as
22
+ # any other unsupported type, not a warning that's easy to miss in a
23
+ # cron log.
24
+ MISDETECTED_SPATIAL_SQL_TYPE = /\Apoint\b/i
25
+
11
26
  def initialize(config)
12
27
  @column_converter = ColumnConverter.new(TypeMapper.new(config.global_type_overrides))
13
28
  @table_rules = config.table_rules
@@ -82,47 +97,46 @@ module SchemaFerry
82
97
  def convert_columns(raw_columns, table_name, rule, ignored, fk_columns)
83
98
  raw_columns
84
99
  .reject { |c| ignored.include?(c[:name]) }
85
- .map { |c| @column_converter.call(c, table_name, rule, fk_columns) }
100
+ .map do |c|
101
+ check_misdetected_spatial_type!(c, table_name, rule)
102
+ @column_converter.call(c, table_name, rule, fk_columns)
103
+ end
86
104
  end
87
105
 
88
- def convert_indexes(raw_indexes, table_name, rule, ignored)
89
- converted = raw_indexes
90
- .reject { |idx| rule&.ignored_indexes&.include?(idx[:name]) }
91
- .reject { |idx| skip_unsupported_index?(idx, table_name) }
92
- .reject { |idx| idx[:columns].intersect?(ignored) }
93
- .map { |idx| build_index_schema(idx, table_name) }
94
- converted + extra_indexes(rule, table_name)
106
+ # map_column already lets a rule override any column's type before it
107
+ # ever reaches this check (ColumnConverter#call skips TypeMapper
108
+ # entirely when an override is present), same as for any other type.
109
+ def check_misdetected_spatial_type!(raw, table_name, rule)
110
+ return unless raw[:sql_type].to_s.match?(MISDETECTED_SPATIAL_SQL_TYPE)
111
+ return if rule&.column_type_overrides&.key?(raw[:name])
112
+
113
+ raise ConversionError, "#{table_name}.#{raw[:name]}: MySQL #{raw[:sql_type]} columns have no " \
114
+ "PostgreSQL equivalent without PostGIS, which schema_ferry does not " \
115
+ "manage. Exclude it with ignore_column :#{raw[:name]}."
95
116
  end
96
117
 
97
- def extra_indexes(rule, table_name)
98
- (rule&.extra_indexes || []).map do |extra|
99
- opts = extra[:options]
100
- name = opts[:name] || "index_#{table_name}_on_#{extra[:columns].join("_")}"
101
- IndexSchema.new(
102
- name: IdentifierShortener.shorten(name.to_s, kind: "index", table: table_name),
103
- columns: extra[:columns],
104
- unique: opts[:unique],
105
- using: opts[:using],
106
- opclass: opts[:opclass],
107
- where: opts[:where],
108
- orders: opts[:order],
109
- lengths: nil
110
- )
111
- end
118
+ def convert_indexes(raw_indexes, table_name, rule, ignored)
119
+ raw_indexes
120
+ .reject { |idx| rule&.ignored_indexes&.include?(idx[:name]) }
121
+ .reject { |idx| idx[:columns].intersect?(ignored) }
122
+ .map do |idx|
123
+ check_unsupported_index_type!(idx, table_name)
124
+ build_index_schema(idx, table_name)
125
+ end
112
126
  end
113
127
 
114
128
  def convert_foreign_keys(raw)
115
129
  surviving_foreign_keys(raw).map { |fk| build_fk_schema(fk) }
116
130
  end
117
131
 
118
- def skip_unsupported_index?(idx, table_name)
119
- return false unless UNSUPPORTED_INDEX_TYPES.include?(idx[:type])
132
+ # A warning here would be easy to miss in an unattended cron run.
133
+ # Raising makes this failure show up the same way as any other error:
134
+ # a non-zero exit status a monitor can catch.
135
+ def check_unsupported_index_type!(idx, table_name)
136
+ return unless UNSUPPORTED_INDEX_TYPES.include?(idx[:type])
120
137
 
121
- emit_warning "#{table_name}: skipping #{idx[:type].to_s.upcase} index #{idx[:name].inspect} " \
122
- "(no PostgreSQL equivalent). Declare a replacement with add_index " \
123
- "(e.g. add_index :col, using: :gin, opclass: :gin_trgm_ops) and " \
124
- "silence this warning with ignore_index :#{idx[:name]}."
125
- true
138
+ raise ConversionError, "#{table_name}: #{idx[:type].to_s.upcase} index #{idx[:name].inspect} " \
139
+ "has no PostgreSQL equivalent. Exclude it with ignore_index :#{idx[:name]}."
126
140
  end
127
141
 
128
142
  def build_index_schema(raw, table_name)
@@ -131,8 +145,6 @@ module SchemaFerry
131
145
  columns: raw[:columns],
132
146
  unique: raw[:unique],
133
147
  using: raw[:using],
134
- opclass: nil, # MySQL has no operator classes
135
- where: raw[:where],
136
148
  lengths: raw[:lengths],
137
149
  orders: raw[:orders]
138
150
  )
@@ -3,9 +3,10 @@
3
3
  module SchemaFerry
4
4
  module Converter
5
5
  class TypeMapper
6
- # PG has no limit concept for text/binary/bigint; drop the MySQL-derived
7
- # limits.
8
- LIMIT_STRIPPED_TYPES = %i[text binary bigint].freeze
6
+ # PG has no limit concept for text/binary/bigint/float; drop the
7
+ # MySQL-derived limits (float's limit: 53 is DOUBLE's internal bit
8
+ # width, not something AR ever reads back from a PG column).
9
+ LIMIT_STRIPPED_TYPES = %i[text binary bigint float].freeze
9
10
 
10
11
  # PG's default timestamp precision is 6. Spelling it out makes ridgepole
11
12
  # see a diff against the PG export (which omits it) on every run.
@@ -43,8 +44,15 @@ module SchemaFerry
43
44
  pg_type, adjusted = normalize_integer(adjusted) if pg_type == :integer
44
45
  adjusted.delete(:limit) if LIMIT_STRIPPED_TYPES.include?(pg_type)
45
46
  strip_default_precision(pg_type, adjusted)
46
- # PG numeric(20) equals numeric(20,0) and is exported without scale.
47
- adjusted[:scale] = nil if pg_type == :decimal && adjusted[:scale]&.zero?
47
+ if pg_type == :decimal
48
+ # PG numeric(20) equals numeric(20,0) and is exported without scale.
49
+ adjusted[:scale] = nil if adjusted[:scale]&.zero?
50
+ # AR's schema dumper renders decimal defaults as a string (e.g.
51
+ # `default: "0"`), not a numeric literal. ridgepole compares against
52
+ # that dumped form, so a BigDecimal/Integer default never matches
53
+ # and gets re-applied on every run.
54
+ adjusted[:default] = adjusted[:default]&.to_s
55
+ end
48
56
 
49
57
  [pg_type, adjusted]
50
58
  end
@@ -6,11 +6,8 @@ module SchemaFerry
6
6
  UNSET = Object.new.freeze
7
7
  private_constant :UNSET
8
8
 
9
- EXTRA_INDEX_OPTIONS = %i[name unique using where opclass order].freeze
10
-
11
9
  attr_reader :table_name, :column_type_overrides,
12
- :column_default_overrides, :ignored_columns, :ignored_indexes,
13
- :extra_indexes
10
+ :column_default_overrides, :ignored_columns, :ignored_indexes
14
11
 
15
12
  def initialize(table_name)
16
13
  @table_name = table_name.to_s
@@ -18,7 +15,6 @@ module SchemaFerry
18
15
  @column_default_overrides = {}
19
16
  @ignored_columns = []
20
17
  @ignored_indexes = []
21
- @extra_indexes = []
22
18
  end
23
19
 
24
20
  def map_column(column_name, type:, default: UNSET)
@@ -33,19 +29,6 @@ module SchemaFerry
33
29
  def ignore_index(index_name)
34
30
  @ignored_indexes << index_name.to_s
35
31
  end
36
-
37
- # Declares a PostgreSQL-side index that does not exist in MySQL (e.g. a
38
- # pg_trgm GIN index replacing a skipped FULLTEXT index). Declared indexes
39
- # are part of the generated schema, so sync keeps them.
40
- def add_index(*columns, **options)
41
- unknown = options.keys - EXTRA_INDEX_OPTIONS
42
- unless unknown.empty?
43
- raise ConfigError, "add_index: unknown option(s) #{unknown.map(&:inspect).join(", ")} " \
44
- "(allowed: #{EXTRA_INDEX_OPTIONS.map(&:inspect).join(", ")})"
45
- end
46
-
47
- @extra_indexes << { columns: columns.map(&:to_s), options: options }
48
- end
49
32
  end
50
33
  end
51
34
  end
@@ -32,8 +32,6 @@ module SchemaFerry
32
32
  :columns, # Array<String>
33
33
  :unique, # Boolean
34
34
  :using, # Symbol | nil
35
- :opclass, # Symbol | Hash | nil (PostgreSQL operator class, e.g. :gin_trgm_ops)
36
- :where, # String | nil
37
35
  :lengths, # Integer | Hash | nil
38
36
  :orders, # Symbol | Hash | nil
39
37
  keyword_init: true
@@ -64,7 +64,6 @@ module SchemaFerry
64
64
  unique: idx.unique,
65
65
  using: idx.using,
66
66
  type: idx.type, # :fulltext | :spatial | nil
67
- where: idx.where,
68
67
  lengths: idx.lengths.presence,
69
68
  orders: idx.orders.presence
70
69
  }
@@ -113,8 +113,6 @@ module SchemaFerry
113
113
  parts = ["name: #{idx.name.inspect}"]
114
114
  parts << "unique: true" if idx.unique
115
115
  parts << "using: #{idx.using.inspect}" if idx.using
116
- parts << "opclass: #{idx.opclass.inspect}" if idx.opclass
117
- parts << "where: #{idx.where.inspect}" if idx.where
118
116
  parts << "order: #{idx.orders.inspect}" if idx.orders
119
117
  " t.index #{cols}, #{parts.join(", ")}"
120
118
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaFerry
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_ferry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyuuri1791