hamster-web 0.1.0.pre.2

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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +61 -0
  4. data/exe/hamster +24 -0
  5. data/lib/hamster/build/manifest.rb +53 -0
  6. data/lib/hamster/build/postgres_compatibility.rb +38 -0
  7. data/lib/hamster/build/query_accessors.rb +73 -0
  8. data/lib/hamster/build/view_compiler.rb +239 -0
  9. data/lib/hamster/cli.rb +166 -0
  10. data/lib/hamster/console.rb +34 -0
  11. data/lib/hamster/data/persistence.rb +152 -0
  12. data/lib/hamster/data/query.rb +106 -0
  13. data/lib/hamster/data/read/compiler.rb +101 -0
  14. data/lib/hamster/data/read/decoder.rb +101 -0
  15. data/lib/hamster/data/read/nodes.rb +118 -0
  16. data/lib/hamster/data/read/relation.rb +198 -0
  17. data/lib/hamster/data/read/schema.rb +40 -0
  18. data/lib/hamster/data/read/validator.rb +273 -0
  19. data/lib/hamster/data/record.rb +81 -0
  20. data/lib/hamster/data/table.rb +90 -0
  21. data/lib/hamster/data.rb +21 -0
  22. data/lib/hamster/database/admin.rb +39 -0
  23. data/lib/hamster/database/configuration.rb +84 -0
  24. data/lib/hamster/database/connection.rb +168 -0
  25. data/lib/hamster/database/database.rb +92 -0
  26. data/lib/hamster/database/errors.rb +21 -0
  27. data/lib/hamster/database/pool.rb +98 -0
  28. data/lib/hamster/database/postgres.rb +66 -0
  29. data/lib/hamster/database/result.rb +30 -0
  30. data/lib/hamster/schema_validation.rb +12 -0
  31. data/lib/hamster/web/action/base.rb +39 -0
  32. data/lib/hamster/web/application.rb +23 -0
  33. data/lib/hamster/web/context.rb +25 -0
  34. data/lib/hamster/web/html.rb +80 -0
  35. data/lib/hamster/web/http.rb +34 -0
  36. data/lib/hamster/web/instrumentation.rb +28 -0
  37. data/lib/hamster/web/router.rb +27 -0
  38. data/lib/hamster/web/routing/base.rb +131 -0
  39. data/lib/hamster/web/version.rb +7 -0
  40. data/lib/hamster/web/view/form.rb +119 -0
  41. data/lib/hamster/web/view.rb +196 -0
  42. data/lib/hamster/web.rb +13 -0
  43. data/templates/app/.gitignore +5 -0
  44. data/templates/app/AGENTS.md +8 -0
  45. data/templates/app/Gemfile +3 -0
  46. data/templates/app/README.md +32 -0
  47. data/templates/app/app/routes/home/index/action.rb +14 -0
  48. data/templates/app/app/routes/home/index/view.html.erb +2 -0
  49. data/templates/app/app/routes/mappings.rb +8 -0
  50. data/templates/app/app/views/layouts/application.html.erb +3 -0
  51. data/templates/app/bin/hamster +4 -0
  52. data/templates/app/bin/server +8 -0
  53. data/templates/app/config/application.rb +23 -0
  54. data/templates/app/config/commands.rb +16 -0
  55. data/templates/app/config/database.rb +20 -0
  56. data/templates/app/config/hamster.json +1 -0
  57. data/templates/app/db/migrations/.keep +0 -0
  58. data/templates/app/spin.lock +6 -0
  59. data/templates/app/spin.toml +2 -0
  60. metadata +133 -0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ module Record
