activerecord-sqlite-types 0.2.0 → 0.3.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/.mutant.yml +18 -0
- data/CHANGELOG.md +20 -0
- data/README.md +57 -26
- data/lib/activerecord-sqlite-types.rb +4 -0
- data/lib/generators/sqlite_types/migration/migration_generator.rb +126 -0
- data/lib/generators/sqlite_types/migration/templates/migration.rb.tt +19 -0
- data/lib/sqlite_types/array.rb +98 -21
- data/lib/sqlite_types/interval.rb +7 -3
- data/lib/sqlite_types/ip_address.rb +30 -4
- data/lib/sqlite_types/migration_helpers.rb +260 -0
- data/lib/sqlite_types/version.rb +1 -1
- data/lib/sqlite_types.rb +3 -0
- data/sig/sqlite_types.rbs +29 -1
- metadata +34 -7
- data/.envrc +0 -5
- data/.standard.yml +0 -3
- data/Rakefile +0 -10
- data/devenv.lock +0 -171
- data/devenv.nix +0 -14
- data/devenv.yaml +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f959c0cd5e305d5ff04b053a280c8dc20d1f731f548c0b4625e6c6cd3db52887
|
|
4
|
+
data.tar.gz: 8401efafe0d900337d772313489636ee66343b0fefc9d88568758d45f1894daa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a986c9134475c7b79a7cde4c7d5e7ee0e68697cb6a4f59e47ac94e3e4dbf1f40cae397a735dbb1bb093ff8599f73d64daf46085da5f6ba356fa3d295d1c2b63a
|
|
7
|
+
data.tar.gz: c7b6677372b165e0958b4f2c7af73a77829c2d71f21fdd93260ca3a132b19f4e7cf7e56b5037a7f112cd9cabf30c09f1e364de2044349763a9e9e9033c6fba76
|
data/.mutant.yml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
usage: opensource
|
|
3
|
+
includes:
|
|
4
|
+
- lib
|
|
5
|
+
- test
|
|
6
|
+
requires:
|
|
7
|
+
- sqlite_types
|
|
8
|
+
- generators/sqlite_types/migration/migration_generator
|
|
9
|
+
environment_variables:
|
|
10
|
+
SIMPLECOV_DISABLED: "1"
|
|
11
|
+
integration:
|
|
12
|
+
name: minitest
|
|
13
|
+
jobs: 1
|
|
14
|
+
mutation:
|
|
15
|
+
timeout: 30.0
|
|
16
|
+
matcher:
|
|
17
|
+
subjects:
|
|
18
|
+
- SQLiteTypes*
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-05-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Add Rails migration generator for PostgreSQL-to-SQLite type preparation migrations
|
|
8
|
+
- Add migration helpers for reversible `inet`, `interval`, and PostgreSQL array conversions
|
|
9
|
+
- Add exact `text[]` rollback support and nested-array rollback validation for PostgreSQL migrations
|
|
10
|
+
- Add integration tests for ActiveRecord persistence, querying, migration data preservation, and generator output
|
|
11
|
+
- Preserve PostgreSQL array `NULL` elements across SQLite JSON-backed array types and migration rollbacks
|
|
12
|
+
- Preserve PostgreSQL array element order during JSON-to-array rollback conversions
|
|
13
|
+
- Add PostgreSQL rollback preflight validation for incompatible JSON array elements before schema changes
|
|
14
|
+
- Add PostgreSQL rollback preflight validation for the actual target array casts before schema changes
|
|
15
|
+
- Lock affected PostgreSQL array tables during rollback validation and restoration when a migration transaction is open
|
|
16
|
+
- Preserve SQL `NULL` array elements when restoring PostgreSQL `json`/`jsonb` arrays
|
|
17
|
+
- Reject invalid nested JSON array shapes without relying on PostgreSQL scalar array-length errors
|
|
18
|
+
- Serialize `:datetime` arrays in PostgreSQL-compatible timestamp JSON format for migrated-row equality queries
|
|
19
|
+
- Allow JSON-backed array subtypes to store broader JSON values after migration with best-effort subtype casting
|
|
20
|
+
- Reject JSON array numeric values that Rails would serialize as strings or `null`
|
|
21
|
+
- Match PostgreSQL `inet` casting for blank and invalid string assignments by casting them to `nil`
|
|
22
|
+
|
|
3
23
|
## [0.2.0] - 2025-10-24
|
|
4
24
|
|
|
5
25
|
### Fixed
|
data/README.md
CHANGED
|
@@ -18,11 +18,45 @@ And then execute:
|
|
|
18
18
|
bundle install
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Migration Generator
|
|
22
|
+
|
|
23
|
+
Generate a rollback-aware type-preparation migration while your application is still running on PostgreSQL:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bin/rails generate sqlite_types:migration prepare_sqlite_types \
|
|
27
|
+
--inet users.current_sign_in_ip users.last_sign_in_ip \
|
|
28
|
+
--interval timings.time_offset \
|
|
29
|
+
--array events.relationship_statuses:string event_classes.summary_points:text:nested email_notification_logs.attachments:hash
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The generator creates a migration that includes `SQLiteTypes::MigrationHelpers`:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
class PrepareSqliteTypes < ActiveRecord::Migration[7.1]
|
|
36
|
+
include SQLiteTypes::MigrationHelpers
|
|
37
|
+
|
|
38
|
+
def change
|
|
39
|
+
change_inet_to_string :users, :current_sign_in_ip
|
|
40
|
+
change_inet_to_string :users, :last_sign_in_ip
|
|
41
|
+
change_interval_to_string :timings, :time_offset
|
|
42
|
+
change_array_to_json :events, :relationship_statuses, :string
|
|
43
|
+
change_array_to_json :event_classes, :summary_points, :text, nested: true
|
|
44
|
+
change_array_to_json :email_notification_logs, :attachments, :hash
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The generated migration is PostgreSQL-only. Run it while the application is still backed by PostgreSQL, then keep the app on PostgreSQL long enough to verify the SQLite-compatible column types with your normal Rails code, staging traffic, and test suite. After that, copy the prepared data to SQLite with a separate data migration tool.
|
|
50
|
+
|
|
51
|
+
On the way up, `change_array_to_json` converts PostgreSQL array columns to `jsonb` and adds a `jsonb_typeof(...) = 'array'` check constraint. On PostgreSQL rollback, it rebuilds the original array column through a temporary column so data compatible with the original PostgreSQL type remains reversible. Nullability is preserved unless you pass `null:` explicitly. The default assumes PostgreSQL-style empty-array defaults; if a column has different default semantics, edit the generated migration and pass `default:` explicitly. Use `:text` when the original column was `text[]`; `:string` restores a Rails `string`/`varchar[]` column. Array element order and SQL `NULL` elements are preserved. Rollbacks lock affected array tables when running inside Rails' default PostgreSQL migration transaction, preflight JSON shape, element compatibility, and the actual PostgreSQL target casts before changing schema, then raise `ActiveRecord::IrreversibleMigration` for incompatible values. Nested PostgreSQL arrays must remain rectangular with non-empty inner arrays because PostgreSQL multidimensional arrays cannot represent ragged JSON arrays or preserve empty inner arrays.
|
|
52
|
+
|
|
21
53
|
## Available Types
|
|
22
54
|
|
|
23
55
|
### IpAddress
|
|
24
56
|
|
|
25
57
|
Replaces PostgreSQL's `inet` type with a string representation that preserves IP address functionality.
|
|
58
|
+
The Ruby value is an `IPAddr`, matching Rails' PostgreSQL `inet` type. `IPAddr` normalizes CIDR host bits when casting values like `192.0.2.15/24`; the migration helper preserves existing database text during the SQL type change, but new model assignments follow Rails' `IPAddr` semantics.
|
|
59
|
+
Blank or invalid string assignments cast to `nil`, matching Rails' PostgreSQL `inet` type. String query values are validated and then kept as written, so queries against migrated rows with preserved host bits match PostgreSQL `inet` text behavior. Invalid strings passed directly to query/persistence serialization are rejected instead of being stored as text.
|
|
26
60
|
|
|
27
61
|
**Usage:**
|
|
28
62
|
|
|
@@ -36,15 +70,12 @@ end
|
|
|
36
70
|
**Migration:**
|
|
37
71
|
|
|
38
72
|
```ruby
|
|
39
|
-
class MigrateInetToString < ActiveRecord::Migration[7.
|
|
40
|
-
|
|
41
|
-
change_column :users, :current_sign_in_ip, :string
|
|
42
|
-
change_column :users, :last_sign_in_ip, :string
|
|
43
|
-
end
|
|
73
|
+
class MigrateInetToString < ActiveRecord::Migration[7.1]
|
|
74
|
+
include SQLiteTypes::MigrationHelpers
|
|
44
75
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
def change
|
|
77
|
+
change_inet_to_string :users, :current_sign_in_ip
|
|
78
|
+
change_inet_to_string :users, :last_sign_in_ip
|
|
48
79
|
end
|
|
49
80
|
end
|
|
50
81
|
```
|
|
@@ -53,7 +84,11 @@ end
|
|
|
53
84
|
|
|
54
85
|
Replaces PostgreSQL arrays with JSON-backed arrays, supporting querying via SQLite's JSON functions.
|
|
55
86
|
|
|
56
|
-
**Supported subtypes:** `:integer`, `:string`, `:hash`, `:datetime`
|
|
87
|
+
**Supported subtypes:** `:integer`, `:string`, `:text`, `:hash`, `:json`, `:jsonb`, `:datetime`
|
|
88
|
+
|
|
89
|
+
All subtypes preserve `nil` elements, matching PostgreSQL array `NULL` elements. At runtime, `SQLiteTypes::Array` casts values only when they already fit the declared subtype; for example, integer strings become integers and datetime strings become `Time` values. Values outside the declared subtype are still allowed when they are native JSON values, because SQLite JSON storage can hold broader data after migration.
|
|
90
|
+
|
|
91
|
+
For example, `:integer` can store integers outside PostgreSQL `integer[]`/`int4[]` bounds and even non-integer JSON values; those values are valid for SQLite but can make a future rollback to the original PostgreSQL column type fail cleanly before schema changes. The same broader-value rule applies to `:string`, `:text`, `:hash`, and `:datetime`; rollback restores `:string` and `:text` through PostgreSQL text conversion, while incompatible values in other subtypes can block rollback. `:datetime` serializes time values in the same timestamp string shape PostgreSQL `to_jsonb(timestamp[])` uses, so equality queries can match migrated rows. The `:json` and `:jsonb` subtypes accept native JSON values only; non-finite floats and Ruby numerics that Rails would encode as strings, such as `BigDecimal`, `Rational`, and `Complex`, are rejected to avoid silent type changes or `null` writes.
|
|
57
92
|
|
|
58
93
|
**Usage:**
|
|
59
94
|
|
|
@@ -68,15 +103,12 @@ end
|
|
|
68
103
|
**Migration:**
|
|
69
104
|
|
|
70
105
|
```ruby
|
|
71
|
-
class MigrateArrayToJson < ActiveRecord::Migration[7.
|
|
72
|
-
|
|
73
|
-
change_column :users, :personality_traits, :json
|
|
74
|
-
change_column :users, :favorite_numbers, :json
|
|
75
|
-
end
|
|
106
|
+
class MigrateArrayToJson < ActiveRecord::Migration[7.1]
|
|
107
|
+
include SQLiteTypes::MigrationHelpers
|
|
76
108
|
|
|
77
|
-
def
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
def change
|
|
110
|
+
change_array_to_json :users, :personality_traits, :string
|
|
111
|
+
change_array_to_json :users, :favorite_numbers, :integer
|
|
80
112
|
end
|
|
81
113
|
end
|
|
82
114
|
```
|
|
@@ -88,6 +120,7 @@ For array querying functionality, see [Stephen Margheim's article on enhancing R
|
|
|
88
120
|
### Interval
|
|
89
121
|
|
|
90
122
|
Replaces PostgreSQL's `interval` type with ISO8601 duration strings.
|
|
123
|
+
Fixed-length `ActiveSupport::Duration` values are serialized through their total seconds, so equivalent durations like `150.minutes` and `2.hours + 30.minutes` use the same queryable representation. Variable durations with month or year parts keep their calendar components.
|
|
91
124
|
|
|
92
125
|
**Usage:**
|
|
93
126
|
|
|
@@ -100,25 +133,23 @@ end
|
|
|
100
133
|
**Migration:**
|
|
101
134
|
|
|
102
135
|
```ruby
|
|
103
|
-
class MigrateIntervalToString < ActiveRecord::Migration[7.
|
|
104
|
-
|
|
105
|
-
change_column :events, :duration, :string
|
|
106
|
-
end
|
|
136
|
+
class MigrateIntervalToString < ActiveRecord::Migration[7.1]
|
|
137
|
+
include SQLiteTypes::MigrationHelpers
|
|
107
138
|
|
|
108
|
-
def
|
|
109
|
-
|
|
139
|
+
def change
|
|
140
|
+
change_interval_to_string :events, :duration
|
|
110
141
|
end
|
|
111
142
|
end
|
|
112
143
|
```
|
|
113
144
|
|
|
114
145
|
## Migration Strategy
|
|
115
146
|
|
|
116
|
-
The recommended approach for migrating from PostgreSQL to SQLite is incremental and reversible:
|
|
147
|
+
The recommended approach for migrating from PostgreSQL to SQLite is incremental and reversible while data remains compatible with the original PostgreSQL column types:
|
|
117
148
|
|
|
118
149
|
1. **Prepare while still on PostgreSQL:**
|
|
119
150
|
- Add custom type declarations to your models
|
|
120
151
|
- Run migrations to change column types (e.g., `inet` → `string`)
|
|
121
|
-
- Test thoroughly
|
|
152
|
+
- Test thoroughly; rollbacks are preflighted and reversible for compatible data.
|
|
122
153
|
|
|
123
154
|
2. **Switch to SQLite:**
|
|
124
155
|
- Update `database.yml` to point to SQLite
|
|
@@ -133,7 +164,7 @@ For a detailed migration guide, see [this presentation on migrating from Postgre
|
|
|
133
164
|
|
|
134
165
|
## Development
|
|
135
166
|
|
|
136
|
-
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
|
|
167
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests with SimpleCov's 100% line and branch coverage gates. Run `bundle exec standardrb` for style checks and `bundle exec mutant run` for the mutation test suite. The default `rake` task runs all three gates.
|
|
137
168
|
|
|
138
169
|
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
139
170
|
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
require "active_record"
|
|
2
|
+
require "active_support/time"
|
|
3
|
+
require "date"
|
|
4
|
+
require "ipaddr"
|
|
2
5
|
require_relative "sqlite_types/version"
|
|
3
6
|
require_relative "sqlite_types/ip_address"
|
|
4
7
|
require_relative "sqlite_types/array"
|
|
5
8
|
require_relative "sqlite_types/interval"
|
|
9
|
+
require_relative "sqlite_types/migration_helpers"
|
|
6
10
|
|
|
7
11
|
module SQLiteTypes
|
|
8
12
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
require "sqlite_types/migration_helpers"
|
|
6
|
+
|
|
7
|
+
module SQLiteTypes
|
|
8
|
+
module Generators
|
|
9
|
+
class MigrationGenerator < Rails::Generators::NamedBase
|
|
10
|
+
include ActiveRecord::Generators::Migration
|
|
11
|
+
|
|
12
|
+
IDENTIFIER_PATTERN = /\A[a-zA-Z_]\w*\z/
|
|
13
|
+
ARRAY_MODIFIERS = %w[nested].freeze
|
|
14
|
+
|
|
15
|
+
namespace "sqlite_types:migration"
|
|
16
|
+
source_root File.expand_path("templates", __dir__)
|
|
17
|
+
|
|
18
|
+
class_option :inet,
|
|
19
|
+
type: :array,
|
|
20
|
+
default: [],
|
|
21
|
+
banner: "table.column",
|
|
22
|
+
desc: "inet columns to migrate to string"
|
|
23
|
+
class_option :interval,
|
|
24
|
+
type: :array,
|
|
25
|
+
default: [],
|
|
26
|
+
banner: "table.column",
|
|
27
|
+
desc: "interval columns to migrate to string"
|
|
28
|
+
class_option :array,
|
|
29
|
+
type: :array,
|
|
30
|
+
default: [],
|
|
31
|
+
banner: "table.column:subtype[:nested]",
|
|
32
|
+
desc: "PostgreSQL array columns to migrate to json/jsonb"
|
|
33
|
+
|
|
34
|
+
def self.next_migration_number(dirname)
|
|
35
|
+
if timestamped_migrations?
|
|
36
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
37
|
+
else
|
|
38
|
+
"%.3d" % (current_migration_number(dirname) + 1)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.timestamped_migrations?
|
|
43
|
+
return ActiveRecord.timestamped_migrations if ActiveRecord.respond_to?(:timestamped_migrations)
|
|
44
|
+
return ActiveRecord::Base.timestamped_migrations if ActiveRecord::Base.respond_to?(:timestamped_migrations)
|
|
45
|
+
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def create_migration_file
|
|
50
|
+
validate_column_options!
|
|
51
|
+
|
|
52
|
+
migration_template "migration.rb.tt", "db/migrate/#{file_name}.rb"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate_column_options!
|
|
58
|
+
return if inet_columns.any? || interval_columns.any? || array_columns.any?
|
|
59
|
+
|
|
60
|
+
raise Rails::Generators::Error, "Provide at least one --inet, --interval, or --array column"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def migration_version
|
|
64
|
+
ActiveRecord::Migration.current_version
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def inet_columns
|
|
68
|
+
@inet_columns ||= Array(options[:inet]).map { |spec| parse_column_spec(spec) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def interval_columns
|
|
72
|
+
@interval_columns ||= Array(options[:interval]).map { |spec| parse_column_spec(spec) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def array_columns
|
|
76
|
+
@array_columns ||= Array(options[:array]).map { |spec| parse_array_spec(spec) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_column_spec(spec)
|
|
80
|
+
table_name, column_name = spec.to_s.split(".", 2)
|
|
81
|
+
raise Rails::Generators::Error, "Expected column as table.column, got #{spec.inspect}" if table_name.empty? || column_name.to_s.empty?
|
|
82
|
+
validate_identifier! table_name, "table"
|
|
83
|
+
validate_identifier! column_name, "column"
|
|
84
|
+
|
|
85
|
+
ColumnSpec.new(table_name: table_name, column_name: column_name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_array_spec(spec)
|
|
89
|
+
column_spec, subtype, *modifiers = spec.to_s.split(":")
|
|
90
|
+
raise Rails::Generators::Error, "Expected array column as table.column:subtype, got #{spec.inspect}" if subtype.to_s.empty?
|
|
91
|
+
validate_array_subtype! subtype
|
|
92
|
+
validate_array_modifiers! modifiers
|
|
93
|
+
|
|
94
|
+
column = parse_column_spec(column_spec)
|
|
95
|
+
ArraySpec.new(
|
|
96
|
+
table_name: column.table_name,
|
|
97
|
+
column_name: column.column_name,
|
|
98
|
+
subtype: subtype,
|
|
99
|
+
nested: modifiers.include?("nested")
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_identifier!(value, label)
|
|
104
|
+
return if value.match?(IDENTIFIER_PATTERN)
|
|
105
|
+
|
|
106
|
+
raise Rails::Generators::Error, "Invalid #{label} identifier: #{value.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_array_subtype!(subtype)
|
|
110
|
+
return if SQLiteTypes::MigrationHelpers::SUPPORTED_ARRAY_SUBTYPES.include?(subtype.to_sym)
|
|
111
|
+
|
|
112
|
+
raise Rails::Generators::Error, "Unsupported array subtype: #{subtype.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_array_modifiers!(modifiers)
|
|
116
|
+
unknown_modifiers = modifiers - ARRAY_MODIFIERS
|
|
117
|
+
return if unknown_modifiers.empty?
|
|
118
|
+
|
|
119
|
+
raise Rails::Generators::Error, "Unsupported array modifier: #{unknown_modifiers.first.inspect}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
ColumnSpec = Struct.new(:table_name, :column_name, keyword_init: true)
|
|
123
|
+
ArraySpec = Struct.new(:table_name, :column_name, :subtype, :nested, keyword_init: true)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite_types/migration_helpers"
|
|
4
|
+
|
|
5
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= migration_version %>]
|
|
6
|
+
include SQLiteTypes::MigrationHelpers
|
|
7
|
+
|
|
8
|
+
def change
|
|
9
|
+
<% inet_columns.each do |column| -%>
|
|
10
|
+
change_inet_to_string :<%= column.table_name %>, :<%= column.column_name %>
|
|
11
|
+
<% end -%>
|
|
12
|
+
<% interval_columns.each do |column| -%>
|
|
13
|
+
change_interval_to_string :<%= column.table_name %>, :<%= column.column_name %>
|
|
14
|
+
<% end -%>
|
|
15
|
+
<% array_columns.each do |column| -%>
|
|
16
|
+
change_array_to_json :<%= column.table_name %>, :<%= column.column_name %>, :<%= column.subtype %><%= ", nested: true" if column.nested %>
|
|
17
|
+
<% end -%>
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/sqlite_types/array.rb
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
3
5
|
module SQLiteTypes
|
|
4
6
|
class Array < ActiveRecord::Type::Json
|
|
5
|
-
SUPPORTED_SUBTYPES = %i[integer string hash datetime].freeze
|
|
7
|
+
SUPPORTED_SUBTYPES = %i[integer string text hash json jsonb datetime].freeze
|
|
8
|
+
POSTGRESQL_TIMESTAMP_JSON_FORMAT = "%Y-%m-%dT%H:%M:%S.%6N"
|
|
6
9
|
|
|
7
10
|
def initialize(subtype, nested: false)
|
|
11
|
+
subtype = subtype.to_sym if subtype.respond_to?(:to_sym)
|
|
8
12
|
raise ArgumentError, "Unsupported subtype: #{subtype}" unless SUPPORTED_SUBTYPES.include?(subtype)
|
|
9
13
|
|
|
10
14
|
@subtype = subtype
|
|
@@ -15,19 +19,25 @@ module SQLiteTypes
|
|
|
15
19
|
def deserialize(value)
|
|
16
20
|
return if value.nil?
|
|
17
21
|
|
|
18
|
-
array =
|
|
22
|
+
array = parse_array(value)
|
|
19
23
|
|
|
20
24
|
if @nested
|
|
21
|
-
array.map
|
|
25
|
+
array.map do |nested_array|
|
|
26
|
+
parse_array(nested_array, nested: true).map { |element| cast_element(element) }
|
|
27
|
+
end
|
|
22
28
|
else
|
|
23
29
|
array.map { |element| cast_element(element) }
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def serialize(value)
|
|
28
|
-
|
|
34
|
+
return super if value.nil?
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
# Normalize through the same cast path as reads so persisted values and equality queries
|
|
37
|
+
# use one canonical JSON representation for the declared subtype.
|
|
38
|
+
array = deserialize(value)
|
|
39
|
+
array = serialize_datetime_array(array) if @subtype == :datetime
|
|
40
|
+
super(array)
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
# Use "=" instead of "IN" in WHERE clause, to match PostgreSQL arrays
|
|
@@ -37,41 +47,108 @@ module SQLiteTypes
|
|
|
37
47
|
|
|
38
48
|
private
|
|
39
49
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
return false if !value.is_a?(::Array)
|
|
43
|
-
return value.all? { |nested_array| nested_array.is_a?(::Array) } if @nested
|
|
44
|
-
|
|
45
|
-
true
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def parse_to_array(value)
|
|
49
|
-
case value
|
|
50
|
+
def parse_array(value, nested: false)
|
|
51
|
+
array = case value
|
|
50
52
|
when ::String
|
|
51
53
|
begin
|
|
52
54
|
parsed = ::ActiveSupport::JSON.decode(value)
|
|
53
|
-
parsed.
|
|
55
|
+
parsed if parsed.instance_of?(::Array)
|
|
54
56
|
rescue JSON::ParserError
|
|
55
57
|
nil
|
|
56
58
|
end
|
|
57
59
|
when ::Array
|
|
58
60
|
value
|
|
59
61
|
end
|
|
62
|
+
|
|
63
|
+
return array if array
|
|
64
|
+
|
|
65
|
+
description = nested ? "nested array" : "array"
|
|
66
|
+
raise ArgumentError, "Invalid #{description} value: #{value.inspect}"
|
|
60
67
|
end
|
|
61
68
|
|
|
62
69
|
def cast_element(elem)
|
|
70
|
+
raise ArgumentError, "Invalid #{@subtype} array element: #{elem.inspect}" unless storable_element?(elem)
|
|
71
|
+
|
|
63
72
|
case @subtype
|
|
64
73
|
when :integer
|
|
65
|
-
elem
|
|
66
|
-
when :string
|
|
67
|
-
elem
|
|
74
|
+
integer_string?(elem) ? cast_integer_element(elem) : elem
|
|
75
|
+
when :string, :text
|
|
76
|
+
elem
|
|
68
77
|
when :hash
|
|
69
|
-
elem.to_h
|
|
78
|
+
elem.is_a?(::Hash) ? elem.to_h : elem
|
|
79
|
+
when :json, :jsonb
|
|
80
|
+
elem
|
|
70
81
|
when :datetime
|
|
71
|
-
|
|
82
|
+
cast_datetime_element(elem)
|
|
72
83
|
else
|
|
73
84
|
raise ArgumentError, "Unsupported subtype: #{@subtype}"
|
|
74
85
|
end
|
|
75
86
|
end
|
|
87
|
+
|
|
88
|
+
def storable_element?(elem)
|
|
89
|
+
return true if @subtype == :datetime && datetime_like?(elem)
|
|
90
|
+
|
|
91
|
+
json_compatible?(elem)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def datetime_like?(value)
|
|
95
|
+
value.acts_like?(:time) || value.is_a?(::Date)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def integer_string?(value)
|
|
99
|
+
value.is_a?(::String) && value.match?(/\A[+-]?\d+\z/)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def cast_integer_element(value)
|
|
103
|
+
Integer(value, 10)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def cast_datetime_element(value)
|
|
107
|
+
return value.respond_to?(:in_time_zone) ? value.in_time_zone : value if datetime_like?(value)
|
|
108
|
+
return value unless value.is_a?(::String)
|
|
109
|
+
|
|
110
|
+
parse_datetime(value)&.in_time_zone || value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_datetime(value)
|
|
114
|
+
::DateTime.parse(value)
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def serialize_datetime_array(array)
|
|
120
|
+
if @nested
|
|
121
|
+
array.map { |nested_array| nested_array.map { |element| serialize_datetime_element(element) } }
|
|
122
|
+
else
|
|
123
|
+
array.map { |element| serialize_datetime_element(element) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def serialize_datetime_element(element)
|
|
128
|
+
return element unless element.acts_like?(:time)
|
|
129
|
+
|
|
130
|
+
postgresql_timestamp_json(element)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def postgresql_timestamp_json(element)
|
|
134
|
+
# PostgreSQL to_jsonb(timestamp[]) emits timestamp text without a time-zone suffix,
|
|
135
|
+
# and omits the fractional part when it is zero.
|
|
136
|
+
element.to_time.utc.strftime(POSTGRESQL_TIMESTAMP_JSON_FORMAT).sub(/(\.\d*?)0+\z/, "\\1").delete_suffix(".")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def json_compatible?(value)
|
|
140
|
+
case value
|
|
141
|
+
when nil, true, false, ::String, ::Integer
|
|
142
|
+
true
|
|
143
|
+
when ::Float
|
|
144
|
+
value.finite?
|
|
145
|
+
when ::Array
|
|
146
|
+
value.all? { |element| json_compatible?(element) }
|
|
147
|
+
when ::Hash
|
|
148
|
+
value.all? do |key, element|
|
|
149
|
+
(key.is_a?(::String) || key.instance_of?(::Symbol)) && json_compatible?(element)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
76
153
|
end
|
|
77
154
|
end
|
|
@@ -7,7 +7,7 @@ module SQLiteTypes
|
|
|
7
7
|
def serialize(value)
|
|
8
8
|
case value
|
|
9
9
|
when ::ActiveSupport::Duration
|
|
10
|
-
value.iso8601(precision: precision)
|
|
10
|
+
canonical_duration(value).iso8601(precision: precision)
|
|
11
11
|
when ::Numeric
|
|
12
12
|
::ActiveSupport::Duration.build(value).iso8601(precision: precision)
|
|
13
13
|
else
|
|
@@ -21,10 +21,14 @@ module SQLiteTypes
|
|
|
21
21
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
|
+
def canonical_duration(value)
|
|
25
|
+
return value if value.variable?
|
|
26
|
+
|
|
27
|
+
::ActiveSupport::Duration.build(value)
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def cast_value(value)
|
|
25
31
|
case value
|
|
26
|
-
when ::ActiveSupport::Duration
|
|
27
|
-
value
|
|
28
32
|
when ::String
|
|
29
33
|
begin
|
|
30
34
|
::ActiveSupport::Duration.parse(value)
|
|
@@ -7,23 +7,49 @@ module SQLiteTypes
|
|
|
7
7
|
|
|
8
8
|
case value
|
|
9
9
|
when ::IPAddr
|
|
10
|
-
|
|
10
|
+
serialize_ipaddr(value)
|
|
11
11
|
when ::String
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
::IPAddr.new(value)
|
|
13
|
+
value
|
|
14
14
|
else
|
|
15
15
|
raise ArgumentError, "Invalid IP address: #{value}"
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def changed?(old_value, new_value, _new_value_before_type_cast)
|
|
20
|
+
!serialize_for_change(old_value).eql?(serialize_for_change(new_value))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def changed_in_place?(raw_old_value, new_value)
|
|
24
|
+
!serialize_for_change(raw_old_value).eql?(serialize_for_change(new_value))
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
19
29
|
private
|
|
20
30
|
|
|
31
|
+
def serialize_ipaddr(value)
|
|
32
|
+
"#{value}/#{value.prefix}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def serialize_for_change(value)
|
|
36
|
+
return if value.nil?
|
|
37
|
+
|
|
38
|
+
cast_value(value)
|
|
39
|
+
end
|
|
40
|
+
|
|
21
41
|
def cast_value(value)
|
|
22
42
|
case value
|
|
23
43
|
when ::IPAddr
|
|
24
44
|
value
|
|
25
45
|
when ::String
|
|
26
|
-
|
|
46
|
+
begin
|
|
47
|
+
::IPAddr.new(value)
|
|
48
|
+
rescue ::IPAddr::InvalidAddressError
|
|
49
|
+
# Rails' PostgreSQL inet type casts invalid string assignments to nil;
|
|
50
|
+
# serialization still raises for invalid query and persistence values.
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
27
53
|
else
|
|
28
54
|
raise ArgumentError, "Invalid IP address: #{value}"
|
|
29
55
|
end
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/filters"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "array"
|
|
6
|
+
|
|
7
|
+
module SQLiteTypes
|
|
8
|
+
module MigrationHelpers
|
|
9
|
+
DEFAULT_NOT_PROVIDED = Object.new.freeze
|
|
10
|
+
POSTGRESQL_IDENTIFIER_LIMIT = 63
|
|
11
|
+
SUPPORTED_ARRAY_SUBTYPES = SQLiteTypes::Array::SUPPORTED_SUBTYPES
|
|
12
|
+
|
|
13
|
+
def change_inet_to_string(table_name, column_name, **options)
|
|
14
|
+
require_postgresql_adapter!
|
|
15
|
+
|
|
16
|
+
reversible do |dir|
|
|
17
|
+
dir.up do
|
|
18
|
+
change_column table_name, column_name, :string, **options.merge(using: "#{quote_column_name(column_name)}::text")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
dir.down do
|
|
22
|
+
change_column table_name, column_name, :inet, **options.merge(using: "#{quote_column_name(column_name)}::inet")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def change_interval_to_string(table_name, column_name, **options)
|
|
28
|
+
require_postgresql_adapter!
|
|
29
|
+
|
|
30
|
+
reversible do |dir|
|
|
31
|
+
dir.up do
|
|
32
|
+
change_column table_name, column_name, :string, **options.merge(using: "#{quote_column_name(column_name)}::text")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
dir.down do
|
|
36
|
+
change_column table_name, column_name, :interval, **options.merge(using: "#{quote_column_name(column_name)}::interval")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def change_array_to_json(
|
|
42
|
+
table_name,
|
|
43
|
+
column_name,
|
|
44
|
+
subtype,
|
|
45
|
+
nested: false,
|
|
46
|
+
null: nil,
|
|
47
|
+
default: [],
|
|
48
|
+
constraint_name: nil
|
|
49
|
+
)
|
|
50
|
+
require_postgresql_adapter!
|
|
51
|
+
|
|
52
|
+
subtype = normalize_array_subtype(subtype)
|
|
53
|
+
constraint_name ||= default_array_constraint_name(table_name, column_name)
|
|
54
|
+
sqlite_types_array_columns << [table_name, column_name, subtype, nested]
|
|
55
|
+
|
|
56
|
+
reversible do |dir|
|
|
57
|
+
dir.up do
|
|
58
|
+
options = {}
|
|
59
|
+
options[:null] = null unless null.nil?
|
|
60
|
+
|
|
61
|
+
change_array_default table_name, column_name, from: default, to: nil
|
|
62
|
+
change_column table_name, column_name, :jsonb, **options.merge(using: "to_jsonb(#{quote_column_name(column_name)})")
|
|
63
|
+
change_array_default table_name, column_name, from: nil, to: default
|
|
64
|
+
add_check_constraint table_name, "jsonb_typeof(#{quote_column_name(column_name)}) = 'array'", name: constraint_name
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
dir.down do
|
|
68
|
+
validate_registered_postgresql_array_rollbacks!
|
|
69
|
+
remove_check_constraint table_name, name: constraint_name
|
|
70
|
+
restore_postgresql_array_column table_name, column_name, subtype, nested: nested, null: resolved_null(table_name, column_name, null), default: default
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def require_postgresql_adapter!
|
|
78
|
+
return if connection.adapter_name.downcase.include?("postgresql")
|
|
79
|
+
|
|
80
|
+
raise ActiveRecord::MigrationError,
|
|
81
|
+
"SQLiteTypes migration helpers must run on PostgreSQL before migrating data to SQLite"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalize_array_subtype(subtype)
|
|
85
|
+
subtype = subtype.to_sym if subtype.respond_to?(:to_sym)
|
|
86
|
+
raise ArgumentError, "Unsupported array subtype: #{subtype}" unless SUPPORTED_ARRAY_SUBTYPES.include?(subtype)
|
|
87
|
+
|
|
88
|
+
subtype
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def change_array_default(table_name, column_name, from:, to:)
|
|
92
|
+
return if from.equal?(DEFAULT_NOT_PROVIDED) || to.equal?(DEFAULT_NOT_PROVIDED)
|
|
93
|
+
return if from == to
|
|
94
|
+
|
|
95
|
+
change_column_default table_name, column_name, from: from, to: to
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def restore_postgresql_array_column(table_name, column_name, subtype, nested:, null:, default:)
|
|
99
|
+
temporary_column_name = temporary_array_column_name(column_name)
|
|
100
|
+
|
|
101
|
+
add_column table_name, temporary_column_name, postgresql_array_subtype(subtype), **postgresql_array_column_options(null: null, default: default)
|
|
102
|
+
execute "UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(temporary_column_name)} = #{postgresql_array_restore_expression(quote_column_name(column_name), subtype, nested)};"
|
|
103
|
+
remove_column table_name, column_name
|
|
104
|
+
rename_column table_name, temporary_column_name, column_name
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def postgresql_array_restore_expression(column_sql, subtype, nested)
|
|
108
|
+
"CASE WHEN #{column_sql} IS NULL THEN NULL ELSE COALESCE((#{postgresql_array_expression(column_sql, subtype, nested)}), '{}') END"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def postgresql_array_subtype(subtype)
|
|
112
|
+
case subtype
|
|
113
|
+
when :hash
|
|
114
|
+
:jsonb
|
|
115
|
+
else
|
|
116
|
+
subtype
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def postgresql_array_expression(column_sql, subtype, nested)
|
|
121
|
+
if nested
|
|
122
|
+
inner_expression = postgresql_array_expression("sqlite_types_outer.value", subtype, false)
|
|
123
|
+
return "SELECT array_agg((#{inner_expression}) ORDER BY sqlite_types_outer.ordinality) FROM jsonb_array_elements(#{column_sql}) WITH ORDINALITY AS sqlite_types_outer(value, ordinality)"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
case subtype
|
|
127
|
+
when :string, :text
|
|
128
|
+
"SELECT array_agg(sqlite_types_element.value ORDER BY sqlite_types_element.ordinality) FROM jsonb_array_elements_text(#{column_sql}) WITH ORDINALITY AS sqlite_types_element(value, ordinality)"
|
|
129
|
+
when :integer
|
|
130
|
+
"SELECT array_agg(sqlite_types_element.value::int ORDER BY sqlite_types_element.ordinality) FROM jsonb_array_elements_text(#{column_sql}) WITH ORDINALITY AS sqlite_types_element(value, ordinality)"
|
|
131
|
+
when :datetime
|
|
132
|
+
"SELECT array_agg(sqlite_types_element.value::timestamp ORDER BY sqlite_types_element.ordinality) FROM jsonb_array_elements_text(#{column_sql}) WITH ORDINALITY AS sqlite_types_element(value, ordinality)"
|
|
133
|
+
when :json
|
|
134
|
+
"SELECT array_agg(CASE WHEN jsonb_typeof(sqlite_types_element.value) = 'null' THEN NULL ELSE sqlite_types_element.value::json END ORDER BY sqlite_types_element.ordinality) FROM jsonb_array_elements(#{column_sql}) WITH ORDINALITY AS sqlite_types_element(value, ordinality)"
|
|
135
|
+
when :hash, :jsonb
|
|
136
|
+
"SELECT array_agg(CASE WHEN jsonb_typeof(sqlite_types_element.value) = 'null' THEN NULL ELSE sqlite_types_element.value END ORDER BY sqlite_types_element.ordinality) FROM jsonb_array_elements(#{column_sql}) WITH ORDINALITY AS sqlite_types_element(value, ordinality)"
|
|
137
|
+
else
|
|
138
|
+
raise ArgumentError, "Unsupported array subtype: #{subtype}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_postgresql_nested_array_shape!(table_name, column_name)
|
|
143
|
+
column_sql = "sqlite_types_row.#{quote_column_name(column_name)}"
|
|
144
|
+
invalid_row = select_value "SELECT 1 FROM #{quote_table_name(table_name)} AS sqlite_types_row WHERE #{column_sql} IS NOT NULL AND (jsonb_typeof(#{column_sql}) <> 'array' OR EXISTS (SELECT 1 FROM jsonb_array_elements(#{column_sql}) AS sqlite_types_outer(value) WHERE CASE WHEN jsonb_typeof(sqlite_types_outer.value) <> 'array' THEN true ELSE jsonb_array_length(sqlite_types_outer.value) = 0 END) OR (SELECT COUNT(DISTINCT jsonb_array_length(sqlite_types_outer.value)) FROM jsonb_array_elements(#{column_sql}) AS sqlite_types_outer(value) WHERE jsonb_typeof(sqlite_types_outer.value) = 'array') > 1) LIMIT 1"
|
|
145
|
+
|
|
146
|
+
return unless invalid_row
|
|
147
|
+
|
|
148
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
149
|
+
"Cannot restore #{table_name}.#{column_name} to a PostgreSQL multidimensional array; nested JSON arrays must be rectangular and inner arrays must be non-empty"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_postgresql_array_elements!(table_name, column_name, subtype, nested:)
|
|
153
|
+
column_sql = "sqlite_types_row.#{quote_column_name(column_name)}"
|
|
154
|
+
invalid_condition = postgresql_invalid_array_element_condition("sqlite_types_element.value", subtype)
|
|
155
|
+
invalid_row = if nested
|
|
156
|
+
select_value "SELECT 1 FROM #{quote_table_name(table_name)} AS sqlite_types_row WHERE #{column_sql} IS NOT NULL AND EXISTS (SELECT 1 FROM jsonb_array_elements(#{column_sql}) AS sqlite_types_outer(value) WHERE jsonb_typeof(sqlite_types_outer.value) = 'array' AND EXISTS (SELECT 1 FROM jsonb_array_elements(sqlite_types_outer.value) AS sqlite_types_element(value) WHERE #{invalid_condition})) LIMIT 1"
|
|
157
|
+
else
|
|
158
|
+
select_value "SELECT 1 FROM #{quote_table_name(table_name)} AS sqlite_types_row WHERE #{column_sql} IS NOT NULL AND EXISTS (SELECT 1 FROM jsonb_array_elements(#{column_sql}) AS sqlite_types_element(value) WHERE #{invalid_condition}) LIMIT 1"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return unless invalid_row
|
|
162
|
+
|
|
163
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
164
|
+
"Cannot restore #{table_name}.#{column_name} to a PostgreSQL #{subtype} array; JSON elements are not compatible with the target array subtype"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def postgresql_invalid_array_element_condition(element_sql, subtype)
|
|
168
|
+
type_sql = "jsonb_typeof(#{element_sql})"
|
|
169
|
+
|
|
170
|
+
case subtype
|
|
171
|
+
when :string, :text
|
|
172
|
+
"false"
|
|
173
|
+
when :datetime
|
|
174
|
+
"#{type_sql} NOT IN ('string', 'null')"
|
|
175
|
+
when :integer
|
|
176
|
+
"CASE WHEN #{type_sql} = 'null' THEN false WHEN #{type_sql} IN ('number', 'string') THEN (#{element_sql} #>> '{}') !~ '^[+-]?[0-9]+$' ELSE true END"
|
|
177
|
+
when :hash, :json, :jsonb
|
|
178
|
+
"false"
|
|
179
|
+
else
|
|
180
|
+
raise ArgumentError, "Unsupported array subtype: #{subtype}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_registered_postgresql_array_rollbacks!
|
|
185
|
+
return if @sqlite_types_array_rollbacks_validated
|
|
186
|
+
|
|
187
|
+
lock_registered_postgresql_array_tables!
|
|
188
|
+
|
|
189
|
+
sqlite_types_array_columns.each do |table_name, column_name, subtype, nested|
|
|
190
|
+
validate_postgresql_array_shape!(table_name, column_name, subtype)
|
|
191
|
+
validate_postgresql_nested_array_shape!(table_name, column_name) if nested
|
|
192
|
+
validate_postgresql_array_elements!(table_name, column_name, subtype, nested: nested)
|
|
193
|
+
validate_postgresql_array_restore_cast!(table_name, column_name, subtype, nested: nested)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
@sqlite_types_array_rollbacks_validated = true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def lock_registered_postgresql_array_tables!
|
|
200
|
+
return if connection.respond_to?(:transaction_open?) && !connection.transaction_open?
|
|
201
|
+
|
|
202
|
+
sqlite_types_array_columns.map(&:first).uniq.each do |table_name|
|
|
203
|
+
execute "LOCK TABLE #{quote_table_name(table_name)} IN SHARE ROW EXCLUSIVE MODE"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def validate_postgresql_array_shape!(table_name, column_name, subtype)
|
|
208
|
+
column_sql = "sqlite_types_row.#{quote_column_name(column_name)}"
|
|
209
|
+
invalid_row = select_value "SELECT 1 FROM #{quote_table_name(table_name)} AS sqlite_types_row WHERE #{column_sql} IS NOT NULL AND jsonb_typeof(#{column_sql}) <> 'array' LIMIT 1"
|
|
210
|
+
|
|
211
|
+
return unless invalid_row
|
|
212
|
+
|
|
213
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
214
|
+
"Cannot restore #{table_name}.#{column_name} to a PostgreSQL #{subtype} array; JSON value is not an array"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def validate_postgresql_array_restore_cast!(table_name, column_name, subtype, nested:)
|
|
218
|
+
column_sql = "sqlite_types_row.#{quote_column_name(column_name)}"
|
|
219
|
+
select_value "SELECT COUNT(*) FROM #{quote_table_name(table_name)} AS sqlite_types_row WHERE #{column_sql} IS NOT NULL AND (#{postgresql_array_restore_expression(column_sql, subtype, nested)}) IS NOT NULL"
|
|
220
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
221
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
222
|
+
"Cannot restore #{table_name}.#{column_name} to a PostgreSQL #{subtype} array; PostgreSQL rejected the rollback cast: #{error.message.lines.first&.strip}"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def sqlite_types_array_columns
|
|
226
|
+
@sqlite_types_array_columns ||= []
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def postgresql_array_column_options(null:, default:)
|
|
230
|
+
options = {array: true}
|
|
231
|
+
options[:null] = null unless null.nil?
|
|
232
|
+
return options if default.equal?(DEFAULT_NOT_PROVIDED)
|
|
233
|
+
|
|
234
|
+
options.merge(default: default)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def resolved_null(table_name, column_name, null)
|
|
238
|
+
return null unless null.nil?
|
|
239
|
+
return unless connection.respond_to?(:columns)
|
|
240
|
+
|
|
241
|
+
connection.columns(table_name).find { |column| column.name == column_name.to_s }&.null
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def default_array_constraint_name(table_name, column_name)
|
|
245
|
+
truncate_identifier("#{table_name}_#{column_name}_array_check")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def temporary_array_column_name(column_name)
|
|
249
|
+
truncate_identifier("#{column_name}_sqlite_types_tmp")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def truncate_identifier(identifier)
|
|
253
|
+
identifier = identifier.to_s
|
|
254
|
+
return identifier if identifier.length <= POSTGRESQL_IDENTIFIER_LIMIT
|
|
255
|
+
|
|
256
|
+
digest = Digest::SHA256.hexdigest(identifier)[0, 10]
|
|
257
|
+
"#{identifier[0, POSTGRESQL_IDENTIFIER_LIMIT - digest.length - 1]}_#{digest}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
data/lib/sqlite_types/version.rb
CHANGED
data/lib/sqlite_types.rb
ADDED
data/sig/sqlite_types.rbs
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
1
|
module SQLiteTypes
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
class IpAddress < ActiveRecord::Type::String
|
|
5
|
+
def serialize: (untyped value) -> String?
|
|
6
|
+
def changed?: (untyped old_value, untyped new_value, untyped new_value_before_type_cast) -> bool
|
|
7
|
+
def changed_in_place?: (untyped raw_old_value, untyped new_value) -> bool
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Array < ActiveRecord::Type::Json
|
|
11
|
+
SUPPORTED_SUBTYPES: ::Array[Symbol]
|
|
12
|
+
|
|
13
|
+
def initialize: ((Symbol | String) subtype, ?nested: bool) -> void
|
|
14
|
+
def deserialize: (untyped value) -> ::Array[untyped]?
|
|
15
|
+
def serialize: (untyped value) -> untyped
|
|
16
|
+
def force_equality?: (untyped value) -> bool
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Interval < ActiveRecord::Type::Value
|
|
20
|
+
def serialize: (untyped value) -> untyped
|
|
21
|
+
def type_cast_for_schema: (untyped value) -> String
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module MigrationHelpers
|
|
25
|
+
DEFAULT_NOT_PROVIDED: untyped
|
|
26
|
+
SUPPORTED_ARRAY_SUBTYPES: ::Array[Symbol]
|
|
27
|
+
|
|
28
|
+
def change_inet_to_string: ((Symbol | String) table_name, (Symbol | String) column_name, **untyped options) -> untyped
|
|
29
|
+
def change_interval_to_string: ((Symbol | String) table_name, (Symbol | String) column_name, **untyped options) -> untyped
|
|
30
|
+
def change_array_to_json: ((Symbol | String) table_name, (Symbol | String) column_name, (Symbol | String) subtype, ?nested: bool, ?null: bool, ?default: untyped, ?constraint_name: String?) -> untyped
|
|
31
|
+
end
|
|
4
32
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activerecord-sqlite-types
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wojtek Wrona
|
|
@@ -38,6 +38,34 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '1.6'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: pg
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.5'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.5'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: railties
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '7.1'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '7.1'
|
|
41
69
|
description: Provides custom ActiveRecord types to handle PostgreSQL-specific data
|
|
42
70
|
types (inet, interval, arrays) when migrating to SQLite in Rails applications.
|
|
43
71
|
email:
|
|
@@ -46,19 +74,18 @@ executables: []
|
|
|
46
74
|
extensions: []
|
|
47
75
|
extra_rdoc_files: []
|
|
48
76
|
files:
|
|
49
|
-
- ".
|
|
50
|
-
- ".standard.yml"
|
|
77
|
+
- ".mutant.yml"
|
|
51
78
|
- CHANGELOG.md
|
|
52
79
|
- LICENSE.txt
|
|
53
80
|
- README.md
|
|
54
|
-
- Rakefile
|
|
55
|
-
- devenv.lock
|
|
56
|
-
- devenv.nix
|
|
57
|
-
- devenv.yaml
|
|
58
81
|
- lib/activerecord-sqlite-types.rb
|
|
82
|
+
- lib/generators/sqlite_types/migration/migration_generator.rb
|
|
83
|
+
- lib/generators/sqlite_types/migration/templates/migration.rb.tt
|
|
84
|
+
- lib/sqlite_types.rb
|
|
59
85
|
- lib/sqlite_types/array.rb
|
|
60
86
|
- lib/sqlite_types/interval.rb
|
|
61
87
|
- lib/sqlite_types/ip_address.rb
|
|
88
|
+
- lib/sqlite_types/migration_helpers.rb
|
|
62
89
|
- lib/sqlite_types/version.rb
|
|
63
90
|
- sig/sqlite_types.rbs
|
|
64
91
|
homepage: https://github.com/wojtodzio/activerecord-sqlite-types
|
data/.envrc
DELETED
data/.standard.yml
DELETED
data/Rakefile
DELETED
data/devenv.lock
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"nodes": {
|
|
3
|
-
"devenv": {
|
|
4
|
-
"locked": {
|
|
5
|
-
"dir": "src/modules",
|
|
6
|
-
"lastModified": 1761156818,
|
|
7
|
-
"owner": "cachix",
|
|
8
|
-
"repo": "devenv",
|
|
9
|
-
"rev": "949fc6dc8f36f38e1cceb1bf1673c4e995a6a766",
|
|
10
|
-
"type": "github"
|
|
11
|
-
},
|
|
12
|
-
"original": {
|
|
13
|
-
"dir": "src/modules",
|
|
14
|
-
"owner": "cachix",
|
|
15
|
-
"repo": "devenv",
|
|
16
|
-
"type": "github"
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
"flake-compat": {
|
|
20
|
-
"flake": false,
|
|
21
|
-
"locked": {
|
|
22
|
-
"lastModified": 1747046372,
|
|
23
|
-
"owner": "edolstra",
|
|
24
|
-
"repo": "flake-compat",
|
|
25
|
-
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
|
26
|
-
"type": "github"
|
|
27
|
-
},
|
|
28
|
-
"original": {
|
|
29
|
-
"owner": "edolstra",
|
|
30
|
-
"repo": "flake-compat",
|
|
31
|
-
"type": "github"
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
"flake-compat_2": {
|
|
35
|
-
"flake": false,
|
|
36
|
-
"locked": {
|
|
37
|
-
"lastModified": 1747046372,
|
|
38
|
-
"owner": "edolstra",
|
|
39
|
-
"repo": "flake-compat",
|
|
40
|
-
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
|
41
|
-
"type": "github"
|
|
42
|
-
},
|
|
43
|
-
"original": {
|
|
44
|
-
"owner": "edolstra",
|
|
45
|
-
"repo": "flake-compat",
|
|
46
|
-
"type": "github"
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
"flake-utils": {
|
|
50
|
-
"inputs": {
|
|
51
|
-
"systems": "systems"
|
|
52
|
-
},
|
|
53
|
-
"locked": {
|
|
54
|
-
"lastModified": 1731533236,
|
|
55
|
-
"owner": "numtide",
|
|
56
|
-
"repo": "flake-utils",
|
|
57
|
-
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
58
|
-
"type": "github"
|
|
59
|
-
},
|
|
60
|
-
"original": {
|
|
61
|
-
"owner": "numtide",
|
|
62
|
-
"repo": "flake-utils",
|
|
63
|
-
"type": "github"
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
"git-hooks": {
|
|
67
|
-
"inputs": {
|
|
68
|
-
"flake-compat": "flake-compat",
|
|
69
|
-
"gitignore": "gitignore",
|
|
70
|
-
"nixpkgs": [
|
|
71
|
-
"nixpkgs"
|
|
72
|
-
]
|
|
73
|
-
},
|
|
74
|
-
"locked": {
|
|
75
|
-
"lastModified": 1760663237,
|
|
76
|
-
"owner": "cachix",
|
|
77
|
-
"repo": "git-hooks.nix",
|
|
78
|
-
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
|
|
79
|
-
"type": "github"
|
|
80
|
-
},
|
|
81
|
-
"original": {
|
|
82
|
-
"owner": "cachix",
|
|
83
|
-
"repo": "git-hooks.nix",
|
|
84
|
-
"type": "github"
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
"gitignore": {
|
|
88
|
-
"inputs": {
|
|
89
|
-
"nixpkgs": [
|
|
90
|
-
"git-hooks",
|
|
91
|
-
"nixpkgs"
|
|
92
|
-
]
|
|
93
|
-
},
|
|
94
|
-
"locked": {
|
|
95
|
-
"lastModified": 1709087332,
|
|
96
|
-
"owner": "hercules-ci",
|
|
97
|
-
"repo": "gitignore.nix",
|
|
98
|
-
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
|
99
|
-
"type": "github"
|
|
100
|
-
},
|
|
101
|
-
"original": {
|
|
102
|
-
"owner": "hercules-ci",
|
|
103
|
-
"repo": "gitignore.nix",
|
|
104
|
-
"type": "github"
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
"nixpkgs": {
|
|
108
|
-
"locked": {
|
|
109
|
-
"lastModified": 1758532697,
|
|
110
|
-
"owner": "cachix",
|
|
111
|
-
"repo": "devenv-nixpkgs",
|
|
112
|
-
"rev": "207a4cb0e1253c7658c6736becc6eb9cace1f25f",
|
|
113
|
-
"type": "github"
|
|
114
|
-
},
|
|
115
|
-
"original": {
|
|
116
|
-
"owner": "cachix",
|
|
117
|
-
"ref": "rolling",
|
|
118
|
-
"repo": "devenv-nixpkgs",
|
|
119
|
-
"type": "github"
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
"nixpkgs-ruby": {
|
|
123
|
-
"inputs": {
|
|
124
|
-
"flake-compat": "flake-compat_2",
|
|
125
|
-
"flake-utils": "flake-utils",
|
|
126
|
-
"nixpkgs": [
|
|
127
|
-
"nixpkgs"
|
|
128
|
-
]
|
|
129
|
-
},
|
|
130
|
-
"locked": {
|
|
131
|
-
"lastModified": 1759902829,
|
|
132
|
-
"owner": "bobvanderlinden",
|
|
133
|
-
"repo": "nixpkgs-ruby",
|
|
134
|
-
"rev": "5fba6c022a63f1e76dee4da71edddad8959f088a",
|
|
135
|
-
"type": "github"
|
|
136
|
-
},
|
|
137
|
-
"original": {
|
|
138
|
-
"owner": "bobvanderlinden",
|
|
139
|
-
"repo": "nixpkgs-ruby",
|
|
140
|
-
"type": "github"
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
"root": {
|
|
144
|
-
"inputs": {
|
|
145
|
-
"devenv": "devenv",
|
|
146
|
-
"git-hooks": "git-hooks",
|
|
147
|
-
"nixpkgs": "nixpkgs",
|
|
148
|
-
"nixpkgs-ruby": "nixpkgs-ruby",
|
|
149
|
-
"pre-commit-hooks": [
|
|
150
|
-
"git-hooks"
|
|
151
|
-
]
|
|
152
|
-
}
|
|
153
|
-
},
|
|
154
|
-
"systems": {
|
|
155
|
-
"locked": {
|
|
156
|
-
"lastModified": 1681028828,
|
|
157
|
-
"owner": "nix-systems",
|
|
158
|
-
"repo": "default",
|
|
159
|
-
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
160
|
-
"type": "github"
|
|
161
|
-
},
|
|
162
|
-
"original": {
|
|
163
|
-
"owner": "nix-systems",
|
|
164
|
-
"repo": "default",
|
|
165
|
-
"type": "github"
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
"root": "root",
|
|
170
|
-
"version": 7
|
|
171
|
-
}
|
data/devenv.nix
DELETED