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