A skeleton web application configured to use Sinatra and ActiveRecord

Initial import

jcs.org f0987bae

+838
+6
.gitignore
··· 1 + .bundle 2 + config/session_secret 3 + db/production/ 4 + db/development.sqlite3 5 + db/test.sqlite3 6 + vendor/
+32
Gemfile
··· 1 + source "https://rubygems.org" 2 + 3 + ruby ">= 2.5.0" 4 + 5 + gem "rack", ">= 2.0.6" 6 + 7 + gem "sinatra", "~> 2.0" 8 + gem "sinatra-contrib", "~> 2.0" 9 + 10 + gem "rack_csrf" 11 + 12 + gem "activerecord", "~> 5.2" 13 + gem "sinatra-activerecord", "~> 2.0" 14 + 15 + gem "sqlite3" 16 + 17 + # for mysql connections 18 + #gem "mysql2" 19 + 20 + gem "unicorn" 21 + gem "json" 22 + 23 + gem "bcrypt" 24 + 25 + # for development 26 + gem "shotgun" 27 + gem "irb", :require => false 28 + 29 + # for testing 30 + gem "rake" 31 + gem "minitest" 32 + gem "rack-test"
+92
Gemfile.lock
··· 1 + GEM 2 + remote: https://rubygems.org/ 3 + specs: 4 + activemodel (5.2.4.3) 5 + activesupport (= 5.2.4.3) 6 + activerecord (5.2.4.3) 7 + activemodel (= 5.2.4.3) 8 + activesupport (= 5.2.4.3) 9 + arel (>= 9.0) 10 + activesupport (5.2.4.3) 11 + concurrent-ruby (~> 1.0, >= 1.0.2) 12 + i18n (>= 0.7, < 2) 13 + minitest (~> 5.1) 14 + tzinfo (~> 1.1) 15 + arel (9.0.0) 16 + backports (3.18.1) 17 + bcrypt (3.1.13) 18 + concurrent-ruby (1.1.6) 19 + i18n (1.8.3) 20 + concurrent-ruby (~> 1.0) 21 + io-console (0.5.6) 22 + irb (1.2.4) 23 + reline (>= 0.0.1) 24 + json (2.3.1) 25 + kgio (2.11.3) 26 + minitest (5.14.1) 27 + multi_json (1.15.0) 28 + mustermann (1.1.1) 29 + ruby2_keywords (~> 0.0.1) 30 + rack (2.2.3) 31 + rack-protection (2.0.8.1) 32 + rack 33 + rack-test (1.1.0) 34 + rack (>= 1.0, < 3) 35 + rack_csrf (2.6.0) 36 + rack (>= 1.1.0) 37 + raindrops (0.19.1) 38 + rake (13.0.1) 39 + reline (0.1.4) 40 + io-console (~> 0.5) 41 + ruby2_keywords (0.0.2) 42 + shotgun (0.9.2) 43 + rack (>= 1.0) 44 + sinatra (2.0.8.1) 45 + mustermann (~> 1.0) 46 + rack (~> 2.0) 47 + rack-protection (= 2.0.8.1) 48 + tilt (~> 2.0) 49 + sinatra-activerecord (2.0.18) 50 + activerecord (>= 4.1) 51 + sinatra (>= 1.0) 52 + sinatra-contrib (2.0.8.1) 53 + backports (>= 2.8.2) 54 + multi_json 55 + mustermann (~> 1.0) 56 + rack-protection (= 2.0.8.1) 57 + sinatra (= 2.0.8.1) 58 + tilt (~> 2.0) 59 + sqlite3 (1.4.2) 60 + thread_safe (0.3.6) 61 + tilt (2.0.10) 62 + tzinfo (1.2.7) 63 + thread_safe (~> 0.1) 64 + unicorn (5.5.5) 65 + kgio (~> 2.6) 66 + raindrops (~> 0.7) 67 + 68 + PLATFORMS 69 + ruby 70 + 71 + DEPENDENCIES 72 + activerecord (~> 5.2) 73 + bcrypt 74 + irb 75 + json 76 + minitest 77 + rack (>= 2.0.6) 78 + rack-test 79 + rack_csrf 80 + rake 81 + shotgun 82 + sinatra (~> 2.0) 83 + sinatra-activerecord (~> 2.0) 84 + sinatra-contrib (~> 2.0) 85 + sqlite3 86 + unicorn 87 + 88 + RUBY VERSION 89 + ruby 2.6.6p146 90 + 91 + BUNDLED WITH 92 + 1.17.2
+80
README.md
··· 1 + ## sinatree 2 + 3 + A skeleton web application configured to use Sinatra and ActiveRecord with 4 + some simple conventions: 5 + 6 + - `views` directory set to `app/views/(current controller name)` 7 + 8 + - default layout configured to `app/views/layouts/application.erb`, with 9 + per-controller layouts `app/views/layouts/(current controller name).erb` 10 + used first 11 + 12 + - database tables using non-auto-incrementing IDs (see 13 + [`UniqueId`](https://github.com/jcs/sinatree/blob/master/lib/unique_id.rb)) 14 + 15 + ### Usage 16 + 17 + Clone `sinatree`: 18 + 19 + git clone https://github.com/jcs/sinatree.git 20 + 21 + Then install Bundler dependencies: 22 + 23 + bundle install --path vendor/bundle 24 + 25 + To create a database table `users` for a new `User` model: 26 + 27 + $EDITOR `bundle exec rake db:create_migration NAME=create_user_model` 28 + 29 + class CreateUserModel < ActiveRecord::Migration[5.2] 30 + def change 31 + create_table :users do |t| 32 + t.timestamps 33 + t.string :username 34 + t.string :password_digest 35 + end 36 + end 37 + end 38 + 39 + Then run the database migrations: 40 + 41 + bundle exec rake db:migrate 42 + 43 + The new `User` model can be created as `app/models/user.rb`: 44 + 45 + class User < DBModel 46 + has_secure_password 47 + end 48 + 49 + A root controller can be created as `app/controllers/home_controller.rb`: 50 + class HomeController < ApplicationController 51 + self.path = :root 52 + 53 + get "/" do 54 + "Hello, world" 55 + end 56 + end 57 + 58 + To run a web server with your application: 59 + 60 + bin/server 61 + 62 + To access an IRB console: 63 + 64 + bin/console 65 + 66 + ### License 67 + 68 + Copyright (c) 2017-2020 joshua stein `<jcs@jcs.org>` 69 + 70 + Permission to use, copy, modify, and distribute this software for any 71 + purpose with or without fee is hereby granted, provided that the above 72 + copyright notice and this permission notice appear in all copies. 73 + 74 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 75 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 76 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 77 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 78 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 79 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 80 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+33
Rakefile
··· 1 + # 2 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + # rake db:create_migration NAME=... 18 + require "sinatra/activerecord/rake" 19 + 20 + namespace :db do 21 + task :load_config do 22 + require "./lib/app.rb" 23 + end 24 + end 25 + 26 + require "rake/testtask" 27 + 28 + Rake::TestTask.new do |t| 29 + t.libs << "spec" 30 + t.pattern = "spec/*_spec.rb" 31 + end 32 + 33 + task :default => [ :test ]
+3
app/controllers/application_controller.rb
··· 1 + class ApplicationController < App 2 + use Rack::Csrf, :raise => true 3 + end
app/models/.gitkeep

