better_auth 0.1.1 → 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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ def self.update_user
6
+ Endpoint.new(path: "/update-user", method: "POST") do |ctx|
7
+ session = current_session(ctx)
8
+ body = normalize_hash(ctx.body)
9
+ raise APIError.new("BAD_REQUEST", message: "Body must be an object") unless body.is_a?(Hash)
10
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_CAN_NOT_BE_UPDATED"]) if body.key?("email")
11
+ update = parse_declared_input(ctx, "user", body, allowed_base: ["name", "image"])
12
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if update.empty?
13
+
14
+ updated = ctx.context.internal_adapter.update_user(session[:user]["id"], update)
15
+ Cookies.set_session_cookie(ctx, {session: session[:session], user: updated}, Cookies.dont_remember?(ctx))
16
+ ctx.json({status: true})
17
+ end
18
+ end
19
+
20
+ def self.change_password
21
+ Endpoint.new(path: "/change-password", method: "POST") do |ctx|
22
+ session = current_session(ctx, sensitive: true)
23
+ body = normalize_hash(ctx.body)
24
+ new_password = body["newPassword"] || body["new_password"]
25
+ current_password = body["currentPassword"] || body["current_password"]
26
+ validate_password_length!(new_password, ctx.context.options.email_and_password)
27
+ account = credential_account(ctx, session[:user]["id"])
28
+ unless account && account["password"] && verify_password_value(ctx, current_password.to_s, account["password"])
29
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
30
+ end
31
+
32
+ ctx.context.internal_adapter.update_account(account["id"], password: hash_password(ctx, new_password))
33
+ token = nil
34
+ if body["revokeOtherSessions"] || body["revoke_other_sessions"]
35
+ ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
36
+ new_session = ctx.context.internal_adapter.create_session(session[:user]["id"])
37
+ Cookies.set_session_cookie(ctx, {session: new_session, user: session[:user]})
38
+ token = new_session["token"]
39
+ end
40
+ ctx.json({token: token, user: Schema.parse_output(ctx.context.options, "user", session[:user])})
41
+ end
42
+ end
43
+
44
+ def self.set_password
45
+ Endpoint.new(path: "/set-password", method: "POST") do |ctx|
46
+ session = current_session(ctx, sensitive: true)
47
+ body = normalize_hash(ctx.body)
48
+ new_password = body["newPassword"] || body["new_password"]
49
+ validate_password_length!(new_password, ctx.context.options.email_and_password)
50
+ account = credential_account(ctx, session[:user]["id"])
51
+ raise APIError.new("BAD_REQUEST", message: "user already has a password") if account && account["password"]
52
+
53
+ ctx.context.internal_adapter.link_account(
54
+ userId: session[:user]["id"],
55
+ providerId: "credential",
56
+ accountId: session[:user]["id"],
57
+ password: hash_password(ctx, new_password)
58
+ )
59
+ ctx.json({status: true})
60
+ end
61
+ end
62
+
63
+ def self.delete_user
64
+ Endpoint.new(path: "/delete-user", method: "POST") do |ctx|
65
+ enabled = ctx.context.options.user.dig(:delete_user, :enabled)
66
+ raise APIError.new("NOT_FOUND") unless enabled
67
+
68
+ session = current_session(ctx, sensitive: true)
69
+ body = normalize_hash(ctx.body)
70
+ sender = ctx.context.options.user.dig(:delete_user, :send_delete_account_verification)
71
+ if body["password"]
72
+ account = credential_account(ctx, session[:user]["id"])
73
+ unless account && account["password"] && verify_password_value(ctx, body["password"], account["password"])
74
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
75
+ end
76
+ end
77
+
78
+ if body["token"]
79
+ delete_user_by_token!(ctx, session, body["token"])
80
+ elsif sender
81
+ token = SecureRandom.hex(16)
82
+ ctx.context.internal_adapter.create_verification_value(
83
+ identifier: "delete-account-#{token}",
84
+ value: session[:user]["id"],
85
+ expiresAt: Time.now + ctx.context.options.user.dig(:delete_user, :delete_token_expires_in).to_i
86
+ )
87
+ sender.call({user: session[:user], token: token}, ctx.request)
88
+ next ctx.json({success: true, message: "Verification email sent"})
89
+ elsif !body["password"]
90
+ require_fresh_session!(ctx, session)
91
+ end
92
+
93
+ delete_current_user!(ctx, session)
94
+ ctx.json({success: true, message: "User deleted"})
95
+ end
96
+ end
97
+
98
+ def self.delete_user_callback
99
+ Endpoint.new(path: "/delete-user/callback", method: "GET") do |ctx|
100
+ enabled = ctx.context.options.user.dig(:delete_user, :enabled)
101
+ raise APIError.new("NOT_FOUND") unless enabled
102
+ session = current_session(ctx)
103
+ token = fetch_value(ctx.query, "token")
104
+ delete_user_by_token!(ctx, session, token)
105
+ callback_url = fetch_value(ctx.query, "callbackURL")
106
+ validate_callback_url!(ctx.context, callback_url)
107
+ delete_current_user!(ctx, session)
108
+ raise ctx.redirect(callback_url) if callback_url
109
+
110
+ ctx.json({success: true, message: "User deleted"})
111
+ end
112
+ end
113
+
114
+ def self.change_email
115
+ Endpoint.new(path: "/change-email", method: "POST") do |ctx|
116
+ enabled = ctx.context.options.user.dig(:change_email, :enabled)
117
+ raise APIError.new("BAD_REQUEST", message: "Change email is disabled") unless enabled
118
+ session = current_session(ctx, sensitive: true)
119
+ body = normalize_hash(ctx.body)
120
+ new_email = (body["newEmail"] || body["new_email"]).to_s.downcase
121
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless EMAIL_PATTERN.match?(new_email)
122
+ raise APIError.new("BAD_REQUEST", message: "Email is the same") if new_email == session[:user]["email"]
123
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]) if ctx.context.internal_adapter.find_user_by_email(new_email)
124
+
125
+ if !session[:user]["emailVerified"] && ctx.context.options.user.dig(:change_email, :update_email_without_verification)
126
+ updated = ctx.context.internal_adapter.update_user_by_email(session[:user]["email"], email: new_email)
127
+ Cookies.set_session_cookie(ctx, {session: session[:session], user: updated})
128
+ next ctx.json({status: true})
129
+ end
130
+
131
+ sender = ctx.context.options.email_verification[:send_verification_email]
132
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
133
+
134
+ token = create_email_verification_token(ctx, session[:user]["email"], update_to: new_email, extra: {"requestType" => "change-email-verification"})
135
+ sender.call({user: session[:user].merge("email" => new_email), token: token}, ctx.request)
136
+ ctx.json({status: true})
137
+ end
138
+ end
139
+
140
+ def self.delete_user_by_token!(ctx, session, token)
141
+ verification = ctx.context.internal_adapter.find_verification_value("delete-account-#{token}")
142
+ unless verification && verification["value"] == session[:user]["id"] && !expired_time?(verification["expiresAt"])
143
+ raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["INVALID_TOKEN"])
144
+ end
145
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
146
+ end
147
+
148
+ def self.delete_current_user!(ctx, session)
149
+ config = ctx.context.options.user[:delete_user] || {}
150
+ call_option(config[:before_delete], session[:user], ctx.request)
151
+ ctx.context.internal_adapter.delete_user(session[:user]["id"])
152
+ ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
153
+ Cookies.delete_session_cookie(ctx)
154
+ call_option(config[:after_delete], session[:user], ctx.request)
155
+ end
156
+
157
+ def self.require_fresh_session!(ctx, session)
158
+ fresh_age = ctx.context.session_config[:fresh_age].to_i
159
+ return if fresh_age <= 0
160
+
161
+ updated_at = Session.normalize_time(session[:session]["updatedAt"] || session[:session]["updated_at"] || session[:session]["createdAt"] || session[:session]["created_at"])
162
+ raise APIError.new("UNAUTHORIZED") unless updated_at && updated_at + fresh_age > Time.now
163
+ end
164
+
165
+ def self.parse_declared_input(ctx, model, data, allowed_base: [])
166
+ input = normalize_hash(data || {})
167
+ table = Schema.auth_tables(ctx.context.options)[model.to_s]
168
+ fields = table ? table.fetch(:fields) : {}
169
+ additional = ctx.context.options.public_send(model.to_sym)[:additional_fields] || {}
170
+ fields = fields.merge(additional.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value }) if model.to_s == "session"
171
+ declared_fields = fields.keys - core_model_fields(model)
172
+ allowed = (Array(allowed_base).map { |field| Schema.storage_key(field) } + declared_fields).uniq
173
+
174
+ input.each_with_object({}) do |(field, value), result|
175
+ next unless fields.key?(field)
176
+ next unless allowed.include?(field)
177
+
178
+ attributes = fields.fetch(field)
179
+ if attributes[:input] == false
180
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
181
+ end
182
+
183
+ result[field] = coerce_input_value(value, attributes)
184
+ end
185
+ end
186
+
187
+ def self.coerce_input_value(value, attributes)
188
+ return value if value.nil?
189
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
190
+
191
+ value
192
+ end
193
+
194
+ def self.core_model_fields(model)
195
+ case model.to_s
196
+ when "user"
197
+ %w[id name email emailVerified image createdAt updatedAt]
198
+ when "session"
199
+ %w[id expiresAt token ipAddress userAgent userId createdAt updatedAt]
200
+ else
201
+ %w[id createdAt updatedAt]
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Schema
5
+ module SQL
6
+ module_function
7
+
8
+ def create_statements(options, dialect:)
9
+ dialect = dialect.to_sym
10
+ tables = Schema.auth_tables(options)
11
+ statements = tables.map { |logical_name, table| create_table_statement(logical_name, table, dialect, tables) }
12
+ statements.concat(tables.flat_map { |_logical_name, table| index_statements(table, dialect) })
13
+ end
14
+
15
+ def create_table_statement(logical_name, table, dialect, tables = nil)
16
+ table_name = table.fetch(:model_name)
17
+ columns = table.fetch(:fields).map do |logical_field, attributes|
18
+ column_definition(table_name, logical_field, attributes, dialect)
19
+ end
20
+ constraints = table.fetch(:fields).flat_map do |logical_field, attributes|
21
+ field_constraints(table_name, logical_field, attributes, dialect, tables)
22
+ end
23
+ body = (columns + constraints).join(",\n ")
24
+
25
+ case dialect
26
+ when :postgres, :sqlite
27
+ %(CREATE TABLE IF NOT EXISTS #{quote(table_name, dialect)} (\n #{body}\n);)
28
+ when :mysql
29
+ %(CREATE TABLE IF NOT EXISTS #{quote(table_name, dialect)} (\n #{body}\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;)
30
+ when :mssql
31
+ %(IF OBJECT_ID(N'#{quote(table_name, dialect)}', N'U') IS NULL\nCREATE TABLE #{quote(table_name, dialect)} (\n #{body}\n);)
32
+ else
33
+ raise ArgumentError, "Unsupported SQL dialect: #{dialect}"
34
+ end
35
+ end
36
+
37
+ def column_definition(table_name, logical_field, attributes, dialect)
38
+ column = quote(attributes[:field_name] || physical_name(logical_field), dialect)
39
+ parts = [column, sql_type(logical_field, attributes, dialect)]
40
+ parts << "PRIMARY KEY" if logical_field == "id"
41
+ if attributes[:required]
42
+ parts << "NOT NULL"
43
+ elsif dialect == :mssql
44
+ parts << "NULL"
45
+ end
46
+ default = default_sql(attributes, dialect)
47
+ parts << "DEFAULT #{default}" if default
48
+ parts.join(" ")
49
+ end
50
+
51
+ def field_constraints(table_name, logical_field, attributes, dialect, tables = nil)
52
+ constraints = []
53
+ column = attributes[:field_name] || physical_name(logical_field)
54
+
55
+ if attributes[:unique] && logical_field != "id"
56
+ constraints << unique_constraint(table_name, column, dialect)
57
+ end
58
+
59
+ reference = attributes[:references]
60
+ if reference
61
+ constraints << foreign_key_constraint(table_name, column, reference, dialect, tables)
62
+ end
63
+
64
+ constraints
65
+ end
66
+
67
+ def index_statements(table, dialect)
68
+ table_name = table.fetch(:model_name)
69
+ table.fetch(:fields).filter_map do |logical_field, attributes|
70
+ next unless attributes[:index]
71
+
72
+ column = attributes[:field_name] || Schema.physical_name(logical_field)
73
+ name = "index_#{table_name}_on_#{column}"
74
+ case dialect
75
+ when :postgres, :sqlite
76
+ %(CREATE INDEX IF NOT EXISTS #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
77
+ when :mysql
78
+ %(CREATE INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
79
+ when :mssql
80
+ %(IF NOT EXISTS (SELECT name FROM sys.indexes WHERE name = '#{name.gsub("'", "''")}' AND object_id = OBJECT_ID(N'#{quote(table_name, dialect)}')) CREATE INDEX #{quote(name, dialect)} ON #{quote(table_name, dialect)} (#{quote(column, dialect)});)
81
+ end
82
+ end
83
+ end
84
+
85
+ def sql_type(logical_field, attributes, dialect)
86
+ case attributes[:type]
87
+ when "boolean"
88
+ case dialect
89
+ when :mysql
90
+ "tinyint(1)"
91
+ when :sqlite
92
+ "integer"
93
+ when :mssql
94
+ "smallint"
95
+ else
96
+ "boolean"
97
+ end
98
+ when "date"
99
+ case dialect
100
+ when :mysql
101
+ "datetime(6)"
102
+ when :sqlite
103
+ "date"
104
+ when :mssql
105
+ "datetime2(3)"
106
+ else
107
+ "timestamptz"
108
+ end
109
+ when "number"
110
+ attributes[:bigint] ? "bigint" : "integer"
111
+ when "json", "string[]", "number[]"
112
+ case dialect
113
+ when :postgres
114
+ "jsonb"
115
+ when :mysql
116
+ "json"
117
+ when :mssql
118
+ "varchar(8000)"
119
+ else
120
+ "text"
121
+ end
122
+ else
123
+ if dialect == :mysql
124
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references]
125
+ indexed ? "varchar(191)" : "text"
126
+ elsif dialect == :mssql
127
+ indexed = logical_field == "id" || attributes[:unique] || attributes[:index] || attributes[:references] || attributes[:sortable]
128
+ indexed ? "varchar(255)" : "varchar(8000)"
129
+ else
130
+ "text"
131
+ end
132
+ end
133
+ end
134
+
135
+ def default_sql(attributes, dialect)
136
+ default = attributes[:default_value]
137
+ return unless default == false || default == true || default.is_a?(Numeric) || default.is_a?(String) || default.respond_to?(:call)
138
+
139
+ if attributes[:type] == "date" && default.respond_to?(:call)
140
+ return (dialect == :mysql) ? "CURRENT_TIMESTAMP(6)" : "CURRENT_TIMESTAMP"
141
+ end
142
+
143
+ case default
144
+ when true
145
+ (dialect == :mysql || dialect == :sqlite || dialect == :mssql) ? "1" : "true"
146
+ when false
147
+ (dialect == :mysql || dialect == :sqlite || dialect == :mssql) ? "0" : "false"
148
+ when Numeric
149
+ default.to_s
150
+ when String
151
+ "'#{default.gsub("'", "''")}'"
152
+ end
153
+ end
154
+
155
+ def unique_constraint(table_name, column, dialect)
156
+ case dialect
157
+ when :postgres, :sqlite
158
+ %(UNIQUE (#{quote(column, dialect)}))
159
+ when :mysql
160
+ %(UNIQUE KEY #{quote("uniq_#{table_name}_#{column}", dialect)} (#{quote(column, dialect)}))
161
+ when :mssql
162
+ %(CONSTRAINT #{quote("uniq_#{table_name}_#{column}", dialect)} UNIQUE (#{quote(column, dialect)}))
163
+ end
164
+ end
165
+
166
+ def foreign_key_constraint(table_name, column, reference, dialect, tables = nil)
167
+ target_model = tables&.fetch(reference.fetch(:model).to_s, nil)&.fetch(:model_name) || reference.fetch(:model)
168
+ target_field = reference.fetch(:field)
169
+ on_delete = reference[:on_delete] ? " ON DELETE #{reference[:on_delete].to_s.upcase}" : ""
170
+
171
+ case dialect
172
+ when :postgres, :sqlite
173
+ %(FOREIGN KEY (#{quote(column, dialect)}) REFERENCES #{quote(target_model, dialect)} (#{quote(target_field, dialect)})#{on_delete})
174
+ when :mysql
175
+ %(CONSTRAINT #{quote("fk_#{table_name}_#{column}", dialect)} FOREIGN KEY (#{quote(column, dialect)}) REFERENCES #{quote(target_model, dialect)} (#{quote(target_field, dialect)})#{on_delete})
176
+ when :mssql
177
+ %(CONSTRAINT #{quote("fk_#{table_name}_#{column}", dialect)} FOREIGN KEY (#{quote(column, dialect)}) REFERENCES #{quote(target_model, dialect)} (#{quote(target_field, dialect)})#{on_delete})
178
+ end
179
+ end
180
+
181
+ def quote(identifier, dialect)
182
+ case dialect
183
+ when :postgres, :sqlite
184
+ %("#{identifier.to_s.gsub("\"", "\"\"")}")
185
+ when :mysql
186
+ "`#{identifier.to_s.gsub("`", "``")}`"
187
+ when :mssql
188
+ "[#{identifier.to_s.gsub("]", "]]")}]"
189
+ else
190
+ raise ArgumentError, "Unsupported SQL dialect: #{dialect}"
191
+ end
192
+ end
193
+
194
+ def physical_name(value)
195
+ value.to_s
196
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
197
+ .tr("-", "_")
198
+ .downcase
199
+ end
200
+ end
201
+ end
202
+ end