activerecord-trilogis-adapter 7.0.2 → 8.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95237ccbfc9b7709877c7f7adf0d60da083a0b64cf2e0521875c92905c512684
4
- data.tar.gz: 4f729bb255d9aaa5cba1ccfdf82fc832b7df068b1a93d5c35e7aa42c3bed887b
3
+ metadata.gz: c50e53fc7f5c471c74e8f587e0c0bdab734181845cd438359537735cc1adbe19
4
+ data.tar.gz: 930f29abad6e57fbbbe3d6e922a191c21cbb75365ef99233633e728a4e4b8b0e
5
5
  SHA512:
6
- metadata.gz: 35b148fb25fd3e22ce7053aa5cf1e3e2da2bd452b9d38487381ba87cba8b77fd4cd00bfb3d69e0804e69e81a650f30f280f825ac9d0845cc2ef3c9796945bc98
7
- data.tar.gz: da2129c0b41779c82e8ba538ffbec3f7473ee1207d4a2148ac87fbc7a09a03eb2cba8835b4c915a00605bef8cf5c25dabaef441a40f077f3195ccd8ee1d523ad
6
+ metadata.gz: 8c504d937064d0ffbd6a57a4c6bb854db79436d1e6b9f933e138aeaf155e769316a54f77a37fe1ca1c704e3c30dd4f7a519559e355ca3159533b21fd3cd1c694
7
+ data.tar.gz: 2cafe377ca75657049c47678ad97eb41d74f1990b96aaa859820822aa4c7aa5d32c50fb555b0c7d0f6958e0a5d91ff0d9eaa9e9b6aec4f7dc9c8fe788ef7905c
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ether
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,78 +1,153 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Arel # :nodoc:
4
- module Visitors # :nodoc:
5
- class Trilogis < MySQL # :nodoc:
6
-
3
+ module Arel
4
+ module Visitors
5
+ class Trilogis < Arel::Visitors::MySQL
7
6
  include RGeo::ActiveRecord::SpatialToSql
8
7
 