This is a binary file and will not be displayed.

+10
app/views/layouts/application.erb
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Hello, World</title> 6 + </head> 7 + <body> 8 + <%= yield %> 9 + </body> 10 + </html>
+20
bin/console
··· 1 + #!/bin/sh 2 + # 3 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 4 + # 5 + # Permission to use, copy, modify, and distribute this software for any 6 + # purpose with or without fee is hereby granted, provided that the above 7 + # copyright notice and this permission notice appear in all copies. 8 + # 9 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 + # 17 + 18 + P=`dirname $0` 19 + 20 + bundle exec irb -I $P/.. -r $P/../lib/app.rb
+20
bin/server
··· 1 + #!/bin/sh 2 + # 3 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 4 + # 5 + # Permission to use, copy, modify, and distribute this software for any 6 + # purpose with or without fee is hereby granted, provided that the above 7 + # copyright notice and this permission notice appear in all copies. 8 + # 9 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 + # 17 + 18 + P=`dirname $0` 19 + 20 + bundle exec shotgun --port 4567 $P/../config.ru
+32
config.ru
··· 1 + # 2 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + if ENV["APP_ENV"] == "production" 18 + # prevent printing stack traces and other nonsense 19 + ENV["RACK_ENV"] = "deployment" 20 + end 21 + 22 + require File.realpath(__dir__) + "/lib/app.rb" 23 + 24 + App.all_routes.reject{|_k,_v| _k == :root }.each do |_k,_v| 25 + map(_k) { run _v } 26 + end 27 + 28 + if !(root_route = App.all_routes.select{|_k,_v| _k == :root }.values.first) 29 + raise "no root route (set \"self.path = :root\" in root controller)" 30 + end 31 + 32 + run root_route
config/.gitkeep

