activerecord-materialize-adapter 0.2.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 (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