rails-snowflake 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: 8f0727305e2203bd51c71c99e038f7902d0abdf9f283bcf0dca93495c5f851bf
4
+ data.tar.gz: 751f6d5c2e3329ad5be5c6104f8cc164af707181e01f3d428673736980f0539e
5
+ SHA512:
6
+ metadata.gz: 05771bac80703b818c3c86021624f0d68ce23ceb5c34cd2423de1d2ce65674cc3824540f706d56593b0f97b6bc6afcd7c01b7e542e18a24a625a777323979957
7
+ data.tar.gz: 287eccd9577239bb6d2543383c3979483f52dc6272e6077681b56f7b86cf0ef94abbe8a11da01e7b90e7254c506d1e732253d048d003b2e79c345fc726ea8c13
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Luiz Eduardo Kowalski
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,145 @@
1
+ # Rails::Snowflake
2
+
3
+ [![CI](https://github.com/luizkowalski/snowflake_id/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/luizkowalski/snowflake_id/actions/workflows/ci.yml)
4
+
5
+ A Rails plugin that provides Snowflake-like IDs for your ActiveRecord models with minimal configuration.
6
+
7
+ Snowflake IDs are 64-bit integers that contain:
8
+ - **48 bits** for millisecond-level timestamp
9
+ - **16 bits** for sequence data (includes hashed table name + secret salt + sequence number)
10
+
11
+ This ensures globally unique, time-sortable IDs that don't reveal the total count of records in your database.
12
+
13
+ ## Features
14
+
15
+ - **Transparent** - Just use `t.snowflake` and it works automatically
16
+ - **Automatic database setup** - Hooks into `db:migrate` and `db:prepare` tasks to ensure everything is set up.
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem "rails-snowflake"
24
+ ```
25
+
26
+ And then execute the installer (note the underscore):
27
+ ```bash
28
+ rails generate rails_snowflake:install
29
+ ```
30
+
31
+ ## Quick Start
32
+
33
+ **That's it!** Just use `t.snowflake` in your migrations and everything works automatically.
34
+
35
+ ### For Snowflake ID as primary key:
36
+ ```ruby
37
+ class CreateUsers < ActiveRecord::Migration[8.0]
38
+ def change
39
+ create_table :users, id: false do |t|
40
+ t.snowflake :id, primary_key: true # Snowflake primary key
41
+ t.string :name
42
+ t.timestamps
43
+ end
44
+ end
45
+ end
46
+ ```
47
+
48
+ **Note**: When using `t.snowflake :id` directly, Rails will complain about redefining the primary key. Always use `create_table :table_name, id: false` when you want a snowflake primary key.
49
+
50
+ ### For additional snowflake columns (non-primary key):
51
+ ```ruby
52
+ class CreatePosts < ActiveRecord::Migration[8.0]
53
+ def change
54
+ create_table :posts do |t|
55
+ t.string :title
56
+ t.text :content
57
+ t.snowflake :uid # Additional snowflake column
58
+ t.timestamps
59
+ end
60
+ end
61
+ end
62
+ ```
63
+
64
+ ### Generator Support
65
+
66
+ You can also use Snowflake helper in Rails generators:
67
+
68
+ ```bash
69
+ # Generate a model with a snowflake field
70
+ rails generate model Post title:string uid:snowflake
71
+
72
+ # This will create a migration like:
73
+ # create_table :posts do |t|
74
+ # t.string :title
75
+ # t.snowflake :uid
76
+ # t.timestamps
77
+ # end
78
+ ```
79
+
80
+ ### Working with Snowflake IDs
81
+
82
+ There's nothing else to be done at this point. `t.snowflake` columns will automatically get unique IDs on record creation, and they are just a `:bigint` column in the database.
83
+ At this point, you can use them like any other integer ID.
84
+
85
+ ```ruby
86
+ user = User.create!(name: "Alice")
87
+ user.id # => 115198501587747344
88
+
89
+ # Convert ID back to timestamp
90
+ Rails::Snowflake::Id.to_time(user.id)
91
+ # => 2024-12-25 10:15:42 UTC
92
+
93
+ # Generate ID for specific timestamp
94
+ Rails::Snowflake::Id.at(1.hour.ago)
95
+ # => 1766651542000012345
96
+ ```
97
+
98
+ ### Database Integration
99
+
100
+ The gem automatically hooks into these Rails tasks:
101
+ - `db:migrate`
102
+ - `db:schema:load`
103
+ - `db:structure:load`
104
+ - `db:seed`
105
+
106
+ ## Migration from Standard IDs
107
+
108
+ If you have existing models with standard Rails IDs, you'll need to run a migration to convert them to Snowflake IDs.
109
+
110
+ ```ruby
111
+ execute("ALTER TABLE table_name ALTER COLUMN id SET DEFAULT timestamp_id('table_name')")
112
+ ```
113
+
114
+ ⚠️ **Warning**: This is a complex operation that may require downtime and careful planning.
115
+
116
+ ## Requirements
117
+
118
+ - **Database**: PostgreSQL (uses PostgreSQL-specific functions)
119
+ - **Rails**: 7.2+ (may work with earlier versions)
120
+ - **Ruby**: 3.2+
121
+
122
+ ## How it Works
123
+
124
+ 1. **Function Creation**: Creates a PostgreSQL `timestamp_id()` function
125
+ 2. **Sequence Management**: Auto-creates sequences for each table (`table_name_id_seq`)
126
+ 3. **ID Generation**: Uses timestamp + hashed sequence for uniqueness
127
+ 4. **Rails Integration**: Hooks into model lifecycle and database tasks
128
+
129
+
130
+ ## Contributing
131
+
132
+ 1. Fork the repository
133
+ 2. Create your feature branch (`git checkout -b feature/my-new-feature`)
134
+ 3. Add tests for your changes
135
+ 4. Commit your changes (`git commit -am 'Add some feature'`)
136
+ 5. Push to the branch (`git push origin feature/my-new-feature`)
137
+ 6. Create a Pull Request
138
+
139
+ ## License
140
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
141
+
142
+
143
+ ## Acknowledgements
144
+
145
+ The implementation of Snowflake-like ids was initially done by [Mastodon](https://github.com/mastodon/mastodon/blob/06803422da3794538cd9cd5c7ccd61a0694ef921/lib/mastodon/snowflake.rb)
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+ require "rake/testtask"
8
+
9
+ task default: %i[test]
10
+
11
+ Rake::TestTask.new(:test) do |t|
12
+ t.libs << "test"
13
+ t.libs << "lib"
14
+ t.test_files = FileList["test/**/*_test.rb"]
15
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Snowflake
5
+ module ColumnMethods
6
+ def snowflake(name, **options)
7
+ if name == :id && !options[:primary_key]
8
+ raise ArgumentError, "Cannot use t.snowflake :id directly. Use `create_table` with `id: false` and then `t.snowflake :id, primary_key: true`"
9
+ end
10
+
11
+ table_name = @name
12
+
13
+ unless table_name
14
+ raise ArgumentError, "Could not determine table name for Snowflake column. Make sure you're using it within a `create_table` block."
15
+ end
16
+
17
+ options[:default] = -> { "timestamp_id('#{table_name}'::text)" }
18
+
19
+ column(name, :bigint, **options)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hook into Rails database tasks to ensure snowflake sequences exist
4
+ def ensure_snowflake_sequences
5
+ return unless defined?(ActiveRecord::Base)
6
+
7
+ begin
8
+ if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
9
+ Rails.logger.debug "Rails::Snowflake: Ensure sequences exist for `timestamp_id` columns"
10
+ Rails::Snowflake::Id.ensure_id_sequences_exist
11
+ end
12
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
13
+ Rails.logger.warn "Rails::Snowflake: Could not ensure sequences: #{e.message}"
14
+ end
15
+ end
16
+
17
+ # Enhance existing Rails database tasks by adding our hook to them
18
+ if Rake::Task.task_defined?("db:migrate")
19
+ Rake::Task["db:migrate"].enhance do
20
+ ensure_snowflake_sequences
21
+ end
22
+ end
23
+
24
+ if Rake::Task.task_defined?("db:schema:load")
25
+ Rake::Task["db:schema:load"].enhance do
26
+ ensure_snowflake_sequences
27
+ end
28
+ end
29
+
30
+ if Rake::Task.task_defined?("db:structure:load")
31
+ Rake::Task["db:structure:load"].enhance do
32
+ ensure_snowflake_sequences
33
+ end
34
+ end
35
+
36
+ if Rake::Task.task_defined?("db:seed")
37
+ Rake::Task["db:seed"].enhance do
38
+ ensure_snowflake_sequences
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Rails
7
+ module Snowflake
8
+ module Generators
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ActiveRecord::Generators::Migration
11
+
12
+ # Ensure the Thor/Rails namespace is rails_snowflake:install
13
+ def self.namespace(name = nil)
14
+ if name
15
+ super
16
+ else
17
+ @namespace ||= "rails_snowflake:install"
18
+ end
19
+ end
20
+
21
+ TEMPLATES = File.join(File.dirname(__FILE__), "templates")
22
+ source_paths << TEMPLATES
23
+
24
+ desc "Install Rails::Snowflake by creating a migration to setup the timestamp_id function"
25
+
26
+ def create_migration_file
27
+ migration_template "install_snowflake_id.rb.erb", File.join(db_migrate_path, "install_snowflake_id.rb")
28
+ end
29
+
30
+ private
31
+
32
+ def migration_version
33
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InstallSnowflakeId < ActiveRecord::Migration<%= migration_version %>
4
+ def up
5
+ # Create the timestamp_id PostgreSQL function
6
+ Rails::Snowflake::Id.define_timestamp_id
7
+ end
8
+
9
+ def down
10
+ # Remove the timestamp_id function
11
+ execute "DROP FUNCTION IF EXISTS timestamp_id(text)"
12
+ end
13
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copied from https://github.com/mastodon/mastodon/blob/06803422da3794538cd9cd5c7ccd61a0694ef921/lib/mastodon/snowflake.rb
4
+
5
+ module Rails
6
+ module Snowflake
7
+ module Id
8
+ DEFAULT_REGEX = /timestamp_id\('(?<seq_prefix>\w+)'/
9
+
10
+ class Callbacks
11
+ def self.around_create(record)
12
+ now = Time.now.utc
13
+
14
+ if record.created_at.nil? || record.created_at >= now || record.created_at == record.updated_at || record.override_timestamps
15
+ yield
16
+ else
17
+ record.id = Rails::Snowflake::Id.at(record.created_at)
18
+ tries = 0
19
+
20
+ begin
21
+ yield
22
+ rescue ActiveRecord::RecordNotUnique
23
+ raise if tries > 100
24
+
25
+ tries += 1
26
+ record.id += rand(100)
27
+
28
+ retry
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ class << self
35
+ # Our ID will be composed of the following:
36
+ # 6 bytes (48 bits) of millisecond-level timestamp
37
+ # 2 bytes (16 bits) of sequence data
38
+ #
39
+ # The 'sequence data' is intended to be unique within a
40
+ # given millisecond, yet obscure the 'serial number' of
41
+ # this row.
42
+ #
43
+ # To do this, we hash the following data:
44
+ # * Table name (if provided, skipped if not)
45
+ # * Secret salt (should not be guessable)
46
+ # * Timestamp (again, millisecond-level granularity)
47
+ #
48
+ # We then take the first two bytes of that value, and add
49
+ # the lowest two bytes of the table ID sequence number
50
+ # (`table_name`_id_seq). This means that even if we insert
51
+ # two rows at the same millisecond, they will have
52
+ # distinct 'sequence data' portions.
53
+ #
54
+ # If this happens, and an attacker can see both such IDs,
55
+ # they can determine which of the two entries was inserted
56
+ # first, but not the total number of entries in the table
57
+ # (even mod 2**16).
58
+ #
59
+ # The table name is included in the hash to ensure that
60
+ # different tables derive separate sequence bases so rows
61
+ # inserted in the same millisecond in different tables do
62
+ # not reveal the table ID sequence number for one another.
63
+ #
64
+ # The secret salt is included in the hash to ensure that
65
+ # external users cannot derive the sequence base given the
66
+ # timestamp and table name, which would allow them to
67
+ # compute the table ID sequence number.
68
+ def define_timestamp_id
69
+ return if already_defined?
70
+
71
+ connection.execute(sanitized_timestamp_id_sql)
72
+ end
73
+
74
+ def ensure_id_sequences_exist
75
+ # Find tables using timestamp IDs.
76
+ connection.tables.each do |table|
77
+ ensure_id_sequences_exist_for(table)
78
+ end
79
+ end
80
+
81
+ def ensure_id_sequences_exist_for(table_name)
82
+ # We're only concerned with "id" columns.
83
+ id_col = connection.columns(table_name).find { |col| col.name == "id" }
84
+ return unless id_col
85
+
86
+ # And only those that are using timestamp_id.
87
+ data = DEFAULT_REGEX.match(id_col.default_function)
88
+ return unless data
89
+
90
+ seq_name = "#{data[:seq_prefix]}_id_seq"
91
+
92
+ # If we were on Postgres 9.5+, we could do CREATE SEQUENCE IF
93
+ # NOT EXISTS, but we can't depend on that. Instead, catch the
94
+ # possible exception and ignore it.
95
+ # Note that seq_name isn't a column name, but it's a
96
+ # relation, like a column, and follows the same quoting rules
97
+ # in Postgres.
98
+ connection.execute(<<~SQL)
99
+ DO $$
100
+ BEGIN
101
+ CREATE SEQUENCE #{connection.quote_column_name(seq_name)};
102
+ EXCEPTION WHEN duplicate_table THEN
103
+ -- Do nothing, we have the sequence already.
104
+ END
105
+ $$ LANGUAGE plpgsql;
106
+ SQL
107
+ rescue StandardError => e
108
+ Rails.logger.warn "Rails::Snowflake: Could not ensure sequence for #{table_name}: #{e.message}"
109
+ end
110
+
111
+ def at(timestamp, with_random: true)
112
+ id = timestamp.to_i * 1000
113
+ id += rand(1000) if with_random
114
+ id <<= 16
115
+ id += rand(2**16) if with_random
116
+ id
117
+ end
118
+
119
+ def to_time(id)
120
+ Time.at((id >> 16) / 1000).utc
121
+ end
122
+
123
+ private
124
+
125
+ def already_defined?
126
+ connection.execute(<<~SQL.squish).values.first.first
127
+ SELECT EXISTS(
128
+ SELECT * FROM pg_proc WHERE proname = 'timestamp_id'
129
+ );
130
+ SQL
131
+ end
132
+
133
+ def sanitized_timestamp_id_sql
134
+ ActiveRecord::Base.sanitize_sql_array(timestamp_id_sql_array)
135
+ end
136
+
137
+ def timestamp_id_sql_array
138
+ [ timestamp_id_sql_string, { random_string: SecureRandom.hex(16) } ]
139
+ end
140
+
141
+ def timestamp_id_sql_string
142
+ <<~SQL
143
+ CREATE OR REPLACE FUNCTION timestamp_id(table_name text)
144
+ RETURNS bigint AS
145
+ $$
146
+ DECLARE
147
+ time_part bigint;
148
+ sequence_base bigint;
149
+ tail bigint;
150
+ BEGIN
151
+ time_part := (
152
+ -- Get the time in milliseconds
153
+ ((date_part('epoch', now()) * 1000))::bigint
154
+ -- And shift it over two bytes
155
+ << 16);
156
+
157
+ sequence_base := (
158
+ 'x' ||
159
+ -- Take the first two bytes (four hex characters)
160
+ substr(
161
+ -- Of the MD5 hash of the data we documented
162
+ md5(table_name || :random_string || time_part::text),
163
+ 1, 4
164
+ )
165
+ -- And turn it into a bigint
166
+ )::bit(16)::bigint;
167
+
168
+ -- Finally, add our sequence number to our base, and chop
169
+ -- it to the last two bytes
170
+ tail := (
171
+ (sequence_base + nextval(table_name || '_id_seq'))
172
+ & 65535);
173
+
174
+ -- Return the time part and the sequence part. OR appears
175
+ -- faster here than addition, but they're equivalent:
176
+ -- time_part has no trailing two bytes, and tail is only
177
+ -- the last two bytes.
178
+ RETURN time_part | tail;
179
+ END
180
+ $$ LANGUAGE plpgsql VOLATILE;
181
+ SQL
182
+ end
183
+
184
+ def connection
185
+ ActiveRecord::Base.connection
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,21 @@
1
+ module Rails
2
+ module Snowflake
3
+ class Railtie < ::Rails::Railtie
4
+ initializer "snowflake_id.register_field_type" do
5
+ ActiveSupport.on_load(:active_record) do
6
+ ActiveRecord::ConnectionAdapters::TableDefinition.prepend(Rails::Snowflake::ColumnMethods)
7
+
8
+ if defined?(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
9
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:snowflake] = { name: "bigint" }
10
+ else
11
+ raise "Rails::Snowflake: Unsupported database adapter. Only PostgreSQL is supported."
12
+ end
13
+ end
14
+ end
15
+
16
+ rake_tasks do
17
+ load "rails/snowflake/database_tasks.rb"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ module Rails
2
+ module Snowflake
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Manually require all Rails::Snowflake modules
4
+ require_relative "snowflake/version"
5
+ require_relative "snowflake/id"
6
+ require_relative "snowflake/column_methods"
7
+ require_relative "snowflake/railtie"
8
+ require_relative "snowflake/generators/install/install_generator"
9
+
10
+
11
+ module Rails
12
+ module Snowflake
13
+ class Error < StandardError; end
14
+ end
15
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :snowflake_id do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-snowflake
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Luiz Eduardo Kowalski
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: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.2'
26
+ description: A Rails plugin that provides a simple way to generate unique Snowflake
27
+ IDs for your ActiveRecord models.
28
+ email:
29
+ - luizeduardokowalski@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - MIT-LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - lib/rails/snowflake.rb
38
+ - lib/rails/snowflake/column_methods.rb
39
+ - lib/rails/snowflake/database_tasks.rb
40
+ - lib/rails/snowflake/generators/install/install_generator.rb
41
+ - lib/rails/snowflake/generators/install/templates/install_snowflake_id.rb.erb
42
+ - lib/rails/snowflake/id.rb
43
+ - lib/rails/snowflake/railtie.rb
44
+ - lib/rails/snowflake/version.rb
45
+ - lib/tasks/rails/snowflake_tasks.rake
46
+ homepage: https://github.com/luizkowalski/snowflake_id/
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/luizkowalski/snowflake_id/
51
+ source_code_uri: https://github.com/luizkowalski/snowflake_id
52
+ changelog_uri: https://github.com/luizkowalski/snowflake_id/CHANGES.md
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.2'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.7.2
68
+ specification_version: 4
69
+ summary: Database-backed Snowflake IDs for Rails models.
70
+ test_files: []