This is a binary file and will not be displayed.

db/.gitkeep

This is a binary file and will not be displayed.

+19
db/config.yml
··· 1 + development: 2 + adapter: sqlite3 3 + database: db/development.sqlite3 4 + pool: 5 5 + timeout: 5000 6 + 7 + test: 8 + adapter: sqlite3 9 + database: db/test.sqlite3 10 + pool: 5 11 + timeout: 5000 12 + 13 + # in db/production so that directory can be owned by the uid:gid running the 14 + # app in the production environment 15 + production: 16 + adapter: sqlite3 17 + database: db/production/production.sqlite3 18 + pool: 5 19 + timeout: 5000
db/migrate/.gitkeep

This is a binary file and will not be displayed.

+179
lib/app.rb
··· 1 + # 2 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + Encoding.default_internal = Encoding.default_external = Encoding::UTF_8 18 + 19 + APP_ROOT = File.realpath(File.dirname(__FILE__) + "/../") 20 + 21 + require "active_record" 22 + require "active_support/time" 23 + 24 + require "sinatra/base" 25 + require "sinatra/namespace" 26 + require "sinatra/activerecord" 27 + require "cgi" 28 + require "rack/csrf" 29 + 30 + class Sinatra::Base 31 + register Sinatra::Namespace 32 + register Sinatra::ActiveRecordExtension 33 + 34 + @@logger = nil 35 + 36 + set :root, File.realpath(__dir__ + "/../") 37 + 38 + def self.cur_controller 39 + raise 40 + rescue => e 41 + e.backtrace.each do |z| 42 + if m = z.match(/app\/controllers\/(.+?)_controller\.rb:/) 43 + return m[1] 44 + end 45 + end 46 + 47 + nil 48 + end 49 + 50 + # app/views/(controller)/ 51 + set :views, Proc.new { App.root + "/app/views/#{cur_controller}/" } 52 + 53 + # app/views/layouts/(controller).erb or app/views/layouts/application.erb 54 + set :erb, :layout => Proc.new { 55 + @@layouts ||= {} 56 + cc = cur_controller 57 + 58 + if File.exists?(f = App.root + "/app/views/layouts/#{cc}.erb") 59 + @@layouts[cc] ||= File.read(f) 60 + else 61 + @@layouts["application"] ||= File.read(App.root + 62 + "/app/views/layouts/application.erb") 63 + end 64 + } 65 + 66 + configure do 67 + # store timestamps in the db in UTC, but convert to Time.zone time when 68 + # instantiating objects 69 + Time.zone = "Central Time (US & Canada)" 70 + ActiveRecord::Base.time_zone_aware_attributes = true 71 + 72 + enable :logging 73 + ActiveSupport::LogSubscriber.colorize_logging = false 74 + @@logger = ::Logger.new(STDOUT) 75 + use Rack::CommonLogger, @@logger 76 + 77 + enable :sessions 78 + begin 79 + set :session_secret, File.read("#{self.root}/config/session_secret") 80 + rescue => e 81 + STDERR.puts e.message 82 + STDERR.puts "no session secret file", 83 + "ruby -e 'require \"securerandom\"; " + 84 + "print SecureRandom.hex(64)' > config/session_secret" 85 + exit 1 86 + end 87 + 88 + # allow erb views to be named view.html.erb 89 + Tilt.register Tilt::ERBTemplate, "html.erb" 90 + end 91 + 92 + singleton_class.send(:alias_method, :env, :environment) 93 + 94 + def self.production? 95 + self.env.to_s == "production" 96 + end 97 + def self.development? 98 + self.env.to_s == "development" 99 + end 100 + def self.test? 101 + self.env.to_s == "test" 102 + end 103 + end 104 + 105 + class App < Sinatra::Base 106 + @@all_routes = {} 107 + cattr_accessor :all_routes 108 + 109 + class << self 110 + attr_accessor :path 111 + end 112 + 113 + def self.logger 114 + @@logger 115 + end 116 + 117 + def flash 118 + session[:flash] ||= {} 119 + end 120 + end 121 + 122 + if ENV["APP_ENV"] 123 + App.env = ENV["APP_ENV"] 124 + end 125 + 126 + # bring in models 127 + require "#{App.root}/lib/db.rb" 128 + require "#{App.root}/lib/db_model.rb" 129 + Dir.glob("#{App.root}/app/models/*.rb").each{|f| require f } 130 + 131 + # and helpers 132 + require "#{App.root}/lib/helpers.rb" 133 + Dir.glob("#{App.root}/app/helpers/*.rb").each do |f| 134 + require f 135 + end 136 + 137 + # and controllers, binding each's helper 138 + ( 139 + [ "#{App.root}/app/controllers/application_controller.rb" ] + 140 + Dir.glob("#{App.root}/app/controllers/*.rb") 141 + ).uniq.each do |f| 142 + mc = Module.constants 143 + require f 144 + newmc = (Module.constants - mc) 145 + 146 + if newmc.count != 1 147 + raise "#{f} introduced #{newmc.count} new classes instead of 1" 148 + end 149 + cont = newmc[0] 150 + 151 + if Kernel.const_defined?(c = cont.to_s.gsub(/Controller$/, "Helper")) 152 + Kernel.const_get(cont).send(:helpers, Kernel.const_get(c)) 153 + end 154 + end 155 + 156 + # and extras 157 + Dir.glob("#{App.root}/app/extras/*.rb").each{|f| require f } 158 + 159 + # bring up active record connection 160 + Db.connect(:environment => App.env.to_s) 161 + 162 + # dynamically build routes for config.ru 163 + ApplicationController.subclasses.each do |subklass| 164 + path = subklass.path 165 + if !path 166 + # WidgetsController -> "widgets" 167 + path = "/" << ActiveSupport::Inflector.underscore( 168 + subklass.to_s.gsub(/Controller$/, "")) 169 + end 170 + 171 + if App.all_routes[path] 172 + raise "duplicate route for #{path.inspect}: " << 173 + "#{App.all_routes[path].inspect} and #{subklass.inspect}" 174 + end 175 + 176 + App.all_routes[path] = subklass 177 + end 178 + 179 + # add local app configuration here
+23
lib/db.rb
··· 1 + # 2 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + class Db 18 + def self.connect(environment:) 19 + dbconfig = YAML.load(File.read(App.root + "/db/config.yml")) 20 + ActiveRecord::Base.dump_schema_after_migration = false 21 + ActiveRecord::Base.establish_connection dbconfig[environment] 22 + end 23 + end
+34
lib/db_model.rb
··· 1 + # 2 + # Copyright (c) 2017-2018 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + require "#{App.root}/lib/unique_id.rb" 18 + 19 + class DBModel < ActiveRecord::Base 20 + self.inheritance_column = "inheritance_type" 21 + self.abstract_class = true 22 + 23 + before_validation :generate_id, 24 + :on => :create 25 + validates :id, 26 + :presence => true, :uniqueness => true 27 + 28 + protected 29 + def generate_id 30 + if self.id.blank? 31 + self.id = UniqueId.get 32 + end 33 + end 34 + end
+11
lib/helpers.rb
··· 1 + module Sinatra 2 + module HTMLEscapeHelper 3 + def h(text) 4 + Rack::Utils.escape_html(text) 5 + end 6 + end 7 + 8 + class Base 9 + helpers Sinatra::HTMLEscapeHelper 10 + end 11 + end
+124
lib/unique_id.rb
··· 1 + # 2 + # Copyright (c) 2019 joshua stein <jcs@jcs.org> 3 + # 4 + # Permission to use, copy, modify, and distribute this software for any 5 + # purpose with or without fee is hereby granted, provided that the above 6 + # copyright notice and this permission notice appear in all copies. 7 + # 8 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 + # 16 + 17 + class UniqueId 18 + # 19 + # (roughly) time-sortable, incrementing ids that don't require an 20 + # auto_increment database table and will not collide between processes and 21 + # servers 22 + # 23 + # uses 64 bits, the size of a bigint in mysql 24 + # 25 + # [0, 32] = (time.to_f - EPOCH) * 10 26 + # [32, 4] = node id, 0 to 15 27 + # [36, 16] = pid 28 + # [52, 12] = sequence, 0 to 4095 29 + # 30 + # this gives us 4096 ids per second, per pid, per node/server 31 + # sequences are shared among threads in a process 32 + # 33 + 34 + # jan 1, 2019 00:00:00 +0000 35 + EPOCH = 1546300800 36 + 37 + LENGTHS = { 38 + :time => 32, 39 + :node => 4, 40 + :pid => 16, 41 + :sequence => 12, 42 + }.freeze 43 + 44 + attr_reader :binary, :time, :node, :pid, :sequence 45 + 46 + def self.build_binary(time, node, pid, seq) 47 + r = sprintf("%0#{LENGTHS[:time]}b%0#{LENGTHS[:node]}b" << 48 + "%0#{LENGTHS[:pid]}b%0#{LENGTHS[:sequence]}b", time, node, pid, seq) 49 + 50 + if r.length != 64 51 + raise "created invalid length binary #{r.inspect} (#{r.length})" 52 + end 53 + 54 + r 55 + end 56 + 57 + def self.get 58 + self.get_binary.to_i(2) 59 + end 60 + 61 + def self.get_binary 62 + self.build_binary((Time.now.to_i - EPOCH), self.node, 63 + self.truncate_pid($$), self.sequence) 64 + end 65 + 66 + def self.node 67 + class_variable_defined?("@@node") ? @@node : 0 68 + end 69 + def self.node=(what) 70 + @@node = what.to_i 71 + end 72 + 73 + def self.sequence 74 + ret = 0 75 + 76 + (@@sequence_mutex ||= Mutex.new).synchronize do 77 + @@sequence ||= 0 78 + 79 + if @@sequence >= (2 ** LENGTHS[:sequence]) - 1 80 + ret = @@sequence = 0 81 + else 82 + ret = (@@sequence += 1) 83 + end 84 + end 85 + 86 + ret 87 + end 88 + 89 + def self.parse(i) 90 + UniqueId.new(i) 91 + end 92 + 93 + def self.truncate_pid(pid) 94 + # pid is technically 17 bits on openbsd, capped at 99999, so cap at 16 bits 95 + pid = pid.to_s(2) 96 + if pid.length > LENGTHS[:pid] 97 + pid = pid[pid.length - LENGTHS[:pid], LENGTHS[:pid]] 98 + end 99 + pid.to_i(2) 100 + end 101 + 102 + def initialize(i) 103 + if !i || i > (2 ** 64) - 1 || i < 0 104 + raise "invalid id #{i}" 105 + end 106 + 107 + @binary = sprintf("%064b", i) 108 + 109 + @time = Time.at(EPOCH + @binary[0, LENGTHS[:time]].to_i(2)) 110 + z = LENGTHS[:time] 111 + 112 + @node = @binary[z, LENGTHS[:node]].to_i(2) 113 + z += LENGTHS[:node] 114 + 115 + @pid = @binary[z, LENGTHS[:pid]].to_i(2) 116 + z += LENGTHS[:pid] 117 + 118 + @sequence = @binary[z, LENGTHS[:sequence]].to_i(2) 119 + end 120 + 121 + def to_i 122 + @binary.to_i(2) 123 + end 124 + end
public/.gitkeep

