activerecord-materialize-adapter 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/lib/active_record/connection_adapters/materialize/column.rb +30 -0
  4. data/lib/active_record/connection_adapters/materialize/database_statements.rb +199 -0
  5. data/lib/active_record/connection_adapters/materialize/explain_pretty_printer.rb +44 -0
  6. data/lib/active_record/connection_adapters/materialize/oid/array.rb +91 -0
  7. data/lib/active_record/connection_adapters/materialize/oid/bit.rb +53 -0
  8. data/lib/active_record/connection_adapters/materialize/oid/bit_varying.rb +15 -0
  9. data/lib/active_record/connection_adapters/materialize/oid/bytea.rb +17 -0
  10. data/lib/active_record/connection_adapters/materialize/oid/cidr.rb +50 -0
  11. data/lib/active_record/connection_adapters/materialize/oid/date.rb +23 -0
  12. data/lib/active_record/connection_adapters/materialize/oid/date_time.rb +23 -0
  13. data/lib/active_record/connection_adapters/materialize/oid/decimal.rb +15 -0
  14. data/lib/active_record/connection_adapters/materialize/oid/enum.rb +20 -0
  15. data/lib/active_record/connection_adapters/materialize/oid/hstore.rb +70 -0
  16. data/lib/active_record/connection_adapters/materialize/oid/inet.rb +15 -0
  17. data/lib/active_record/connection_adapters/materialize/oid/jsonb.rb +15 -0
  18. data/lib/active_record/connection_adapters/materialize/oid/legacy_point.rb +44 -0
  19. data/lib/active_record/connection_adapters/materialize/oid/money.rb +41 -0
  20. data/lib/active_record/connection_adapters/materialize/oid/oid.rb +15 -0
  21. data/lib/active_record/connection_adapters/materialize/oid/point.rb +64 -0
  22. data/lib/active_record/connection_adapters/materialize/oid/range.rb +96 -0
  23. data/lib/active_record/connection_adapters/materialize/oid/specialized_string.rb +18 -0
  24. data/lib/active_record/connection_adapters/materialize/oid/type_map_initializer.rb +112 -0
  25. data/lib/active_record/connection_adapters/materialize/oid/uuid.rb +25 -0
  26. data/lib/active_record/connection_adapters/materialize/oid/vector.rb +28 -0
  27. data/lib/active_record/connection_adapters/materialize/oid/xml.rb +30 -0
  28. data/lib/active_record/connection_adapters/materialize/oid.rb +35 -0
  29. data/lib/active_record/connection_adapters/materialize/quoting.rb +205 -0
  30. data/lib/active_record/connection_adapters/materialize/referential_integrity.rb +43 -0
  31. data/lib/active_record/connection_adapters/materialize/schema_creation.rb +76 -0
  32. data/lib/active_record/connection_adapters/materialize/schema_definitions.rb +222 -0
  33. data/lib/active_record/connection_adapters/materialize/schema_dumper.rb +49 -0
  34. data/lib/active_record/connection_adapters/materialize/schema_statements.rb +742 -0
  35. data/lib/active_record/connection_adapters/materialize/type_metadata.rb +36 -0
  36. data/lib/active_record/connection_adapters/materialize/utils.rb +80 -0
  37. data/lib/active_record/connection_adapters/materialize/version.rb +9 -0
  38. data/lib/active_record/connection_adapters/materialize_adapter.rb +952 -0
  39. data/lib/active_record/tasks/materialize_database_tasks.rb +130 -0
  40. data/lib/activerecord-materialize-adapter.rb +3 -0
  41. data/lib/materialize/errors/database_error.rb +10 -0
  42. data/lib/materialize/errors/incomplete_input.rb +10 -0
  43. metadata +170 -0
