activerecord-cockroachdb-adapter 6.0.0beta1 → 6.1.0.pre.beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,85 @@ module ActiveRecord
2
2
  module ConnectionAdapters
3
3
  module CockroachDB
4
4
  module PostgreSQLColumnMonkeyPatch
5
+ # most functions taken from activerecord-postgis-adapter spatial_column
6
+ # https://github.com/rgeo/activerecord-postgis-adapter/blob/master/lib/active_record/connection_adapters/postgis/spatial_column.rb
7
+ def initialize(name, default, sql_type_metadata = nil, null = true,
8
+ default_function = nil, collation: nil, comment: nil,
9
+ serial: nil, spatial: nil)
10
+ @sql_type_metadata = sql_type_metadata
11
+ @geographic = !!(sql_type_metadata.sql_type =~ /geography\(/i)
12
+
13
+ if spatial
14
+ # This case comes from an entry in the geometry_columns table
15
+ set_geometric_type_from_name(spatial[:type])
16
+ @srid = spatial[:srid].to_i
17
+ @has_z = !!spatial[:has_z]
18
+ @has_m = !!spatial[:has_m]
19
+ elsif @geographic
20
+ # Geographic type information is embedded in the SQL type
21
+ @srid = 4326
22
+ @has_z = @has_m = false
23
+ build_from_sql_type(sql_type_metadata.sql_type)
24
+ elsif sql_type =~ /geography|geometry|point|linestring|polygon/i
25
+ build_from_sql_type(sql_type_metadata.sql_type)
26
+ elsif sql_type_metadata.sql_type =~ /geography|geometry|point|linestring|polygon/i
27
+ # A geometry column with no geometry_columns entry.
28
+ # @geometric_type = geo_type_from_sql_type(sql_type)
29
+ build_from_sql_type(sql_type_metadata.sql_type)
30
+ end
31
+ super(name, default, sql_type_metadata, null, default_function,
32
+ collation: collation, comment: comment, serial: serial)
33
+ if spatial? && @srid
34
+ @limit = { srid: @srid, type: to_type_name(geometric_type) }
35
+ @limit[:has_z] = true if @has_z
36
+ @limit[:has_m] = true if @has_m
37
+ @limit[:geographic] = true if @geographic
38
+ end
39
+ end
40
+
41
+ attr_reader :geographic,
42
+ :geometric_type,
43
+ :has_m,
44
+ :has_z,
45
+ :srid
46
+
47
+ alias geographic? geographic
48
+ alias has_z? has_z
49
+ alias has_m? has_m
50
+
51
+ def limit
52
+ spatial? ? @limit : super
53
+ end
54
+
55
+ def spatial?
56
+ %i[geometry geography].include?(@sql_type_metadata.type)
57
+ end
58
+
5
59
  def serial?
6
- default_function == "unique_rowid()"
60
+ default_function == 'unique_rowid()'
61
+ end
62
+
63
+ private
64
+
65
+ def set_geometric_type_from_name(name)
66
+ @geometric_type = RGeo::ActiveRecord.geometric_type_from_name(name) || RGeo::Feature::Geometry
67
+ end
68
+
69
+ def build_from_sql_type(sql_type)
70
+ geo_type, @srid, @has_z, @has_m = OID::Spatial.parse_sql_type(sql_type)
71
+ set_geometric_type_from_name(geo_type)
72
+ end
73
+
74
+ def to_type_name(geometric_type)
75
+ name = geometric_type.type_name.underscore
76
+ case name
77
+ when 'point'
78
+ 'st_point'
79
+ when 'polygon'
80
+ 'st_polygon'
81
+ else
82
+ name
83
+ end
7
84
  end
8
85
  end
9
86
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CockroachDB
6
+ module ColumnMethods
7
+ def spatial(name, options = {})
8
+ raise "You must set a type. For example: 't.spatial type: :st_point'" unless options[:type]
9
+
10
+ column(name, options[:type], **options)
11
+ end
12
+
13
+ def geography(name, options = {})
14
+ column(name, :geography, **options)
15
+ end
16
+
17
+ def geometry(name, options = {})
18
+ column(name, :geometry, **options)
19
+ end
20
+
21
+ def geometry_collection(name, options = {})
22
+ column(name, :geometry_collection, **options)
23
+ end
24
+
25
+ def line_string(name, options = {})
26
+ column(name, :line_string, **options)
27
+ end
28
+
29
+ def multi_line_string(name, options = {})
30
+ column(name, :multi_line_string, **options)
31
+ end
32
+
33
+ def multi_point(name, options = {})
34
+ column(name, :multi_point, **options)
35
+ end
36
+
37
+ def multi_polygon(name, options = {})
38
+ column(name, :multi_polygon, **options)
39
+ end
40
+
41
+ def st_point(name, options = {})
42
+ column(name, :st_point, **options)
43
+ end
44
+
45
+ def st_polygon(name, options = {})
46
+ column(name, :st_polygon, **options)
47
+ end
48
+ end
49
+ end
50
+
51
+ PostgreSQL::Table.include CockroachDB::ColumnMethods
52
+ end
53
+ end
@@ -0,0 +1,126 @@
1
+ # frozen-string-literal: true
2
+
3
+ require "active_support/duration"
4
+
5
+ module ActiveRecord
6
+ module ConnectionAdapters
7
+ module CockroachDB
8
+ module OID
9
+ module Interval # :nodoc:
10
+ DEFAULT_PRECISION = 6 # microseconds
11
+
12
+ def cast_value(value)
13
+ case value
14
+ when ::ActiveSupport::Duration
15
+ value
16
+ when ::String
17
+ begin
18
+ PostgresqlInterval::Parser.parse(value)
19
+ rescue PostgresqlInterval::ParseError
20
+ # Try ISO 8601
21
+ super
22
+ end
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ def serialize(value)
29
+ precision = self.precision || DEFAULT_PRECISION
30
+ case value
31
+ when ::ActiveSupport::Duration
32
+ serialize_duration(value, precision)
33
+ when ::Numeric
34
+ serialize_duration(value.seconds, precision)
35
+ else
36
+ super
37
+ end
38
+ end
39
+
40
+ def type_cast_for_schema(value)
41
+ serialize(value).inspect
42
+ end
43
+
44
+ private
45
+
46
+ # Convert an ActiveSupport::Duration to
47
+ # the postgres interval style
48
+ # ex. 1 year 2 mons 3 days 4 hours 5 minutes 6 seconds
49
+ def serialize_duration(value, precision)
50
+ yrs = value.parts.fetch(:years, 0)
51
+ mons = value.parts.fetch(:months, 0)
52
+ days = value.parts.fetch(:days, 0)
53
+ hrs = value.parts.fetch(:hours, 0)
54
+ mins = value.parts.fetch(:minutes, 0)
55
+ secs = value.parts.fetch(:seconds, 0).round(precision)
56
+
57
+ "#{yrs} years #{mons} mons #{days} days #{hrs} hours #{mins} minutes #{secs} seconds"
58
+ end
59
+ end
60
+
61
+ PostgreSQL::OID::Interval.prepend(Interval)
62
+ end
63
+
64
+ module PostgresqlInterval
65
+ class Parser
66
+ PARTS = ActiveSupport::Duration::PARTS
67
+ PARTS_IN_SECONDS = ActiveSupport::Duration::PARTS_IN_SECONDS
68
+
69
+ # modified regex from https://github.com/jeremyevans/sequel/blob/master/lib/sequel/extensions/pg_interval.rb#L86
70
+ REGEX = /\A([+-]?\d+ years?\s?)?([+-]?\d+ mons?\s?)?([+-]?\d+ days?\s?)?(?:([+-])?(\d{2,10}):(\d\d):(\d\d(\.\d+)?))?\z/
71
+
72
+ def self.parse(string)
73
+ matches = REGEX.match(string)
74
+ raise(ParseError) unless matches
75
+
76
+ # 1 => years, 2 => months, 3 => days, 4 => nil, 5 => hours,
77
+ # 6 => minutes, 7 => seconds with fraction digits, 8 => fractional portion of 7
78
+ duration = 0
79
+ parts = {}
80
+
81
+ if matches[1]
82
+ val = matches[1].to_i
83
+ duration += val * PARTS_IN_SECONDS[:years]
84
+ parts[:years] = val
85
+ end
86
+
87
+ if matches[2]
88
+ val = matches[2].to_i
89
+ duration += val * PARTS_IN_SECONDS[:months]
90
+ parts[:months] = val
91
+ end
92
+
93
+ if matches[3]
94
+ val = matches[3].to_i
95
+ duration += val * PARTS_IN_SECONDS[:days]
96
+ parts[:days] = val
97
+ end
98
+
99
+ if matches[5]
100
+ val = matches[5].to_i
101
+ duration += val * PARTS_IN_SECONDS[:hours]
102
+ parts[:hours] = val
103
+ end
104
+
105
+ if matches[6]
106
+ val = matches[6].to_i
107
+ duration += val * PARTS_IN_SECONDS[:minutes]
108
+ parts[:minutes] = val
109
+ end
110
+
111
+ if matches[7]
112
+ val = matches[7].to_f
113
+ duration += val * PARTS_IN_SECONDS[:seconds]
114
+ parts[:seconds] = val
115
+ end
116
+
117
+ ActiveSupport::Duration.new(duration, parts)
118
+ end
119
+ end
120
+
121
+ class ParseError < StandardError
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module CockroachDB
6
+ module OID
7
+ class Spatial < Type::Value
8
+ # sql_type is a string that comes from the database definition
9
+ # examples:
10
+ # "geometry(Point,4326)"
11
+ # "geography(Point,4326)"
12
+ # "geometry(Polygon,4326) NOT NULL"
13
+ # "geometry(Geography,4326)"
14
+ def initialize(oid, sql_type)
15
+ @sql_type = sql_type
16
+ @geo_type, @srid, @has_z, @has_m = self.class.parse_sql_type(sql_type)
17
+ end
18
+
19
+ # sql_type: geometry, geometry(Point), geometry(Point,4326), ...
20
+ #
21
+ # returns [geo_type, srid, has_z, has_m]
22
+ # geo_type: geography, geometry, point, line_string, polygon, ...
23
+ # srid: 1234
24
+ # has_z: false
25
+ # has_m: false
26
+ def self.parse_sql_type(sql_type)
27
+ geo_type = nil
28
+ srid = 0
29
+ has_z = false
30
+ has_m = false
31
+
32
+ if sql_type =~ /(geography|geometry)\((.*)\)$/i
33
+ # geometry(Point)
34
+ # geometry(Point,4326)
35
+ params = Regexp.last_match(2).split(',')
36
+ if params.first =~ /([a-z]+[^zm])(z?)(m?)/i
37
+ has_z = Regexp.last_match(2).length > 0
38
+ has_m = Regexp.last_match(3).length > 0
39
+ geo_type = Regexp.last_match(1)
40
+ end
41
+ srid = Regexp.last_match(1).to_i if params.last =~ /(\d+)/
42
+ else
43
+ geo_type = sql_type
44
+ end
45
+ [geo_type, srid, has_z, has_m]
46
+ end
47
+
48
+ def spatial_factory
49
+ @spatial_factory ||=
50
+ RGeo::ActiveRecord::SpatialFactoryStore.instance.factory(
51
+ factory_attrs
52
+ )
53
+ end
54
+
55
+ def geographic?
56
+ @sql_type =~ /geography/
57
+ end
58
+
59
+ def spatial?
60
+ true
61
+ end
62
+
63
+ def type
64
+ geographic? ? :geography : :geometry
65
+ end
66
+
67
+ # support setting an RGeo object or a WKT string
68
+ def serialize(value)
69
+ return if value.nil?
70
+
71
+ geo_value = cast_value(value)
72
+
73
+ # TODO: - only valid types should be allowed
74
+ # e.g. linestring is not valid for point column
75
+ # raise "maybe should raise" unless RGeo::Feature::Geometry.check_type(geo_value)
76
+
77
+ RGeo::WKRep::WKBGenerator.new(hex_format: true, type_format: :ewkb, emit_ewkb_srid: true)
78
+ .generate(geo_value)
79
+ end
80
+
81
+ private
82
+
83
+ def cast_value(value)
84
+ return if value.nil?
85
+
86
+ value.is_a?(String) ? parse_wkt(value) : value
87
+ end
88
+
89
+ # convert WKT string into RGeo object
90
+ def parse_wkt(string)
91
+ wkt_parser(string).parse(string)
92
+ rescue RGeo::Error::ParseError
93
+ nil
94
+ end
95
+
96
+ def binary_string?(string)
97
+ string[0] == "\x00" || string[0] == "\x01" || string[0, 4] =~ /[0-9a-fA-F]{4}/
98
+ end
99
+
100
+ def wkt_parser(string)
101
+ if binary_string?(string)
102
+ RGeo::WKRep::WKBParser.new(spatial_factory, support_ewkb: true, default_srid: @srid)
103
+ else
104
+ RGeo::WKRep::WKTParser.new(spatial_factory, support_ewkt: true, default_srid: @srid)
105
+ end
106
+ end
107
+
108
+ def factory_attrs
109
+ {
110
+ geo_type: @geo_type.underscore,
111
+ has_m: @has_m,
112
+ has_z: @has_z,
113
+ srid: @srid,
114
+ sql_type: type.to_s
115
+ }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,26 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module CockroachDB
4
+ module OID
5
+ module TypeMapInitializer
6
+ # override
7
+ # Replaces the query with a faster version that doesn't rely on the
8
+ # use of 'array_in(cstring,oid,integer)'::regprocedure.
9
+ def query_conditions_for_initial_load
10
+ known_type_names = @store.keys.map { |n| "'#{n}'" }
11
+ known_type_types = %w('r' 'e' 'd')
12
+ <<~SQL % [known_type_names.join(", "), known_type_types.join(", ")]
13
+ WHERE
14
+ t.typname IN (%s)
15
+ OR t.typtype IN (%s)
16
+ OR (t.typarray = 0 AND t.typcategory='A')
17
+ OR t.typelem != 0
18
+ SQL
19
+ end
20
+ end
21
+
22
+ PostgreSQL::OID::TypeMapInitializer.prepend(TypeMapInitializer)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -14,10 +14,18 @@ module ActiveRecord
14
14
  # always be strings. Then, we won't have to make any additional changes
15
15
  # to ActiveRecord to support inserting integer values into string
16
16
  # columns.
17
+ #
18
+ # For spatial types, data is stored as Well-known Binary (WKB) strings
19
+ # (https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry#Well-known_binary)
20
+ # but when creating objects, using RGeo features is more convenient than
21
+ # converting to WKB, so this does it automatically.
17
22
  def _quote(value)
18
- case value
19
- when Numeric
23
+ if value.is_a?(Numeric)
20
24
  "'#{quote_string(value.to_s)}'"
25
+ elsif RGeo::Feature::Geometry.check_type(value)
26
+ "'#{RGeo::WKRep::WKBGenerator.new(hex_format: true, type_format: :ewkb, emit_ewkb_srid: true).generate(value)}'"
27
+ elsif value.is_a?(RGeo::Cartesian::BoundingBox)
28
+ "'#{value.min_x},#{value.min_y},#{value.max_x},#{value.max_y}'::box"
21
29
  else
22
30
  super
23
31
  end
@@ -4,7 +4,7 @@ module ActiveRecord
4
4
  module SchemaStatements
5
5
  include ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaStatements
6
6
 
7
- def add_index(table_name, column_name, options = {})
7
+ def add_index(table_name, column_name, **options)
8
8
  super
9
9
  rescue ActiveRecord::StatementInvalid => error
10
10
  if debugging? && error.cause.class == PG::FeatureNotSupported
@@ -39,8 +39,43 @@ module ActiveRecord
39
39
  nil
40
40
  end
41
41
 
42
+ # override
43
+ # https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb#L624
44
+ def new_column_from_field(table_name, field)
45
+ column_name, type, default, notnull, oid, fmod, collation, comment = field
46
+ type_metadata = fetch_type_metadata(column_name, type, oid.to_i, fmod.to_i)
47
+ default_value = extract_value_from_default(default)
48
+ default_function = extract_default_function(default_value, default)
49
+
50
+ serial =
51
+ if (match = default_function&.match(/\Anextval\('"?(?<sequence_name>.+_(?<suffix>seq\d*))"?'::regclass\)\z/))
52
+ sequence_name_from_parts(table_name, column_name, match[:suffix]) == match[:sequence_name]
53
+ end
54
+
55
+ # {:dimension=>2, :has_m=>false, :has_z=>false, :name=>"latlon", :srid=>0, :type=>"GEOMETRY"}
56
+ spatial = spatial_column_info(table_name).get(column_name, type_metadata.sql_type)
57
+
58
+ PostgreSQL::Column.new(
59
+ column_name,
60
+ default_value,
61
+ type_metadata,
62
+ !notnull,
63
+ default_function,
64
+ collation: collation,
65
+ comment: comment.presence,
66
+ serial: serial,
67
+ spatial: spatial
68
+ )
69
+ end
70
+
42
71
  # CockroachDB will use INT8 if the SQL type is INTEGER, so we make it use
43
72
  # INT4 explicitly when needed.
73
+ #
74
+ # For spatial columns, include the limit to properly format the column name
75
+ # since type alone is not enough to format the column.
76
+ # Ex. type_to_sql(:geography, limit: "Point,4326")
77
+ # => "geography(Point,4326)"
78
+ #
44
79
  def type_to_sql(type, limit: nil, precision: nil, scale: nil, array: nil, **) # :nodoc:
45
80
  sql = \
46
81
  case type.to_s
@@ -52,6 +87,8 @@ module ActiveRecord
52
87
  when 5..8; "int8"
53
88
  else super
54
89
  end
90
+ when "geometry", "geography"
91
+ "#{type}(#{limit})"
55
92
  else
56
93
  super
57
94
  end
@@ -86,6 +123,34 @@ module ActiveRecord
86
123
  query_value("SELECT setval(#{quote(quoted_sequence)}, #{max_pk ? max_pk : minvalue}, #{max_pk ? true : false})", "SCHEMA")
87
124
  end
88
125
  end
126
+
127
+ # override
128
+ def native_database_types
129
+ # Add spatial types
130
+ super.merge(
131
+ geography: { name: "geography" },
132
+ geometry: { name: "geometry" },
133
+ geometry_collection: { name: "geometry_collection" },
134
+ line_string: { name: "line_string" },
135
+ multi_line_string: { name: "multi_line_string" },
136
+ multi_point: { name: "multi_point" },
137
+ multi_polygon: { name: "multi_polygon" },
138
+ spatial: { name: "geometry" },
139
+ st_point: { name: "st_point" },
140
+ st_polygon: { name: "st_polygon" }
141
+ )
142
+ end
143
+
144
+ # override
145
+ def create_table_definition(*args, **kwargs)
146
+ CockroachDB::TableDefinition.new(self, *args, **kwargs)
147
+ end
148
+
149
+ # memoize hash of column infos for tables
150
+ def spatial_column_info(table_name)
151
+ @spatial_column_info ||= {}
152
+ @spatial_column_info[table_name.to_sym] ||= SpatialColumnInfo.new(self, table_name.to_s)
153
+ end
89
154
  end
90
155
  end
91
156
  end