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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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