@@ -0,0 +1,952 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Make sure we're using pg high enough for type casts and Ruby 2.2+ compatibility
4
+ gem "pg", ">= 0.18", "< 2.0"
5
+ require "pg"
6
+
7
+ # Use async_exec instead of exec_params on pg versions before 1.1
8
+ class ::PG::Connection # :nodoc:
9
+ unless self.public_method_defined?(:async_exec_params)
10
+ remove_method :exec_params
11
+ alias exec_params async_exec
12
+ end
13
+ end
14
+
15
+ require 'arel'
16
+ require 'active_support/all'
17
+ require "active_record/schema_dumper"
18
+ require "active_record/connection_adapters/abstract_adapter"
19
+ require "active_record"
20
+
21
+ require "active_record/connection_adapters/statement_pool"
22
+ require "active_record/connection_adapters/materialize/column"
23
+ require "active_record/connection_adapters/materialize/database_statements"
24
+ require "active_record/connection_adapters/materialize/explain_pretty_printer"
25
+ require "active_record/connection_adapters/materialize/oid"
26
+ require "active_record/connection_adapters/materialize/quoting"
27
+ require "active_record/connection_adapters/materialize/referential_integrity"
28
+ require "active_record/connection_adapters/materialize/schema_creation"
29
+ require "active_record/connection_adapters/materialize/schema_definitions"
30
+ require "active_record/connection_adapters/materialize/schema_dumper"
31
+ require "active_record/connection_adapters/materialize/schema_statements"
32
+ require "active_record/connection_adapters/materialize/type_metadata"
33
+ require "active_record/connection_adapters/materialize/utils"
34
+ require "active_record/tasks/materialize_database_tasks"
35
+
36
+ module ActiveRecord
37
+ module ConnectionHandling # :nodoc:
38
+ # Establishes a connection to the database that's used by all Active Record objects
39
+ def materialize_connection(config)
40
+ conn_params = config.symbolize_keys
41
+
42
+ conn_params.delete_if { |_, v| v.nil? }
43
+
44
+ # Map ActiveRecords param names to PGs.
45
+ conn_params[:user] = conn_params.delete(:username) if conn_params[:username]
46
+ conn_params[:dbname] = conn_params.delete(:database) if conn_params[:database]
47
+
48
+ # Forward only valid config params to PG::Connection.connect.
49
+ valid_conn_param_keys = PG::Connection.conndefaults_hash.keys + [:requiressl]
50
+ conn_params.slice!(*valid_conn_param_keys)
51
+
52
+ conn = PG.connect(conn_params)
53
+ ConnectionAdapters::MaterializeAdapter.new(conn, logger, conn_params, config)
54
+ rescue ::PG::Error => error
55
+ if error.message.include?(conn_params[:dbname])
56
+ raise ActiveRecord::NoDatabaseError
57
+ else
58
+ raise
59
+ end
60
+ end
61
+ end
62
+
63
+ module ConnectionAdapters
64
+ # The Materialize adapter works with the native C (https://bitbucket.org/ged/ruby-pg) driver.
65
+ #
66
+ # Options:
67
+ #
68
+ # * <tt>:host</tt> - Defaults to a Unix-domain socket in /tmp. On machines without Unix-domain sockets,
69
+ # the default is to connect to localhost.
70
+ # * <tt>:port</tt> - Defaults to 5432.
71
+ # * <tt>:username</tt> - Defaults to be the same as the operating system name of the user running the application.
72
+ # * <tt>:password</tt> - Password to be used if the server demands password authentication.
73
+ # * <tt>:database</tt> - Defaults to be the same as the user name.
74
+ # * <tt>:schema_search_path</tt> - An optional schema search path for the connection given
75
+ # as a string of comma-separated schema names. This is backward-compatible with the <tt>:schema_order</tt> option.
76
+ # * <tt>:encoding</tt> - An optional client encoding that is used in a <tt>SET client_encoding TO
77
+ # <encoding></tt> call on the connection.
78
+ # * <tt>:min_messages</tt> - An optional client min messages that is used in a
79
+ # <tt>SET client_min_messages TO <min_messages></tt> call on the connection.
80
+ # * <tt>:variables</tt> - An optional hash of additional parameters that
81
+ # will be used in <tt>SET SESSION key = val</tt> calls on the connection.
82
+ # * <tt>:insert_returning</tt> - An optional boolean to control the use of <tt>RETURNING</tt> for <tt>INSERT</tt> statements
83
+ # defaults to true.
84
+ #
85
+ # Any further options are used as connection parameters to libpq. See
86
+ # https://www.postgresql.org/docs/current/static/libpq-connect.html for the
87
+ # list of parameters.
88
+ #
89
+ # In addition, default connection parameters of libpq can be set per environment variables.
90
+ # See https://www.postgresql.org/docs/current/static/libpq-envars.html .
91
+ class MaterializeAdapter < AbstractAdapter
92
+ ADAPTER_NAME = "Materialize"
93
+
94
+ ##
95
+ # :singleton-method:
96
+ # Materialize allows the creation of "unlogged" tables, which do not record
97
+ # data in the Materialize Write-Ahead Log. This can make the tables faster,
98
+ # but significantly increases the risk of data loss if the database
99
+ # crashes. As a result, this should not be used in production
100
+ # environments. If you would like all created tables to be unlogged in
101
+ # the test environment you can add the following line to your test.rb
102
+ # file:
103
+ #
104
+ # ActiveRecord::ConnectionAdapters::MaterializeAdapter.create_unlogged_tables = true
105
+ class_attribute :create_unlogged_tables, default: false
106
+
107
+ NATIVE_DATABASE_TYPES = {
108
+ primary_key: "bigserial primary key",
109
+ string: { name: "character varying" },
110
+ text: { name: "text" },
111
+ integer: { name: "integer", limit: 4 },
112
+ float: { name: "float" },
113
+ decimal: { name: "decimal" },
114
+ datetime: { name: "timestamp" },
115
+ time: { name: "time" },
116
+ date: { name: "date" },
117
+ daterange: { name: "daterange" },
118
+ numrange: { name: "numrange" },
119
+ tsrange: { name: "tsrange" },
120
+ tstzrange: { name: "tstzrange" },
121
+ int4range: { name: "int4range" },
122
+ int8range: { name: "int8range" },
123
+ binary: { name: "bytea" },
124
+ boolean: { name: "boolean" },
125
+ xml: { name: "xml" },
126
+ tsvector: { name: "tsvector" },
127
+ hstore: { name: "hstore" },
128
+ inet: { name: "inet" },
129
+ cidr: { name: "cidr" },
130
+ macaddr: { name: "macaddr" },
131
+ uuid: { name: "uuid" },
132
+ json: { name: "json" },
133
+ jsonb: { name: "jsonb" },
134
+ ltree: { name: "ltree" },
135
+ citext: { name: "citext" },
136
+ point: { name: "point" },
137
+ line: { name: "line" },
138
+ lseg: { name: "lseg" },
139
+ box: { name: "box" },
140
+ path: { name: "path" },
141
+ polygon: { name: "polygon" },
142
+ circle: { name: "circle" },
143
+ bit: { name: "bit" },
144
+ bit_varying: { name: "bit varying" },
145
+ money: { name: "money" },
146
+ interval: { name: "interval" },
147
+ oid: { name: "oid" },
148
+ }
149
+
150
+ OID = Materialize::OID #:nodoc:
151
+
152
+ include Materialize::Quoting
153
+ include Materialize::ReferentialIntegrity
154
+ include Materialize::SchemaStatements
155
+ include Materialize::DatabaseStatements
156
+
157
+ def supports_bulk_alter?
158
+ true
159
+ end
160
+
161
+ def supports_index_sort_order?
162
+ true
163
+ end
164
+
165
+ def supports_partitioned_indexes?
166
+ database_version >= 110_000
167
+ end
168
+
169
+ def supports_partial_index?
170
+ true
171
+ end
172
+
173
+ def supports_expression_index?
174
+ true
175
+ end
176
+
177
+ def supports_transaction_isolation?
178
+ true
179
+ end
180
+
181
+ def supports_foreign_keys?
182
+ true
183
+ end
184
+
185
+ def supports_validate_constraints?
186
+ true
187
+ end
188
+
189
+ def supports_views?
190
+ true
191
+ end
192
+
193
+ def supports_datetime_with_precision?
194
+ true
195
+ end
196
+
197
+ def supports_json?
198
+ true
199
+ end
200
+
201
+ def supports_comments?
202
+ true
203
+ end
204
+
205
+ def supports_savepoints?
206
+ true
207
+ end
208
+
209
+ def supports_insert_returning?
210
+ true
211
+ end
212
+
213
+ def supports_insert_on_conflict?
214
+ database_version >= 90500
215
+ end
216
+ alias supports_insert_on_duplicate_skip? supports_insert_on_conflict?
217
+ alias supports_insert_on_duplicate_update? supports_insert_on_conflict?
218
+ alias supports_insert_conflict_target? supports_insert_on_conflict?
219
+
220
+ def index_algorithms
221
+ { concurrently: "CONCURRENTLY" }
222
+ end
223
+
224
+ class StatementPool < ConnectionAdapters::StatementPool # :nodoc:
225
+ def initialize(connection, max)
226
+ super(max)
227
+ @connection = connection
228
+ @counter = 0
229
+ end
230
+
231
+ def next_key
232
+ "a#{@counter + 1}"
233
+ end
234
+
235
+ def []=(sql, key)
236
+ super.tap { @counter += 1 }
237
+ end
238
+
239
+ private
240
+ def dealloc(key)
241
+ @connection.query "DEALLOCATE #{key}" if connection_active?
242
+ rescue PG::Error
243
+ end
244
+
245
+ def connection_active?
246
+ @connection.status == PG::CONNECTION_OK
247
+ rescue PG::Error
248
+ false
249
+ end
250
+ end
251
+
252
+ # Initializes and connects a Materialize adapter.
253
+ def initialize(connection, logger, connection_parameters, config)
254
+ super(connection, logger, config)
255
+
256
+ @connection_parameters = connection_parameters
257
+
258
+ # @local_tz is initialized as nil to avoid warnings when connect tries to use it
259
+ @local_tz = nil
260
+ @max_identifier_length = nil
261
+
262
+ configure_connection
263
+ add_pg_encoders
264
+ add_pg_decoders
265
+
266
+ @type_map = Type::HashLookupTypeMap.new
267
+ initialize_type_map
268
+ @local_tz = execute("SHOW TIMEZONE", "SCHEMA").first["TimeZone"]
269
+ @use_insert_returning = @config.key?(:insert_returning) ? self.class.type_cast_config_to_boolean(@config[:insert_returning]) : true
270
+ end
271
+
272
+ def self.database_exists?(config)
273
+ !!ActiveRecord::Base.materialize_connection(config)
274
+ rescue ActiveRecord::NoDatabaseError
275
+ false
276
+ end
277
+
278
+ # Is this connection alive and ready for queries?
279
+ def active?
280
+ @lock.synchronize do
281
+ @connection.query "SELECT 1"
282
+ end
283
+ true
284
+ rescue PG::Error
285
+ false
286
+ end
287
+
288
+ # Close then reopen the connection.
289
+ def reconnect!
290
+ @lock.synchronize do
291
+ super
292
+ @connection.reset
293
+ configure_connection
294
+ rescue PG::ConnectionBad
295
+ connect
296
+ end
297
+ end
298
+
299
+ def reset!
300
+ @lock.synchronize do
301
+ clear_cache!
302
+ reset_transaction
303
+ unless @connection.transaction_status == ::PG::PQTRANS_IDLE
304
+ @connection.query "ROLLBACK"
305
+ end
306
+ @connection.query "DISCARD ALL"
307
+ configure_connection
308
+ end
309
+ end
310
+
311
+ # Disconnects from the database if already connected. Otherwise, this
312
+ # method does nothing.
313
+ def disconnect!
314
+ @lock.synchronize do
315
+ super
316
+ @connection.close rescue nil
317
+ end
318
+ end
319
+
320
+ def discard! # :nodoc:
321
+ super
322
+ @connection.socket_io.reopen(IO::NULL) rescue nil
323
+ @connection = nil
324
+ end
325
+
326
+ def native_database_types #:nodoc:
327
+ NATIVE_DATABASE_TYPES
328
+ end
329
+
330
+ def supports_ddl_transactions?
331
+ true
332
+ end
333
+
334
+ def supports_advisory_locks?
335
+ true
336
+ end
337
+
338
+ def supports_explain?
339
+ true
340
+ end
341
+
342
+ def supports_extensions?
343
+ true
344
+ end
345
+
346
+ def supports_ranges?
347
+ true
348
+ end
349
+ deprecate :supports_ranges?
350
+
351
+ def supports_materialized_views?
352
+ true
353
+ end
354
+
355
+ def supports_foreign_tables?
356
+ true
357
+ end
358
+
359
+ def supports_pgcrypto_uuid?
360
+ database_version >= 90400
361
+ end
362
+
363
+ def supports_optimizer_hints?
364
+ unless defined?(@has_pg_hint_plan)
365
+ @has_pg_hint_plan = extension_available?("pg_hint_plan")
366
+ end
367
+ @has_pg_hint_plan
368
+ end
369
+
370
+ def supports_common_table_expressions?
371
+ true
372
+ end
373
+
374
+ def supports_lazy_transactions?
375
+ true
376
+ end
377
+
378
+ def get_advisory_lock(lock_id) # :nodoc:
379
+ unless lock_id.is_a?(Integer) && lock_id.bit_length <= 63
380
+ raise(ArgumentError, "Materialize requires advisory lock ids to be a signed 64 bit integer")
381
+ end
382
+ query_value("SELECT pg_try_advisory_lock(#{lock_id})")
383
+ end
384
+
385
+ def release_advisory_lock(lock_id) # :nodoc:
386
+ unless lock_id.is_a?(Integer) && lock_id.bit_length <= 63
387
+ raise(ArgumentError, "Materialize requires advisory lock ids to be a signed 64 bit integer")
388
+ end
389
+ query_value("SELECT pg_advisory_unlock(#{lock_id})")
390
+ end
391
+
392
+ def enable_extension(name)
393
+ exec_query("CREATE EXTENSION IF NOT EXISTS \"#{name}\"").tap {
394
+ reload_type_map
395
+ }
396
+ end
397
+
398
+ def disable_extension(name)
399
+ exec_query("DROP EXTENSION IF EXISTS \"#{name}\" CASCADE").tap {
400
+ reload_type_map
401
+ }
402
+ end
403
+
404
+ def extension_available?(name)
405
+ query_value("SELECT true FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA")
406
+ end
407
+
408
+ def extension_enabled?(name)
409
+ query_value("SELECT installed_version IS NOT NULL FROM pg_available_extensions WHERE name = #{quote(name)}", "SCHEMA")
410
+ end
411
+
412
+ def extensions
413
+ exec_query("SELECT extname FROM pg_extension", "SCHEMA").cast_values
414
+ end
415
+
416
+ # Returns the configured supported identifier length supported by Materialize
417
+ def max_identifier_length
418
+ @max_identifier_length ||= query_value("SHOW max_identifier_length", "SCHEMA").to_i
419
+ end
420
+
421
+ # Set the authorized user for this session
422
+ def session_auth=(user)
423
+ clear_cache!
424
+ execute("SET SESSION AUTHORIZATION #{user}")
425
+ end
426
+
427
+ def use_insert_returning?
428
+ @use_insert_returning
429
+ end
430
+
431
+ def column_name_for_operation(operation, node) # :nodoc:
432
+ OPERATION_ALIASES.fetch(operation) { operation.downcase }
433
+ end
434
+
435
+ OPERATION_ALIASES = { # :nodoc:
436
+ "maximum" => "max",
437
+ "minimum" => "min",
438
+ "average" => "avg",
439
+ }
440
+
441
+ # Returns the version of the connected Materialize server.
442
+ def get_database_version # :nodoc:
443
+ @connection.server_version
444
+ end
445
+ alias :materialize_version :database_version
446
+
447
+ def default_index_type?(index) # :nodoc:
448
+ index.using == :btree || super
449
+ end
450
+
451
+ def build_insert_sql(insert) # :nodoc:
452
+ sql = +"INSERT #{insert.into} #{insert.values_list}"
453
+
454
+ if insert.skip_duplicates?
455
+ sql << " ON CONFLICT #{insert.conflict_target} DO NOTHING"
456
+ elsif insert.update_duplicates?
457
+ sql << " ON CONFLICT #{insert.conflict_target} DO UPDATE SET "
458
+ sql << insert.updatable_columns.map { |column| "#{column}=excluded.#{column}" }.join(",")
459
+ end
460
+
461
+ sql << " RETURNING #{insert.returning}" if insert.returning
462
+ sql
463
+ end
464
+
465
+ def check_version # :nodoc:
466
+ if database_version < 90300
467
+ raise "Your version of Materialize (#{database_version}) is too old. Active Record supports Materialize >= 9.3."
468
+ end
469
+ end
470
+
471
+ private
472
+ # See https://www.postgresql.org/docs/current/static/errcodes-appendix.html
473
+ VALUE_LIMIT_VIOLATION = "22001"
474
+ NUMERIC_VALUE_OUT_OF_RANGE = "22003"
475
+ NOT_NULL_VIOLATION = "23502"
476
+ FOREIGN_KEY_VIOLATION = "23503"
477
+ UNIQUE_VIOLATION = "23505"
478
+ SERIALIZATION_FAILURE = "40001"
479
+ DEADLOCK_DETECTED = "40P01"
480
+ LOCK_NOT_AVAILABLE = "55P03"
481
+ QUERY_CANCELED = "57014"
482
+
483
+ def translate_exception(exception, message:, sql:, binds:)
484
+ return exception unless exception.respond_to?(:result)
485
+
486
+ case exception.result.try(:error_field, PG::PG_DIAG_SQLSTATE)
487
+ when UNIQUE_VIOLATION
488
+ RecordNotUnique.new(message, sql: sql, binds: binds)
489
+ when FOREIGN_KEY_VIOLATION
490
+ InvalidForeignKey.new(message, sql: sql, binds: binds)
491
+ when VALUE_LIMIT_VIOLATION
492
+ ValueTooLong.new(message, sql: sql, binds: binds)
493
+ when NUMERIC_VALUE_OUT_OF_RANGE
494
+ RangeError.new(message, sql: sql, binds: binds)
495
+ when NOT_NULL_VIOLATION
496
+ NotNullViolation.new(message, sql: sql, binds: binds)
497
+ when SERIALIZATION_FAILURE
498
+ SerializationFailure.new(message, sql: sql, binds: binds)
499
+ when DEADLOCK_DETECTED
500
+ Deadlocked.new(message, sql: sql, binds: binds)
501
+ when LOCK_NOT_AVAILABLE
502
+ LockWaitTimeout.new(message, sql: sql, binds: binds)
503
+ when QUERY_CANCELED
504
+ QueryCanceled.new(message, sql: sql, binds: binds)
505
+ else
506
+ super
507
+ end
508
+ end
509
+
510
+ def get_oid_type(oid, fmod, column_name, sql_type = "")
511
+ if !type_map.key?(oid)
512
+ load_additional_types([oid])
513
+ end
514
+
515
+ type_map.fetch(oid, fmod, sql_type) {
516
+ warn "unknown OID #{oid}: failed to recognize type of '#{column_name}'. It will be treated as String."
517
+ Type.default_value.tap do |cast_type|
518
+ type_map.register_type(oid, cast_type)
519
+ end
520
+ }
521
+ end
522
+
523
+ def initialize_type_map(m = type_map)
524
+ m.register_type "int2", Type::Integer.new(limit: 2)
525
+ m.register_type "int4", Type::Integer.new(limit: 4)
526
+ m.register_type "int8", Type::Integer.new(limit: 8)
527
+ m.register_type "oid", OID::Oid.new
528
+ m.register_type "float4", Type::Float.new
529
+ m.alias_type "float8", "float4"
530
+ m.register_type "text", Type::Text.new
531
+ register_class_with_limit m, "varchar", Type::String
532
+ m.alias_type "char", "varchar"
533
+ m.alias_type "name", "varchar"
534
+ m.alias_type "bpchar", "varchar"
535
+ m.register_type "bool", Type::Boolean.new
536
+ register_class_with_limit m, "bit", OID::Bit
537
+ register_class_with_limit m, "varbit", OID::BitVarying
538
+ m.alias_type "timestamptz", "timestamp"
539
+ m.register_type "date", OID::Date.new
540
+
541
+ m.register_type "money", OID::Money.new
542
+ m.register_type "bytea", OID::Bytea.new
543
+ m.register_type "point", OID::Point.new
544
+ m.register_type "hstore", OID::Hstore.new
545
+ m.register_type "json", Type::Json.new
546
+ m.register_type "jsonb", OID::Jsonb.new
547
+ m.register_type "cidr", OID::Cidr.new
548
+ m.register_type "inet", OID::Inet.new
549
+ m.register_type "uuid", OID::Uuid.new
550
+ m.register_type "xml", OID::Xml.new
551
+ m.register_type "tsvector", OID::SpecializedString.new(:tsvector)
552
+ m.register_type "macaddr", OID::SpecializedString.new(:macaddr)
553
+ m.register_type "citext", OID::SpecializedString.new(:citext)
554
+ m.register_type "ltree", OID::SpecializedString.new(:ltree)
555
+ m.register_type "line", OID::SpecializedString.new(:line)
556
+ m.register_type "lseg", OID::SpecializedString.new(:lseg)
557
+ m.register_type "box", OID::SpecializedString.new(:box)
558
+ m.register_type "path", OID::SpecializedString.new(:path)
559
+ m.register_type "polygon", OID::SpecializedString.new(:polygon)
560
+ m.register_type "circle", OID::SpecializedString.new(:circle)
561
+
562
+ m.register_type "interval" do |_, _, sql_type|
563
+ precision = extract_precision(sql_type)
564
+ OID::SpecializedString.new(:interval, precision: precision)
565
+ end
566
+
567
+ register_class_with_precision m, "time", Type::Time
568
+ register_class_with_precision m, "timestamp", OID::DateTime
569
+
570
+ m.register_type "numeric" do |_, fmod, sql_type|
571
+ precision = extract_precision(sql_type)
572
+ scale = extract_scale(sql_type)
573
+
574
+ # The type for the numeric depends on the width of the field,
575
+ # so we'll do something special here.
576
+ #
577
+ # When dealing with decimal columns:
578
+ #
579
+ # places after decimal = fmod - 4 & 0xffff
580
+ # places before decimal = (fmod - 4) >> 16 & 0xffff
581
+ if fmod && (fmod - 4 & 0xffff).zero?
582
+ # FIXME: Remove this class, and the second argument to
583
+ # lookups on PG
584
+ Type::DecimalWithoutScale.new(precision: precision)
585
+ else
586
+ OID::Decimal.new(precision: precision, scale: scale)
587
+ end
588
+ end
589
+ end
590
+
591
+ # Extracts the value from a Materialize column default definition.
592
+ def extract_value_from_default(default)
593
+ case default
594
+ # Quoted types
595
+ when /\A[\(B]?'(.*)'.*::"?([\w. ]+)"?(?:\[\])?\z/m
596
+ # The default 'now'::date is CURRENT_DATE
597
+ if $1 == "now" && $2 == "date"
598
+ nil
599
+ else
600
+ $1.gsub("''", "'")
601
+ end
602
+ # Boolean types
603
+ when "true", "false"
604
+ default
605
+ # Numeric types
606
+ when /\A\(?(-?\d+(\.\d*)?)\)?(::bigint)?\z/
607
+ $1
608
+ # Object identifier types
609
+ when /\A-?\d+\z/
610
+ $1
611
+ else
612
+ # Anything else is blank, some user type, or some function
613
+ # and we can't know the value of that, so return nil.
614
+ nil
615
+ end
616
+ end
617
+
618
+ def extract_default_function(default_value, default)
619
+ default if has_default_function?(default_value, default)
620
+ end
621
+
622
+ def has_default_function?(default_value, default)
623
+ !default_value && %r{\w+\(.*\)|\(.*\)::\w+|CURRENT_DATE|CURRENT_TIMESTAMP}.match?(default)
624
+ end
625
+
626
+ def load_additional_types(oids = nil)
627
+ initializer = OID::TypeMapInitializer.new(type_map)
628
+
629
+ query = <<~SQL
630
+ SELECT t.oid, t.typname, t.typelem, t.typinput, r.rngsubtype, t.typtype, t.typbasetype
631
+ FROM pg_type as t
632
+ LEFT JOIN pg_range as r ON oid = rngtypid
633
+ SQL
634
+
635
+ if oids
636
+ query += "WHERE t.oid IN (%s)" % oids.join(", ")
637
+ else
638
+ query += initializer.query_conditions_for_initial_load
639
+ end
640
+
641
+ execute_and_clear(query, "SCHEMA", []) do |records|
642
+ initializer.run(records)
643
+ end
644
+ end
645
+
646
+ FEATURE_NOT_SUPPORTED = "0A000" #:nodoc:
647
+
648
+ def execute_and_clear(sql, name, binds, prepare: false)
649
+ if preventing_writes? && write_query?(sql)
650
+ raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}"
651
+ end
652
+
653
+ if without_prepared_statement?(binds)
654
+ result = exec_no_cache(sql, name, [])
655
+ elsif !prepare
656
+ result = exec_no_cache(sql, name, binds)
657
+ else
658
+ result = exec_cache(sql, name, binds)
659
+ end
660
+ ret = yield result
661
+ result.clear
662
+ ret
663
+ end
664
+
665
+ def exec_no_cache(sql, name, binds)
666
+ materialize_transactions
667
+
668
+ # make sure we carry over any changes to ActiveRecord::Base.default_timezone that have been
669
+ # made since we established the connection
670
+ update_typemap_for_default_timezone
671
+
672
+ type_casted_binds = type_casted_binds(binds)
673
+ log(sql, name, binds, type_casted_binds) do
674
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
675
+ @connection.exec_params(sql, type_casted_binds)
676
+ end
677
+ end
678
+ end
679
+
680
+ def exec_cache(sql, name, binds)
681
+ materialize_transactions
682
+ update_typemap_for_default_timezone
683
+
684
+ stmt_key = prepare_statement(sql, binds)
685
+ type_casted_binds = type_casted_binds(binds)
686
+
687
+ log(sql, name, binds, type_casted_binds, stmt_key) do
688
+ ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
689
+ @connection.exec_prepared(stmt_key, type_casted_binds)
690
+ end
691
+ end
692
+ rescue ActiveRecord::StatementInvalid => e
693
+ raise unless is_cached_plan_failure?(e)
694
+
695
+ # Nothing we can do if we are in a transaction because all commands
696
+ # will raise InFailedSQLTransaction
697
+ if in_transaction?
698
+ raise ActiveRecord::PreparedStatementCacheExpired.new(e.cause.message)
699
+ else
700
+ @lock.synchronize do
701
+ # outside of transactions we can simply flush this query and retry
702
+ @statements.delete sql_key(sql)
703
+ end
704
+ retry
705
+ end
706
+ end
707
+
708
+ # Annoyingly, the code for prepared statements whose return value may
709
+ # have changed is FEATURE_NOT_SUPPORTED.
710
+ #
711
+ # This covers various different error types so we need to do additional
712
+ # work to classify the exception definitively as a
713
+ # ActiveRecord::PreparedStatementCacheExpired
714
+ #
715
+ # Check here for more details:
716
+ # https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/utils/cache/plancache.c#l573
717
+ CACHED_PLAN_HEURISTIC = "cached plan must not change result type"
718
+ def is_cached_plan_failure?(e)
719
+ pgerror = e.cause
720
+ code = pgerror.result.result_error_field(PG::PG_DIAG_SQLSTATE)
721
+ code == FEATURE_NOT_SUPPORTED && pgerror.message.include?(CACHED_PLAN_HEURISTIC)
722
+ rescue
723
+ false
724
+ end
725
+
726
+ def in_transaction?
727
+ open_transactions > 0
728
+ end
729
+
730
+ # Returns the statement identifier for the client side cache
731
+ # of statements
732
+ def sql_key(sql)
733
+ "#{schema_search_path}-#{sql}"
734
+ end
735
+
736
+ # Prepare the statement if it hasn't been prepared, return
737
+ # the statement key.
738
+ def prepare_statement(sql, binds)
739
+ @lock.synchronize do
740
+ sql_key = sql_key(sql)
741
+ unless @statements.key? sql_key
742
+ nextkey = @statements.next_key
743
+ begin
744
+ @connection.prepare nextkey, sql
745
+ rescue => e
746
+ raise translate_exception_class(e, sql, binds)
747
+ end
748
+ # Clear the queue
749
+ @connection.get_last_result
750
+ @statements[sql_key] = nextkey
751
+ end
752
+ @statements[sql_key]
753
+ end
754
+ end
755
+
756
+ # Connects to a Materialize server and sets up the adapter depending on the
757
+ # connected server's characteristics.
758
+ def connect
759
+ @connection = PG.connect(@connection_parameters)
760
+ configure_connection
761
+ add_pg_encoders
762
+ add_pg_decoders
763
+ end
764
+
765
+ # Configures the encoding, verbosity, schema search path, and time zone of the connection.
766
+ # This is called by #connect and should not be called manually.
767
+ def configure_connection
768
+ if @config[:encoding]
769
+ @connection.set_client_encoding(@config[:encoding])
770
+ end
771
+ self.schema_search_path = @config[:schema_search_path] || @config[:schema_order]
772
+
773
+ variables = @config.fetch(:variables, {}).stringify_keys
774
+
775
+ # If using Active Record's time zone support configure the connection to return
776
+ # TIMESTAMP WITH ZONE types in UTC.
777
+ unless variables["timezone"]
778
+ if ActiveRecord::Base.default_timezone == :utc
779
+ variables["timezone"] = "UTC"
780
+ elsif @local_tz
781
+ variables["timezone"] = @local_tz
782
+ end
783
+ end
784
+
785
+ # SET statements from :variables config hash
786
+ # https://www.postgresql.org/docs/current/static/sql-set.html
787
+ variables.map do |k, v|
788
+ if v == ":default" || v == :default
789
+ # Sets the value to the global or compile default
790
+ execute("SET SESSION #{k} TO DEFAULT", "SCHEMA")
791
+ elsif !v.nil?
792
+ execute("SET SESSION #{k} TO #{quote(v)}", "SCHEMA")
793
+ end
794
+ end
795
+ end
796
+
797
+ # Returns the list of a table's column names, data types, and default values.
798
+ #
799
+ # The underlying query is roughly:
800
+ # SELECT column.name, column.type, default.value, column.comment
801
+ # FROM column LEFT JOIN default
802
+ # ON column.table_id = default.table_id
803
+ # AND column.num = default.column_num
804
+ # WHERE column.table_id = get_table_id('table_name')
805
+ # AND column.num > 0
806
+ # AND NOT column.is_dropped
807
+ # ORDER BY column.num
808
+ #
809
+ # If the table name is not prefixed with a schema, the database will
810
+ # take the first match from the schema search path.
811
+ #
812
+ # Query implementation notes:
813
+ # - format_type includes the column size constraint, e.g. varchar(50)
814
+ # - ::regclass is a function that gives the id for a table name
815
+ def column_definitions(table_name)
816
+ query(<<~SQL, "SCHEMA")
817
+ SELECT a.attname, format_type(a.atttypid, a.atttypmod),
818
+ pg_get_expr(d.adbin, d.adrelid), a.attnotnull, a.atttypid, a.atttypmod,
819
+ c.collname, col_description(a.attrelid, a.attnum) AS comment
820
+ FROM pg_attribute a
821
+ LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum
822
+ LEFT JOIN pg_type t ON a.atttypid = t.oid
823
+ LEFT JOIN pg_collation c ON a.attcollation = c.oid AND a.attcollation <> t.typcollation
824
+ WHERE a.attrelid = #{quote(quote_table_name(table_name))}::regclass
825
+ AND a.attnum > 0 AND NOT a.attisdropped
826
+ ORDER BY a.attnum
827
+ SQL
828
+ end
829
+
830
+ def extract_table_ref_from_insert_sql(sql)
831
+ sql[/into\s("[A-Za-z0-9_."\[\]\s]+"|[A-Za-z0-9_."\[\]]+)\s*/im]
832
+ $1.strip if $1
833
+ end
834
+
835
+ def arel_visitor
836
+ Arel::Visitors::PostgreSQL.new(self)
837
+ end
838
+
839
+ def build_statement_pool
840
+ StatementPool.new(@connection, self.class.type_cast_config_to_integer(@config[:statement_limit]))
841
+ end
842
+
843
+ def can_perform_case_insensitive_comparison_for?(column)
844
+ @case_insensitive_cache ||= {}
845
+ @case_insensitive_cache[column.sql_type] ||= begin
846
+ sql = <<~SQL
847
+ SELECT exists(
848
+ SELECT * FROM pg_proc
849
+ WHERE proname = 'lower'
850
+ AND proargtypes = ARRAY[#{quote column.sql_type}::regtype]::oidvector
851
+ ) OR exists(
852
+ SELECT * FROM pg_proc
853
+ INNER JOIN pg_cast
854
+ ON ARRAY[casttarget]::oidvector = proargtypes
855
+ WHERE proname = 'lower'
856
+ AND castsource = #{quote column.sql_type}::regtype
857
+ )
858
+ SQL
859
+ execute_and_clear(sql, "SCHEMA", []) do |result|
860
+ result.getvalue(0, 0)
861
+ end
862
+ end
863
+ end
864
+
865
+ def add_pg_encoders
866
+ map = PG::TypeMapByClass.new
867
+ map[Integer] = PG::TextEncoder::Integer.new
868
+ map[TrueClass] = PG::TextEncoder::Boolean.new
869
+ map[FalseClass] = PG::TextEncoder::Boolean.new
870
+ @connection.type_map_for_queries = map
871
+ end
872
+
873
+ def update_typemap_for_default_timezone
874
+ if @default_timezone != ActiveRecord::Base.default_timezone && @timestamp_decoder
875
+ decoder_class = ActiveRecord::Base.default_timezone == :utc ?
876
+ PG::TextDecoder::TimestampUtc :
877
+ PG::TextDecoder::TimestampWithoutTimeZone
878
+
879
+ @timestamp_decoder = decoder_class.new(@timestamp_decoder.to_h)
880
+ @connection.type_map_for_results.add_coder(@timestamp_decoder)
881
+ @default_timezone = ActiveRecord::Base.default_timezone
882
+ end
883
+ end
884
+
885
+ def add_pg_decoders
886
+ @default_timezone = nil
887
+ @timestamp_decoder = nil
888
+
889
+ coders_by_name = {
890
+ "int2" => PG::TextDecoder::Integer,
891
+ "int4" => PG::TextDecoder::Integer,
892
+ "int8" => PG::TextDecoder::Integer,
893
+ "oid" => PG::TextDecoder::Integer,
894
+ "float4" => PG::TextDecoder::Float,
895
+ "float8" => PG::TextDecoder::Float,
896
+ "bool" => PG::TextDecoder::Boolean,
897
+ }
898
+
899
+ if defined?(PG::TextDecoder::TimestampUtc)
900
+ # Use native PG encoders available since pg-1.1
901
+ coders_by_name["timestamp"] = PG::TextDecoder::TimestampUtc
902
+ coders_by_name["timestamptz"] = PG::TextDecoder::TimestampWithTimeZone
903
+ end
904
+
905
+ known_coder_types = coders_by_name.keys.map { |n| quote(n) }
906
+ query = <<~SQL % known_coder_types.join(", ")
907
+ SELECT t.oid, t.typname
908
+ FROM pg_type as t
909
+ WHERE t.typname IN (%s)
910
+ SQL
911
+ coders = execute_and_clear(query, "SCHEMA", []) do |result|
912
+ result
913
+ .map { |row| construct_coder(row, coders_by_name[row["typname"]]) }
914
+ .compact
915
+ end
916
+
917
+ map = PG::TypeMapByOid.new
918
+ coders.each { |coder| map.add_coder(coder) }
919
+ @connection.type_map_for_results = map
920
+
921
+ # extract timestamp decoder for use in update_typemap_for_default_timezone
922
+ @timestamp_decoder = coders.find { |coder| coder.name == "timestamp" }
923
+ update_typemap_for_default_timezone
924
+ end
925
+
926
+ def construct_coder(row, coder_class)
927
+ return unless coder_class
928
+ coder_class.new(oid: row["oid"].to_i, name: row["typname"])
929
+ end
930
+
931
+ ActiveRecord::Type.add_modifier({ array: true }, OID::Array, adapter: :materialize)
932
+ ActiveRecord::Type.add_modifier({ range: true }, OID::Range, adapter: :materialize)
933
+ ActiveRecord::Type.register(:bit, OID::Bit, adapter: :materialize)
934
+ ActiveRecord::Type.register(:bit_varying, OID::BitVarying, adapter: :materialize)
935
+ ActiveRecord::Type.register(:binary, OID::Bytea, adapter: :materialize)
936
+ ActiveRecord::Type.register(:cidr, OID::Cidr, adapter: :materialize)
937
+ ActiveRecord::Type.register(:date, OID::Date, adapter: :materialize)
938
+ ActiveRecord::Type.register(:datetime, OID::DateTime, adapter: :materialize)
939
+ ActiveRecord::Type.register(:decimal, OID::Decimal, adapter: :materialize)
940
+ ActiveRecord::Type.register(:enum, OID::Enum, adapter: :materialize)
941
+ ActiveRecord::Type.register(:hstore, OID::Hstore, adapter: :materialize)
942
+ ActiveRecord::Type.register(:inet, OID::Inet, adapter: :materialize)
943
+ ActiveRecord::Type.register(:jsonb, OID::Jsonb, adapter: :materialize)
944
+ ActiveRecord::Type.register(:money, OID::Money, adapter: :materialize)
945
+ ActiveRecord::Type.register(:point, OID::Point, adapter: :materialize)
946
+ ActiveRecord::Type.register(:legacy_point, OID::LegacyPoint, adapter: :materialize)
947
+ ActiveRecord::Type.register(:uuid, OID::Uuid, adapter: :materialize)
948
+ ActiveRecord::Type.register(:vector, OID::Vector, adapter: :materialize)
949
+ ActiveRecord::Type.register(:xml, OID::Xml, adapter: :materialize)
950
+ end
951
+ end
952
+ end