This is a binary file and will not be displayed.

spec/fixtures/.gitkeep

This is a binary file and will not be displayed.

+19
spec/spec_helper.rb
··· 1 + require "minitest/autorun" 2 + require "rack/test" 3 + 4 + ENV["APP_ENV"] = "test" 5 + 6 + require File.realpath(File.dirname(__FILE__) + "/../lib/app.rb") 7 + 8 + if File.exist?(_f = ActiveRecord::Base.connection_config[:database]) 9 + File.unlink(_f) 10 + end 11 + 12 + ActiveRecord::Migration.verbose = false 13 + ActiveRecord::Base.connection.migration_context.migrate 14 + 15 + include Rack::Test::Methods 16 + 17 + def app 18 + Rubywarden::App 19 + end
+101
spec/unique_id_spec.rb
··· 1 + require_relative "spec_helper.rb" 2 + 3 + describe UniqueId do 4 + it "gives an id" do 5 + t = Time.now 6 + sleep 0.6 7 + 8 + oseq = UniqueId.sequence 9 + 10 + UniqueId.node = 5 11 + uid = UniqueId.get 12 + assert uid 13 + 14 + sleep 0.6 15 + t2 = Time.now 16 + 17 + uobj = UniqueId.new(uid) 18 + assert uobj 19 + 20 + assert_operator uobj.time, :>=, t 21 + assert_operator uobj.time, :<, t2 22 + 23 + assert_equal uobj.node, 5 24 + 25 + pid = UniqueId.truncate_pid($$) 26 + assert_operator pid, :>, 0 27 + assert_equal uobj.pid, pid 28 + 29 + assert_equal uobj.sequence, oseq + 1 30 + end 31 + 32 + it "parses an id" do 33 + u = UniqueId.parse(60449109262819330) 34 + assert_equal u.binary, 35 + "0000000011010110110000100010010100000100100110010110000000000010" 36 + assert_equal u.node, 0 37 + assert_equal u.pid, 18838 38 + assert_equal u.sequence, 2 39 + end 40 + 41 + it "handles big times" do 42 + b = UniqueId.build_binary( 43 + Time.parse("2040-02-03 04:05:06").to_i - UniqueId::EPOCH, 44 + 1, 45 + 65535, 46 + 1234) 47 + u = UniqueId.new(b.to_i(2)) 48 + assert_equal u.time.year, 2040 49 + end 50 + 51 + it "does not collide" do 52 + threadids = {} 53 + allids = {} 54 + threads = [] 55 + 56 + 10.times do |x| 57 + threads.push Thread.new { 58 + reader, writer = IO.pipe("binary", :binmode => true) 59 + writer.set_encoding("binary") 60 + 61 + fork do 62 + reader.close 63 + ids = (2 ** UniqueId::LENGTHS[:sequence]).times.map{ UniqueId.get } 64 + writer.write(ids.join(",")) 65 + exit!(0) 66 + end 67 + 68 + writer.close 69 + 70 + ids = reader.read.split(",").map{|i| i.to_i } 71 + threadids[x] = ids 72 + } 73 + end 74 + 75 + threads.each{|z| z.join } 76 + 77 + assert_equal threadids.keys.count, 10 78 + 79 + threadids.each do |k,v| 80 + v.each_with_index do |iv,ik| 81 + if ik == 0 82 + next 83 + end 84 + 85 + assert_equal UniqueId.new(iv).time.year, Time.now.year 86 + 87 + if UniqueId.new(iv).time.to_i < UniqueId.new(v[ik - 1]).time.to_i 88 + raise "#{iv} is not >= #{v[ik - 1]}" 89 + end 90 + end 91 + 92 + v.each_with_index do |i,x| 93 + if allids[i] 94 + raise "collision of #{i}! (thread #{k}, index #{x})" 95 + else 96 + allids[i] = true 97 + end 98 + end 99 + end 100 + end 101 + end