9
- if ::Arel::Visitors.const_defined?(:BindVisitor)
10
- include ::Arel::Visitors::BindVisitor
11
- end
12
-
13
- FUNC_MAP = {
14
- "st_wkttosql" => "ST_GeomFromText",
15
- "st_wkbtosql" => "ST_GeomFromWKB",
16
- "st_length" => "ST_Length"
8
+ # MySQL spatial function mappings
9
+ SPATIAL_FUNCTIONS = {
10
+ "st_contains" => "ST_Contains",
11
+ "st_crosses" => "ST_Crosses",
12
+ "st_disjoint" => "ST_Disjoint",
13
+ "st_distance" => "ST_Distance",
14
+ "st_equals" => "ST_Equals",
15
+ "st_intersects" => "ST_Intersects",
16
+ "st_overlaps" => "ST_Overlaps",
17
+ "st_touches" => "ST_Touches",
18
+ "st_within" => "ST_Within",
19
+ "st_area" => "ST_Area",
20
+ "st_length" => "ST_Length",
21
+ "st_buffer" => "ST_Buffer",
22
+ "st_centroid" => "ST_Centroid",
23
+ "st_envelope" => "ST_Envelope",
24
+ "st_geomfromtext" => "ST_GeomFromText",
25
+ "st_geomfromwkb" => "ST_GeomFromWKB",
26
+ "st_astext" => "ST_AsText",
27
+ "st_asbinary" => "ST_AsBinary",
28
+ "st_srid" => "ST_SRID"
17
29
  }.freeze
18
30
 
31
+ # Override st_func to use our function mappings
19
32
  def st_func(standard_name)
20
- FUNC_MAP[standard_name.downcase] || standard_name
21
- end
22
-
23
- def visit_String(node, collector)
24
- node, srid = Trilogis.parse_node(node)
25
- collector << wkttosql_statement(node, srid)
26
- end
27
-
28
- def visit_RGeo_ActiveRecord_SpatialNamedFunction(node, collector)
29
- aggregate(st_func(node.name), node, collector)
33
+ SPATIAL_FUNCTIONS[standard_name.downcase] || standard_name
30
34
  end
31
35
 
36
+ # Override visit_in_spatial_context to use our axis-order logic
32
37
  def visit_in_spatial_context(node, collector)
33
38
  case node
34
39
  when String
35
- node, srid = Trilogis.parse_node(node)
36
- collector << wkttosql_statement(node, srid)
40
+ visit_wkt_string(node, collector)
37
41
  when RGeo::Feature::Instance
38
- collector << visit_RGeo_Feature_Instance(node, collector)
42
+ visit_RGeo_Feature_Instance(node, collector)
39
43
  when RGeo::Cartesian::BoundingBox
40
- collector << visit_RGeo_Cartesian_BoundingBox(node, collector)
44
+ geom = node.to_geometry
45
+ visit_RGeo_Feature_Instance(geom, collector)
41
46
  else
42
47
  visit(node, collector)
43
48
  end
44
49
  end
45
50
 
46
- def self.parse_node(node)
47
- value, srid = nil, 0
48
- if node =~ /.*;.*$/i
49
- params = Regexp.last_match(0).split(";")
50
- if params.first =~ /(srid|SRID)=\d*/
51
- srid = params.first.split("=").last.to_i
51
+ def visit_spatial_value(node, collector)
52
+ case node
53
+ when RGeo::Feature::Instance
54
+ visit_RGeo_Feature_Instance(node, collector)
55
+ when String
56
+ if node.match?(/^[A-Z]/) # WKT string
57
+ visit_wkt_string(node, collector)
52
58
  else
53
- value = params.first
59
+ super
54
60
  end
55
- if params.last =~ /(srid|SRID)=\d*/
56
- srid = params.last.split("=").last.to_i
61
+ else
62
+ super
63
+ end
64
+ end
65
+
66
+ def visit_RGeo_Feature_Instance(node, collector)
67
+ srid = node.srid || 0
68
+ wkt = node.as_text
69
+
70
+ # MySQL ST_GeomFromText supports axis-order option for geographic SRIDs
71
+ # This ensures longitude-latitude order for SRID 4326 (WGS84)
72
+ if srid == 4326
73
+ collector << "ST_GeomFromText('#{wkt}', #{srid}, #{ActiveRecord::ConnectionAdapters::TrilogisAdapter::AXIS_ORDER_LONG_LAT})"
74
+ else
75
+ collector << "ST_GeomFromText('#{wkt}', #{srid})"
76
+ end
77
+ end
78
+
79
+ def visit_wkt_string(wkt, collector)
80
+ # Extract SRID if present in EWKT format
81
+ if wkt =~ /^SRID=(\d+);(.+)$/i
82
+ srid = Regexp.last_match(1).to_i
83
+ clean_wkt = Regexp.last_match(2)
84
+ # Use axis-order for geographic SRID
85
+ if srid == 4326
86
+ collector << "ST_GeomFromText('#{clean_wkt}', #{srid}, #{ActiveRecord::ConnectionAdapters::TrilogisAdapter::AXIS_ORDER_LONG_LAT})"
57
87
  else
58
- value = params.last
88
+ collector << "ST_GeomFromText('#{clean_wkt}', #{srid})"
59
89
  end
60
90
  else
61
- value = node
91
+ collector << "ST_GeomFromText('#{wkt}', 0)"
62
92
  end
63
- [value, srid]
64
93
  end
65
94
 
66
- private
95
+ # Handle spatial function calls
96
+ def visit_Arel_Nodes_NamedFunction(o, collector)
97
+ name = o.name.downcase
98
+ if SPATIAL_FUNCTIONS.key?(name)
99
+ collector << SPATIAL_FUNCTIONS[name]
100
+ collector << "("
101
+ o.expressions.each_with_index do |arg, i|
102
+ collector << ", " if i.positive?
103
+ # Handle string arguments (WKT/EWKT)
104
+ if arg.is_a?(String)
105
+ visit_wkt_string(arg, collector)
106
+ else
107
+ visit(arg, collector)
108
+ end
109
+ end
110
+ collector << ")"
111
+ else
112
+ super
113
+ end
114
+ end
67
115
 
68
- def wkttosql_statement(node, srid)
69
- func_name = st_func("ST_WKTToSQL")
116
+ # Override literal visiting for spatial values
117
+ def visit_Arel_Nodes_Quoted(o, collector)
118
+ if o.value.is_a?(RGeo::Feature::Instance)
119
+ visit_RGeo_Feature_Instance(o.value, collector)
120
+ else
121
+ super
122
+ end
123
+ end
70
124
 
71
- args = [quote(node)]
72
- args << srid unless srid.zero?
73
- args << ActiveRecord::ConnectionAdapters::TrilogisAdapter::AXIS_ORDER_LONG_LAT
125
+ # Handle RGeo spatial constant nodes from rgeo-activerecord gem
126
+ def visit_RGeo_ActiveRecord_SpatialConstantNode(node, collector)
127
+ value = node.delegate
128
+ if value.is_a?(RGeo::Feature::Instance)
129
+ visit_RGeo_Feature_Instance(value, collector)
130
+ elsif value.is_a?(String)
131
+ # Handle WKT strings
132
+ if value.match?(/^[A-Z]/)
133
+ visit_wkt_string(value, collector)
134
+ else
135
+ # Regular string literal
136
+ collector << quote(value)
137
+ end
138
+ else
139
+ # For numeric or other values
140
+ collector << quote(value)
141
+ end
142
+ end
74
143
 
75
- "#{func_name}(#{args.join(', ')})"
144
+ # Support for spatial predicates in WHERE clauses
145
+ def visit_spatial_predicate(predicate_name, left, right, collector)
146
+ collector << "#{SPATIAL_FUNCTIONS[predicate_name]}("
147
+ visit(left, collector)
148
+ collector << ", "
149
+ visit(right, collector)
150
+ collector << ")"
76
151
  end
77
152
  end
78
153
  end
@@ -1,23 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if defined?(Rails)
4
- require "rails/railtie"
5
-
6
- module ActiveRecord
7
- module ConnectionAdapters
8
- class TrilogisAdapter
9
- class Railtie < ::Rails::Railtie
3
+ module ActiveRecord
4
+ module ConnectionAdapters
5
+ module Trilogis
6
+ class Railtie < Rails::Railtie
7
+ initializer "trilogis.initialize" do
10
8
  ActiveSupport.on_load(:active_record) do
11
- require "active_record/connection_adapters/trilogis/connection"
12
- ActiveRecord::Base.public_send :extend, ActiveRecord::ConnectionAdapters::TrilogisAdapter::Connection
9
+ require "active_record/connection_adapters/trilogis_adapter"
10
+ end
11
+ end
12
+
13
+ # Register database tasks for spatial databases
14
+ rake_tasks do
15
+ namespace :db do
16
+ namespace :trilogis do
17
+ desc "Create spatial extensions if needed"
18
+ task setup: :environment do
19
+ ActiveRecord::Base.connection.execute(
20
+ "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() LIMIT 1"
21
+ )
22
+ puts "Trilogis adapter is ready. MySQL spatial support enabled."
23
+ end
24
+ end
13
25
  end
14
26
  end
15
27
  end
16
28
  end
17
29
  end
18
30
  end
19
-
20
- if defined?(Rails::DBConsole)
21
- require "trilogy_adapter/rails/dbconsole"
22
- Rails::DBConsole.prepend(ActiveRecord::ConnectionAdapters::TrilogisAdapter::Rails::DBConsole)
23
- end
@@ -3,16 +3,19 @@
3
3
  module ActiveRecord
4
4
  module ConnectionAdapters
5
5
  module Trilogis
6
- class SchemaCreation < MySQL::SchemaCreation # :nodoc:
6
+ class SchemaCreation < MySQL::SchemaCreation
7
7
  private
8
8
 
9
- def add_column_options!(sql, options)
10
- if options[:srid]
11
- sql << " /*!80003 SRID #{options[:srid]} */"
12
- end
13
-
14
- super
9
+ def add_column_options!(sql, options)
10
+ # Add SRID option for spatial columns in MySQL 8.0+
11
+ # Format: /*!80003 SRID #{srid} */
12
+ if options[:srid]
13
+ sql_result = "#{sql} /*!80003 SRID #{options[:srid]} */"
14
+ sql.replace(sql_result)
15
15
  end
16
+
17
+ super
18
+ end
16
19
  end
17
20
  end
18
21
  end
@@ -4,71 +4,202 @@ module ActiveRecord
4
4
  module ConnectionAdapters
5
5
  module Trilogis
6
6
  module SchemaStatements
7
- # super: https://github.com/rails/rails/blob/master/activerecord/lib/active_record/connection_adapters/mysql/schema_statements.rb
7
+ def type_to_sql(type, limit: nil, precision: nil, scale: nil, **)
8
+ if spatial_type?(type.to_s)
9
+ # If limit[:type] is specified, use it as the geometry type (e.g., "point")
10
+ # Otherwise use the base type (e.g., "geometry")
11
+ base_type = if limit.is_a?(Hash) && limit[:type]
12
+ limit[:type]
13
+ else
14
+ type
15
+ end
16
+ sql_type = spatial_sql_type(base_type, nil)
17
+ sql_type = "#{sql_type} SRID #{limit[:srid]}" if limit.is_a?(Hash) && limit[:srid]
18
+ sql_type
19
+ else
20
+ super
21
+ end
22
+ end
8
23
 
9
- # override
10
- def indexes(table_name) #:nodoc:
24
+ def add_index(table_name, column_name, **options)
25
+ index_type = options[:type]
26
+
27
+ # Handle spatial indexes - MySQL uses SPATIAL keyword, not USING
28
+ if index_type == :spatial
29
+ options = options.dup
30
+ options.delete(:using) # Remove any USING clause for spatial indexes
31
+ options[:type] = :spatial
32
+ end
33
+
34
+ super
35
+ end
36
+
37
+ def indexes(table_name)
11
38
  indexes = super
12
- # HACK(aleks, 06/15/18): MySQL 5 does not support prefix lengths for spatial indexes
13
- # https://dev.mysql.com/doc/refman/5.6/en/create-index.html
14
- indexes.select do |idx|
15
- idx.type == :spatial
16
- end.each { |idx| idx.is_a?(Struct) ? idx.lengths = {} : idx.instance_variable_set(:@lengths, {}) }
39
+
40
+ # MySQL doesn't support prefix lengths for spatial indexes
41
+ indexes.each do |index|
42
+ if index.using == :gist || index.comment&.include?("spatial") || index.type == :spatial
43
+ index.instance_variable_set(:@lengths, {})
44
+ end
45
+ end
46
+
17
47
  indexes
18
48
  end
19
49
 
20
- # override
21
- def type_to_sql(type, limit: nil, precision: nil, scale: nil, unsigned: nil, **) # :nodoc:
22
- if (info = RGeo::ActiveRecord.geometric_type_from_name(type.to_s.delete("_")))
23
- type = limit[:type] || type if limit.is_a?(::Hash)
24
- type = type.to_s.delete("_").upcase
50
+ # Override columns to use parent's implementation but enhance spatial columns
51
+ # DO NOT override - let parent class handle all column creation
52
+
53
+ # Override to properly handle spatial columns creation
54
+ def new_column_from_field(table_name, field, definitions = nil)
55
+ field_name = extract_field_value(field, :Field, :field)
56
+ sql_type = extract_field_value(field, :Type, :type)
57
+
58
+ if spatial_type?(sql_type)
59
+ build_spatial_column(table_name, field, field_name, sql_type)
60
+ else
61
+ super
62
+ end
63
+ end
64
+
65
+ def create_table_definition(name, **)
66
+ Trilogis::TableDefinition.new(self, name, **)
67
+ end
68
+
69
+ def create_table(table_name, **options, &)
70
+ # Clear spatial cache when creating table with force: true
71
+ # This ensures we don't have stale cache from a previously dropped table
72
+ clear_spatial_cache_for(table_name) if options[:force]
73
+ super
74
+ end
75
+
76
+ def add_column(table_name, column_name, type, **options)
77
+ if spatial_type?(type.to_s)
78
+ # Build ALTER TABLE statement for spatial column
79
+ sql_type = spatial_sql_type(type, options[:type])
80
+ base_sql = "ALTER TABLE #{quote_table_name(table_name)} " \
81
+ "ADD #{quote_column_name(column_name)} #{sql_type}"
82
+ sql_parts = [base_sql]
83
+
84
+ # Add SRID if specified
85
+ sql_parts << " SRID #{options[:srid]}" if options[:srid] && options[:srid] != 0
86
+
87
+ # Add NULL constraint
88
+ sql_parts << " NOT NULL" if options[:null] == false
89
+
90
+ # Add DEFAULT if specified (allow falsy values like 0/false)
91
+ sql_parts << " DEFAULT #{quote_default_expression(options[:default], nil)}" if options.key?(:default)
92
+
93
+ execute sql_parts.join
94
+
95
+ # Clear memoized spatial column info for this table
96
+ clear_spatial_cache_for(table_name)
97
+ else
98
+ super
25
99
  end
100
+ end
101
+
102
+ def drop_table(table_name, **options)
103
+ # Clear memoized spatial column info for this table before dropping
104
+ clear_spatial_cache_for(table_name)
26
105
  super
27
106
  end
28
107
 
108
+ def rename_table(table_name, new_name)
109
+ # Clear cache for both old and new table names
110
+ clear_spatial_cache_for(table_name)
111
+ clear_spatial_cache_for(new_name)
112
+ super
113
+ end
114
+
115
+ # Clear all spatial column caches (useful for tests)
116
+ def clear_spatial_cache!
117
+ @spatial_column_info = {}
118
+ end
119
+
120
+ def spatial_sql_type(base_type, subtype = nil)
121
+ sql_type = base_type.to_s.delete("_").upcase
122
+ subtype_sql = subtype.to_s
123
+ if subtype_sql.empty?
124
+ sql_type
125
+ else
126
+ "#{sql_type}(#{subtype_sql.delete('_').upcase})"
127
+ end
128
+ end
129
+
29
130
  private
30
131
 
31
- # override
132
+ def spatial_type?(type)
133
+ TrilogisAdapter::SPATIAL_COLUMN_TYPES.include?(type.to_s.downcase)
134
+ end
135
+
32
136
  def schema_creation
33
- Trilogis::SchemaCreation.new(self)
137
+ SchemaCreation.new(self)
34
138
  end
35
139
 
36
- # override
37
- def create_table_definition(*args, **options)
38
- Trilogis::TableDefinition.new(self, *args, **options)
140
+ # Memoized spatial column info per table
141
+ def spatial_column_info(table_name)
142
+ @spatial_column_info ||= {}
143
+ @spatial_column_info[table_name.to_sym] ||= SpatialColumnInfo.new(self, table_name.to_s)
39
144
  end
40
145
 
41
- # override
42
- def new_column_from_field(table_name, field)
43
- type_metadata = fetch_type_metadata(field[:Type], field[:Extra])
44
- default, default_function = field[:Default], nil
146
+ # Clear spatial cache for a specific table
147
+ def clear_spatial_cache_for(table_name)
148
+ @spatial_column_info&.delete(table_name.to_sym)
149
+ @spatial_column_info&.delete(table_name.to_s)
150
+ end
45
151
 
46
- if type_metadata.type == :datetime && /\ACURRENT_TIMESTAMP(?:\([0-6]?\))?\z/i.match?(default)
47
- default, default_function = nil, default
48
- elsif type_metadata.extra == "DEFAULT_GENERATED"
49
- default = +"(#{default})" unless default.start_with?("(")
50
- default, default_function = nil, default
152
+ # Extract field value with case-insensitive key lookup
153
+ def extract_field_value(field, *keys)
154
+ keys.each do |key|
155
+ return field[key] if field.key?(key)
156
+ return field[key.to_s] if field.key?(key.to_s)
51
157
  end
158
+ nil
159
+ end
52
160
 
53
- # {:dimension=>2, :has_m=>false, :has_z=>false, :name=>"latlon", :srid=>0, :type=>"GEOMETRY"}
54
- spatial = spatial_column_info(table_name).get(field[:Field], type_metadata.sql_type)
161
+ # Build a spatial column from field metadata
162
+ def build_spatial_column(table_name, field, field_name, sql_type)
163
+ spatial_info = spatial_column_info(table_name).get(field_name, sql_type)
164
+ type_metadata = fetch_type_metadata(sql_type)
55
165
 
56
166
  SpatialColumn.new(
57
- field[:Field],
58
- default,
167
+ field_name,
168
+ extract_field_value(field, :Default, :default),
59
169
  type_metadata,
60
- field[:Null] == "YES",
61
- default_function,
62
- collation: field[:Collation],
63
- comment: field[:Comment].presence,
64
- spatial: spatial
170
+ extract_field_value(field, :Null, :null) == "YES",
171
+ extract_field_value(field, :Extra, :extra),
172
+ collation: extract_field_value(field, :Collation, :collation),
173
+ comment: extract_field_value(field, :Comment, :comment).presence,
174
+ spatial_info: spatial_info
65
175
  )
66
176
  end
177
+ end
67
178
 
68
- # memoize hash of column infos for tables
69
- def spatial_column_info(table_name)
70
- @spatial_column_info ||= {}
71
- @spatial_column_info[table_name.to_sym] = SpatialColumnInfo.new(self, table_name.to_s)
179
+ class SchemaCreation < ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation
180
+ private
181
+
182
+ def visit_ColumnDefinition(o)
183
+ if spatial_column?(o)
184
+ sql_type = spatial_sql_type(o.sql_type, o.options[:type])
185
+ column_sql_parts = ["#{quote_column_name(o.name)} #{sql_type}"]
186
+
187
+ # Add SRID if specified (MySQL 8.0+ syntax: COLUMN TYPE SRID value)
188
+ column_sql_parts << " SRID #{o.options[:srid]}" if o.options[:srid] && o.options[:srid] != 0
189
+
190
+ column_sql_parts << " NOT NULL" unless o.null
191
+ column_sql_parts << " DEFAULT #{quote_default_expression(o.default, o)}" unless o.default.nil?
192
+ column_sql_parts.join
193
+ else
194
+ super
195
+ end
196
+ end
197
+
198
+ def spatial_column?(column)
199
+ # Check both with and without underscores
200
+ sql_type = column.sql_type.to_s.downcase
201
+ TrilogisAdapter::SPATIAL_COLUMN_TYPES.include?(sql_type) ||
202
+ TrilogisAdapter::SPATIAL_COLUMN_TYPES.include?(sql_type.delete("_"))
72
203
  end
73
204
  end
74
205
  end