5
+ class Error < StandardError
6
+ attr_reader :result
7
+
8
+ def initialize(result)
9
+ @result = result
10
+ super(result.errors.join(", "))
11
+ end
12
+ end
13
+
14
+ class ValidationError < Error; end
15
+ class ConflictError < Error; end
16
+ class NotFoundError < Error; end
17
+ class PersistenceError < Error; end
18
+ class MissingConnection < StandardError; end
19
+
20
+ class Result
21
+ attr_reader :record, :code, :errors, :cause
22
+
23
+ def initialize(record:, code: nil, errors: [], cause: nil)
24
+ @record = record
25
+ @code = code
26
+ @errors = errors
27
+ @cause = cause
28
+ end
29
+
30
+ def success?
31
+ code.nil?
32
+ end
33
+
34
+ def value!
35
+ return record if success?
36
+
37
+ error_class = case code
38
+ when :invalid then ValidationError
39
+ when :conflict then ConflictError
40
+ when :not_found then NotFoundError
41
+ else PersistenceError
42
+ end
43
+ raise error_class.new(self)
44
+ end
45
+ end
46
+
47
+ def self.with_connection(connection)
48
+ previous = Thread.current[:hamster_record_connection]
49
+ Thread.current[:hamster_record_connection] = connection
50
+ yield
51
+ ensure
52
+ Thread.current[:hamster_record_connection] = previous
53
+ end
54
+
55
+ def self.connection
56
+ Thread.current[:hamster_record_connection] ||
57
+ raise(MissingConnection, "Bind a database connection with Hamster::Record.with_connection")
58
+ end
59
+
60
+
61
+ def initialize(attributes = nil, **keywords)
62
+ source = attributes || keywords
63
+ @attributes = {}
64
+ source.each { |key, value| @attributes[key.to_sym] = value }
65
+ @partial = !attributes.nil?
66
+ end
67
+
68
+ def [](name)
69
+ key = name.to_sym
70
+ raise MissingAttributeError, "Unloaded attribute #{key}" if @partial && !@attributes.key?(key)
71
+ raise MissingAttributeError, "Undeclared attribute #{key}" unless self.class.columns.key?(key)
72
+ @attributes[key]
73
+ end
74
+
75
+ def write_attribute(name, value)
76
+ @attributes[name.to_sym] = value
77
+ end
78
+ end
79
+
80
+ class MissingAttributeError < StandardError; end
81
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ class Table
5
+ include Record
6
+ include Persistence
7
+ extend Persistence::ClassMethods
8
+
9
+ REGISTRY = []
10
+
11
+ def self.inherited(child)
12
+ child.set_metadata(table_name, schema_name, columns) unless self == Table
13
+ REGISTRY << child
14
+ end
15
+
16
+ def self.set_metadata(name, schema, definitions)
17
+ @table_name = name
18
+ @schema_name = schema
19
+ @columns = definitions.dup
20
+ end
21
+
22
+ def self.registry
23
+ REGISTRY.dup.freeze
24
+ end
25
+
26
+ def self.table(name, schema: "public")
27
+ @table_name = Read.identifier(name)
28
+ @schema_name = Read.identifier(schema)
29
+ end
30
+
31
+ def self.table_name
32
+ @table_name || name.split("::").last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase + "s"
33
+ end
34
+
35
+ def self.schema_name
36
+ @schema_name || "public"
37
+ end
38
+
39
+ def self.columns
40
+ (@columns || {}).dup.freeze
41
+ end
42
+
43
+ def self.column(name, type:, nullable: true, enabled: true, write: :none)
44
+ register_column(name, type: type, nullable: nullable, enabled: enabled, write: write)
45
+ # Spinel expands these names from literal column declarations at compile time.
46
+ if RUBY_ENGINE == "ruby"
47
+ define_method("#{name}") { self[name] }
48
+ define_method("#{name}=") { |value| write_attribute(name, value) }
49
+ end
50
+ end
51
+
52
+ def self.register_column(name, type:, nullable:, enabled:, write:)
53
+ raise Read::ValidationError, "Unsupported column type #{type}" unless Read::TYPES.key?(type)
54
+ unless [true, false].include?(nullable) && [true, false].include?(enabled)
55
+ raise Read::ValidationError, "Column flags must be boolean"
56
+ end
57
+ unless [:none, :create, :update, :both].include?(write)
58
+ raise Read::ValidationError, "Column write must be :none, :create, :update, or :both"
59
+ end
60
+ key = Read.identifier(name).to_sym
61
+ unless key.to_s.match?(/\A[a-z_][a-z0-9_]*\z/)
62
+ raise Read::ValidationError, "Column #{key} cannot be used as a record accessor"
63
+ end
64
+ if Table.method_defined?(key) || Table.private_method_defined?(key) ||
65
+ Table.method_defined?("#{key}=") || Table.private_method_defined?("#{key}=")
66
+ raise Read::ValidationError, "Column #{key} would shadow a record method"
67
+ end
68
+ @columns = columns.merge(key => { type: type, nullable: nullable, enabled: enabled, write: write }.freeze)
69
+ end
70
+
71
+ def self.[](name); Read::Source.new(self)[name]; end
72
+ def self.as(name); Read::Source.new(self, name); end
73
+ def self.all; Read::Source.new(self).all; end
74
+ def self.first(connection: nil); all.first(connection: connection); end
75
+ def self.last(connection: nil); all.last(connection: connection); end
76
+ def self.count(connection: nil); all.count(connection: connection); end
77
+ def self.select(*items); all.select(*items); end
78
+ def self.reselect(*items); all.reselect(*items); end
79
+ def self.group(*items); all.group(*items); end
80
+ def self.order(*items); all.order(*items); end
81
+ def self.reorder(*items); all.reorder(*items); end
82
+ def self.where(value); all.where(value); end
83
+ def self.having(value); all.having(value); end
84
+ def self.limit(value); all.limit(value); end
85
+ def self.offset(value); all.offset(value); end
86
+ def self.distinct(enabled = true); all.distinct(enabled); end
87
+ def self.joins(source, on:); all.joins(source, on: on); end
88
+ def self.left_joins(source, on:); all.left_joins(source, on: on); end
89
+ end
90
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "web/instrumentation"
5
+ require_relative "database/errors"
6
+ require_relative "database/configuration"
7
+ require_relative "database/result"
8
+ require_relative "database/connection"
9
+ require_relative "database/pool"
10
+ require_relative "database/admin"
11
+ require_relative "database/database"
12
+ require_relative "data/record"
13
+ require_relative "data/persistence"
14
+ require_relative "data/read/nodes"
15
+ require_relative "data/read/relation"
16
+ require_relative "data/read/validator"
17
+ require_relative "data/read/compiler"
18
+ require_relative "data/read/decoder"
19
+ require_relative "data/table"
20
+ require_relative "data/query"
21
+ require_relative "data/read/schema"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ class Database
5
+ class Admin
6
+ def self.create!(configuration)
7
+ connection = Connection.new(Postgres.connect(admin_configuration(configuration)))
8
+ existing = connection.execute("SELECT 1 FROM pg_database WHERE datname = $1", [configuration.database])
9
+ return false unless existing.rows.empty?
10
+
11
+ connection.execute_script("CREATE DATABASE #{quote_identifier(configuration.database)}")
12
+ true
13
+ ensure
14
+ connection&.close
15
+ end
16
+
17
+ def self.admin_configuration(configuration)
18
+ Configuration.new(
19
+ adapter: configuration.adapter,
20
+ host: configuration.host,
21
+ port: configuration.port,
22
+ database: configuration.admin_database,
23
+ username: configuration.admin_username,
24
+ password: configuration.admin_password,
25
+ pool_size: 1,
26
+ checkout_timeout: configuration.checkout_timeout
27
+ )
28
+ end
29
+
30
+ def self.quote_identifier(identifier)
31
+ raise ConfigurationError, "unsafe database name" unless identifier.match?(/\A[a-z_][a-z0-9_]*\z/)
32
+
33
+ "\"#{identifier}\""
34
+ end
35
+
36
+ private_class_method :admin_configuration, :quote_identifier
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ class Database
5
+ class Configuration
6
+ attr_reader :adapter, :host, :port, :database, :username, :password,
7
+ :pool_size, :checkout_timeout, :admin_database,
8
+ :admin_username, :admin_password
9
+
10
+ def initialize(adapter:, host:, port:, database:, username:, password:, pool_size:, checkout_timeout:,
11
+ admin_database: "postgres", admin_username: nil, admin_password: nil)
12
+ @adapter = adapter.to_s
13
+ @host = host.to_s
14
+ @port = integer_value(port, "port")
15
+ @database = database.to_s
16
+ @username = username.to_s
17
+ @password = password.to_s
18
+ @pool_size = integer_value(pool_size, "pool_size")
19
+ @checkout_timeout = float_value(checkout_timeout, "checkout_timeout")
20
+ @admin_database = admin_database.to_s
21
+ @admin_username = (admin_username || username).to_s
22
+ @admin_password = (admin_password || password).to_s
23
+ validate!
24
+ end
25
+
26
+ def self.from_env(env = ENV, app_name: "hamster")
27
+ if env.key?("DATABASE_TLS") || env.key?("DATABASE_SSLMODE")
28
+ raise ConfigurationError, "database TLS settings are unsupported by the postgres adapter"
29
+ end
30
+
31
+ adapter = env.fetch("DATABASE_ADAPTER", "postgres").dup
32
+ host = env.fetch("DATABASE_HOST", "127.0.0.1").dup
33
+ port = env.fetch("DATABASE_PORT", "5432").dup
34
+ app_name = env.fetch("HAMSTER_APP_NAME", app_name).dup
35
+ environment = env.fetch("HAMSTER_ENV", "development").dup
36
+ database = env.fetch("DATABASE_NAME", "#{app_name}_#{environment}").dup
37
+ username = env.fetch("DATABASE_USERNAME").dup
38
+ password = env.fetch("DATABASE_PASSWORD", "").dup
39
+ admin_database = env.fetch("DATABASE_ADMIN_NAME", "postgres").dup
40
+ admin_username = env.fetch("DATABASE_ADMIN_USERNAME", username).dup
41
+ admin_password = env.fetch("DATABASE_ADMIN_PASSWORD", password).dup
42
+ pool_size = env.fetch("DATABASE_POOL_SIZE", "5").dup
43
+ checkout_timeout = env.fetch("DATABASE_CHECKOUT_TIMEOUT", "5.0").dup
44
+ new(
45
+ adapter: adapter, host: host, port: port, database: database,
46
+ username: username, password: password, pool_size: pool_size,
47
+ checkout_timeout: checkout_timeout, admin_database: admin_database,
48
+ admin_username: admin_username, admin_password: admin_password
49
+ )
50
+ rescue KeyError => error
51
+ raise ConfigurationError, "missing database setting #{error.key}"
52
+ end
53
+
54
+ private
55
+
56
+ def integer_value(value, name)
57
+ Integer(value)
58
+ rescue ArgumentError, TypeError
59
+ raise ConfigurationError, "database #{name} must be an integer"
60
+ end
61
+
62
+ def float_value(value, name)
63
+ Float(value)
64
+ rescue ArgumentError, TypeError
65
+ raise ConfigurationError, "database #{name} must be a number"
66
+ end
67
+
68
+ def validate!
69
+ raise ConfigurationError, "unsupported database adapter #{adapter.inspect}" unless adapter == "postgres"
70
+ raise ConfigurationError, "database host must not be empty" if host.empty?
71
+ raise ConfigurationError, "database port must be between 1 and 65535" unless port.between?(1, 65_535)
72
+ raise ConfigurationError, "database name must not be empty" if database.empty?
73
+ unless database.match?(/\A[a-z_][a-z0-9_]*\z/)
74
+ raise ConfigurationError, "database name must be a lowercase PostgreSQL identifier"
75
+ end
76
+ raise ConfigurationError, "database admin name must not be empty" if admin_database.empty?
77
+ raise ConfigurationError, "database admin username must not be empty" if admin_username.empty?
78
+ raise ConfigurationError, "database username must not be empty" if username.empty?
79
+ raise ConfigurationError, "database pool_size must be positive" unless pool_size.positive?
80
+ raise ConfigurationError, "database checkout_timeout must be positive" unless checkout_timeout.positive?
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Hamster
5
+ class Database
6
+ class Connection
7
+ attr_reader :raw
8
+
9
+ def initialize(raw)
10
+ @raw = raw
11
+ @usable = true
12
+ end
13
+
14
+ def execute(sql, params = [])
15
+ Hamster::Instrumentation.instrument("db.execute", attributes: {"db.system.name" => "postgresql", "db.operation.name" => Hamster::Instrumentation.sql_operation(sql)}, kind: "client") do |details|
16
+ begin
17
+ execute_statement(sql, params)
18
+ rescue Error => error
19
+ Database.telemetry_error(details, error)
20
+ raise
21
+ end
22
+ end
23
+ end
24
+
25
+ def execute_statement(sql, params)
26
+ values = serialize_params(params)
27
+ result = raw.exec_params(sql, values)
28
+ result_object(result)
29
+ rescue ArgumentError => error
30
+ raise error
31
+ rescue PG::Error => error
32
+ mark_unusable unless usable_status?
33
+ raise QueryError.new("database query failed", original: error,
34
+ sqlstate: Postgres.sqlstate(error))
35
+ rescue StandardError => error
36
+ mark_unusable unless usable_status?
37
+ raise error if error.is_a?(Error)
38
+
39
+ raise ConnectionError.new("database operation failed: #{error.message}", original: error)
40
+ end
41
+
42
+ def execute_script(sql)
43
+ Hamster::Instrumentation.instrument("db.execute_script", attributes: {"db.system.name" => "postgresql", "db.operation.name" => "SCRIPT"}, kind: "client") do |details|
44
+ begin
45
+ execute_script_statement(sql)
46
+ rescue Error => error
47
+ Database.telemetry_error(details, error)
48
+ raise
49
+ end
50
+ end
51
+ end
52
+
53
+ def execute_script_statement(sql)
54
+ result_object(raw.exec(sql))
55
+ rescue PG::Error => error
56
+ mark_unusable unless usable_status?
57
+ raise QueryError.new("database query failed", original: error,
58
+ sqlstate: Postgres.sqlstate(error))
59
+ rescue StandardError => error
60
+ mark_unusable unless usable_status?
61
+ raise error if error.is_a?(Error)
62
+
63
+ raise ConnectionError.new("database operation failed: #{error.message}", original: error)
64
+ end
65
+
66
+ def transaction
67
+ raise TransactionError, "nested transactions are not supported" unless idle?
68
+
69
+ execute("BEGIN")
70
+ begin
71
+ result = yield self
72
+ execute("COMMIT")
73
+ result
74
+ rescue StandardError => original
75
+ begin
76
+ execute("ROLLBACK") unless idle?
77
+ rescue StandardError
78
+ mark_unusable
79
+ end
80
+ raise original
81
+ end
82
+ end
83
+
84
+ def idle?
85
+ raw.transaction_status == PG::PQTRANS_IDLE
86
+ rescue StandardError
87
+ false
88
+ end
89
+
90
+ def usable?
91
+ @usable && raw.transaction_status != PG::PQTRANS_UNKNOWN
92
+ rescue StandardError
93
+ false
94
+ end
95
+
96
+ def close
97
+ raw.close unless raw.transaction_status == PG::PQTRANS_UNKNOWN
98
+ rescue StandardError
99
+ mark_unusable
100
+ ensure
101
+ @usable = false
102
+ end
103
+
104
+ def mark_unusable
105
+ @usable = false
106
+ end
107
+
108
+ private
109
+
110
+ private :execute_statement, :execute_script_statement
111
+
112
+ def usable_status?
113
+ raw.transaction_status != PG::PQTRANS_UNKNOWN
114
+ rescue StandardError
115
+ false
116
+ end
117
+
118
+ def serialize_params(params)
119
+ raise ArgumentError, "database parameters must be an Array" unless params.is_a?(Array)
120
+
121
+ params.map do |value|
122
+ case value
123
+ when NilClass, String then value
124
+ when Integer then value.to_s
125
+ when Time then value.iso8601(6)
126
+ when DateTime then value.iso8601(6)
127
+ when Date then value.iso8601
128
+ when TrueClass then "true"
129
+ when FalseClass then "false"
130
+ else
131
+ raise ArgumentError, "unsupported database parameter #{value.class}"
132
+ end
133
+ end
134
+ end
135
+
136
+ def copy_rows(result)
137
+ rows = []
138
+ row_index = 0
139
+ while row_index < result.ntuples
140
+ row = []
141
+ column_index = 0
142
+ while column_index < result.nfields
143
+ row << result.getvalue(row_index, column_index)
144
+ column_index += 1
145
+ end
146
+ rows << row
147
+ row_index += 1
148
+ end
149
+ rows
150
+ end
151
+
152
+ def result_object(result)
153
+ Result.new(columns: result.fields.dup, rows: copy_rows(result), types: copy_types(result),
154
+ command_tag: result.cmd_tag)
155
+ end
156
+
157
+ def copy_types(result)
158
+ types = []
159
+ column_index = 0
160
+ while column_index < result.nfields
161
+ types << result.ftype(column_index)
162
+ column_index += 1
163
+ end
164
+ types
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ class Database
5
+ attr_reader :configuration
6
+
7
+ def initialize(configuration, migrations_path:)
8
+ @configuration = configuration
9
+ @migrations_path = File.expand_path(migrations_path)
10
+ @pool = Pool.new(configuration)
11
+ end
12
+
13
+ # Database boundaries own SQLSTATE classification, never the telemetry core.
14
+ def self.telemetry_error(details, error)
15
+ state = error.sqlstate
16
+ if state.instance_of?(String) && state.valid_encoding? && state.match?(/\A[0-9A-Z]{5}\z/)
17
+ details["db.response.status_code"] = state
18
+ end
19
+ rescue StandardError
20
+ Hamster::Instrumentation.diagnostic
21
+ end
22
+
23
+ def with_connection
24
+ connection = Hamster::Instrumentation.instrument("db.checkout", attributes: {"db.system.name" => "postgresql"}) do |details|
25
+ begin
26
+ @pool.checkout
27
+ rescue Error => error
28
+ Database.telemetry_error(details, error)
29
+ raise
30
+ end
31
+ end
32
+ begin
33
+ Hamster::Record.with_connection(connection) { yield connection }
34
+ ensure
35
+ @pool.checkin(connection)
36
+ end
37
+ end
38
+
39
+ def migrate!
40
+ with_connection do |connection|
41
+ connection.transaction do
42
+ connection.execute(<<~SQL)
43
+ CREATE TABLE IF NOT EXISTS schema_migrations (
44
+ version TEXT PRIMARY KEY,
45
+ applied_at TIMESTAMPTZ NOT NULL
46
+ )
47
+ SQL
48
+
49
+ applied = rows(connection, "SELECT version FROM schema_migrations ORDER BY version")
50
+ migration_files.each do |version, path|
51
+ next if applied.any? { |row| row["version"] == version }
52
+
53
+ connection.execute_script(File.read(path))
54
+ connection.execute("INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)",
55
+ [version, Time.now.utc.iso8601])
56
+ end
57
+ end
58
+ end
59
+ true
60
+ end
61
+
62
+ def verify!
63
+ with_connection do |connection|
64
+ present = rows(connection, "SELECT to_regclass('public.schema_migrations') AS name")
65
+ raise ConfigurationError, "migration tracking is missing; run bin/hamster db:migrate" if present.first["name"].nil?
66
+ applied = rows(connection, "SELECT version FROM schema_migrations ORDER BY version").map { |row| row["version"] }
67
+ missing = migration_files.map(&:first) - applied
68
+ raise ConfigurationError, "pending migrations #{missing.join(', ')}; run bin/hamster db:migrate" unless missing.empty?
69
+ end
70
+ true
71
+ end
72
+
73
+ def shutdown
74
+ @pool.shutdown
75
+ end
76
+
77
+ private
78
+
79
+ def rows(connection, sql, params = [])
80
+ connection.execute(sql, params).hashes
81
+ end
82
+
83
+ def migration_files
84
+ files = Dir[File.join(@migrations_path, "*.sql")].sort.map do |path|
85
+ [File.basename(path, ".sql").split("_", 2).first, path]
86
+ end
87
+ versions = files.map(&:first)
88
+ raise ConfigurationError, "duplicate migration versions" unless versions.uniq.length == versions.length
89
+ files
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ class Database
5
+ class Error < StandardError
6
+ attr_reader :original, :sqlstate
7
+
8
+ def initialize(message, original: nil, sqlstate: nil)
9
+ super(message)
10
+ @original = original
11
+ @sqlstate = sqlstate
12
+ end
13
+ end
14
+
15
+ class ConfigurationError < Error; end
16
+ class CheckoutTimeout < Error; end
17
+ class ConnectionError < Error; end
18
+ class QueryError < Error; end
19
+ class TransactionError < Error; end
20
+ end
21
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hamster
4
+ class Database
5
+ class Pool
6
+ def initialize(configuration)
7
+ @configuration = configuration
8
+ @lock = Mutex.new
9
+ @ready = ConditionVariable.new
10
+ @available = []
11
+ @total = 0
12
+ @borrowed = 0
13
+ @closed = false
14
+ end
15
+
16
+ def checkout
17
+ connection = reserve
18
+ begin
19
+ connection ||= Connection.new(Postgres.connect(@configuration))
20
+ rescue StandardError
21
+ release_failed_reservation
22
+ raise
23
+ end
24
+ connection
25
+ end
26
+
27
+ def checkin(connection)
28
+ reusable = connection.usable?
29
+ if reusable && !connection.idle?
30
+ begin
31
+ connection.execute("ROLLBACK")
32
+ reusable = connection.idle?
33
+ rescue StandardError
34
+ reusable = false
35
+ end
36
+ end
37
+
38
+ connection.close unless reusable
39
+ @lock.synchronize do
40
+ @borrowed -= 1
41
+ if reusable && !@closed
42
+ @available << connection
43
+ else
44
+ @total -= 1
45
+ end
46
+ @ready.broadcast
47
+ end
48
+ end
49
+
50
+ def shutdown
51
+ available = @lock.synchronize do
52
+ @closed = true
53
+ connections = @available
54
+ @available = []
55
+ @ready.broadcast
56
+ connections
57
+ end
58
+ available.each(&:close)
59
+ end
60
+
61
+ private
62
+
63
+ def reserve
64
+ deadline = Time.now + @configuration.checkout_timeout
65
+ @lock.synchronize do
66
+ loop do
67
+ raise ConnectionError, "database pool is shut down" if @closed
68
+
69
+ connection = @available.pop
70
+ if connection
71
+ @borrowed += 1
72
+ return connection
73
+ end
74
+
75
+ if @total < @configuration.pool_size
76
+ @total += 1
77
+ @borrowed += 1
78
+ return nil
79
+ end
80
+
81
+ remaining = deadline - Time.now
82
+ raise CheckoutTimeout, "database pool checkout timed out" unless remaining.positive?
83
+
84
+ @ready.wait(@lock, remaining)
85
+ end
86
+ end
87
+ end
88
+
89
+ def release_failed_reservation
90
+ @lock.synchronize do
91
+ @total -= 1
92
+ @borrowed -= 1
93
+ @ready.broadcast
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end