forest_admin_datasource_snowflake 1.29.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.
@@ -0,0 +1,37 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib)
3
+
4
+ require_relative 'lib/forest_admin_datasource_snowflake/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'forest_admin_datasource_snowflake'
8
+ spec.version = ForestAdminDatasourceSnowflake::VERSION
9
+ spec.authors = ['Forest Admin']
10
+ spec.email = ['support@forestadmin.com']
11
+ spec.homepage = 'https://www.forestadmin.com'
12
+ spec.summary = 'Snowflake datasource for Forest Admin (read-only).'
13
+ spec.description = 'Exposes Snowflake tables and views as Forest Admin collections via ODBC. ' \
14
+ 'Queries are translated from Forest ConditionTrees to parameterized SQL; ' \
15
+ 'no ActiveRecord involvement on the Snowflake side.'
16
+ spec.license = 'GPL-3.0'
17
+ spec.required_ruby_version = '>= 3.0.0'
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/ForestAdmin/agent-ruby'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/ForestAdmin/agent-ruby/blob/main/CHANGELOG.md'
22
+ spec.metadata['rubygems_mfa_required'] = 'false'
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = 'exe'
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ['lib']
33
+
34
+ spec.add_dependency 'connection_pool', '~> 2.4'
35
+ spec.add_dependency 'ruby-odbc', '~> 0.99999'
36
+ spec.add_dependency 'zeitwerk', '~> 2.3'
37
+ end
@@ -0,0 +1,141 @@
1
+ require 'odbc'
2
+ require 'date'
3
+ require 'json'
4
+
5
+ module ForestAdminDatasourceSnowflake
6
+ class Collection < ForestAdminDatasourceToolkit::Collection
7
+ READ_ONLY_MESSAGE = 'forest_admin_datasource_snowflake is read-only.'.freeze
8
+ ReadOnlyError = ForestAdminDatasourceToolkit::Exceptions::ForestException
9
+
10
+ attr_reader :table_name
11
+
12
+ def initialize(datasource, table_name)
13
+ super
14
+ @table_name = table_name
15
+ @json_columns = []
16
+ fetch_fields
17
+ enable_count
18
+ end
19
+
20
+ def list(_caller, filter, projection)
21
+ sql, binds = Utils::Query.new(self, projection: projection, filter: filter).to_sql
22
+ execute_to_hashes(sql, binds, projection.to_a)
23
+ end
24
+
25
+ def aggregate(_caller, filter, aggregation, limit = nil)
26
+ sql, binds, group_columns = Utils::Query.new(
27
+ self,
28
+ filter: filter,
29
+ aggregation: aggregation,
30
+ limit: limit
31
+ ).to_aggregate_sql
32
+
33
+ raw_rows = execute_raw(sql, binds)
34
+ raw_rows.map do |row|
35
+ value = coerce_value(row.first)
36
+ group_hash = group_columns.each_with_index.to_h { |col, i| [col, coerce_for_column(col, row[i + 1])] }
37
+ { 'value' => value, 'group' => group_hash }
38
+ end
39
+ end
40
+
41
+ def create(_caller, _data) = raise(ReadOnlyError, READ_ONLY_MESSAGE)
42
+ def update(_caller, _filter, _data) = raise(ReadOnlyError, READ_ONLY_MESSAGE)
43
+ def delete(_caller, _filter) = raise(ReadOnlyError, READ_ONLY_MESSAGE)
44
+
45
+ private
46
+
47
+ def fetch_fields
48
+ rows = @datasource.snowflake_columns_for(@table_name)
49
+ pk_names = resolve_primary_keys(rows)
50
+
51
+ rows.each do |row|
52
+ column_name = row[1]
53
+ snowflake_type = row[2]
54
+ nullable = row[3].to_s.casecmp('YES').zero?
55
+
56
+ forest_type = Parser::Column.forest_type_for_snowflake_native(snowflake_type)
57
+ @json_columns << column_name if forest_type == 'Json'
58
+
59
+ field = ForestAdminDatasourceToolkit::Schema::ColumnSchema.new(
60
+ column_type: forest_type,
61
+ filter_operators: Parser::Column.operators_for_column_type(forest_type),
62
+ is_primary_key: pk_names.include?(column_name),
63
+ is_read_only: true,
64
+ is_sortable: true,
65
+ default_value: nil,
66
+ enum_values: [],
67
+ validation: nullable ? [] : [{ operator: 'Present' }]
68
+ )
69
+ add_field(column_name, field)
70
+ end
71
+ end
72
+
73
+ def resolve_primary_keys(rows)
74
+ column_names = rows.map { |r| r[1] }
75
+ override = @datasource.primary_keys_override_for(@table_name)
76
+ return resolve_override_primary_keys(override, column_names) if override
77
+
78
+ declared_pks = @datasource.primary_keys_for(@table_name)
79
+ matches = declared_pks.filter_map do |pk|
80
+ column_names.find { |name| name.to_s.casecmp(pk.to_s).zero? }
81
+ end
82
+ return matches if matches.any?
83
+
84
+ fallback = (rows.find { |r| r[1].to_s.casecmp('id').zero? } || rows.first)&.dig(1)
85
+ fallback ? [fallback] : []
86
+ end
87
+
88
+ def resolve_override_primary_keys(override, column_names)
89
+ override.map do |declared_pk|
90
+ column_names.find { |name| name.to_s.casecmp(declared_pk.to_s).zero? } ||
91
+ raise(ForestAdminDatasourceSnowflake::Error,
92
+ "primary_keys override '#{declared_pk}' does not match any column on table " \
93
+ "'#{@table_name}' (available: #{column_names.join(", ")})")
94
+ end
95
+ end
96
+
97
+ def execute_to_hashes(sql, binds, projected_columns)
98
+ rows = execute_raw(sql, binds)
99
+ rows.map { |row| projected_columns.each_with_index.to_h { |col, i| [col, coerce_for_column(col, row[i])] } }
100
+ end
101
+
102
+ def coerce_for_column(column_name, value)
103
+ return parse_json_value(value) if @json_columns.include?(column_name)
104
+
105
+ coerce_value(value)
106
+ end
107
+
108
+ def coerce_value(value)
109
+ case value
110
+ when ::ODBC::TimeStamp
111
+ ::Time.utc(value.year, value.month, value.day, value.hour, value.minute, value.second)
112
+ when ::ODBC::Date
113
+ ::Date.new(value.year, value.month, value.day)
114
+ when ::ODBC::Time
115
+ format('%<h>02d:%<m>02d:%<s>02d', h: value.hour, m: value.minute, s: value.second)
116
+ else
117
+ value
118
+ end
119
+ end
120
+
121
+ def parse_json_value(value)
122
+ return value unless value.is_a?(String)
123
+
124
+ JSON.parse(value)
125
+ rescue JSON::ParserError
126
+ value
127
+ end
128
+
129
+ def execute_raw(sql, binds)
130
+ @datasource.with_connection do |conn|
131
+ stmt = conn.prepare(sql)
132
+ begin
133
+ stmt.execute(*binds)
134
+ stmt.fetch_all || []
135
+ ensure
136
+ stmt.drop
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,258 @@
1
+ require 'odbc'
2
+ require 'connection_pool'
3
+
4
+ module ForestAdminDatasourceSnowflake
5
+ class Datasource < ForestAdminDatasourceToolkit::Datasource
6
+ DEFAULT_POOL_SIZE = 5
7
+ DEFAULT_POOL_TIMEOUT = 5
8
+
9
+ CONNECTION_LOST_PATTERNS = [
10
+ /Communication link failure/i,
11
+ /Connection.*lost/i,
12
+ /Connection is closed/i,
13
+ /Session.*expired/i,
14
+ /Session.*timed out/i,
15
+ /Broken pipe/i,
16
+ /Not connected/i,
17
+ /timeout expired/i,
18
+ /Authentication token has expired/i,
19
+ /token.*expired/i
20
+ ].freeze
21
+
22
+ SYSTEM_SCHEMAS = %w[INFORMATION_SCHEMA].freeze
23
+
24
+ attr_reader :pool
25
+
26
+ def initialize(conn_str:,
27
+ pool_size: DEFAULT_POOL_SIZE, pool_timeout: DEFAULT_POOL_TIMEOUT,
28
+ statement_timeout: nil, primary_keys: nil)
29
+ super()
30
+ @schema_override = extract_schema_from_conn_str(conn_str)
31
+ @statement_timeout = statement_timeout
32
+ @primary_keys_override = (primary_keys || {}).transform_keys { |k| k.to_s.upcase }
33
+ @pool = ConnectionPool.new(size: pool_size, timeout: pool_timeout) do
34
+ open_connection(conn_str)
35
+ end
36
+
37
+ @schema_override ||= resolve_default_schema!
38
+
39
+ generate_collections
40
+ discover_relations
41
+ end
42
+
43
+ def with_connection(&block)
44
+ retried = false
45
+ begin
46
+ @pool.with(&block)
47
+ rescue ::ODBC::Error => e
48
+ if !retried && connection_lost?(e)
49
+ retried = true
50
+ reset_pool!
51
+ retry
52
+ end
53
+ raise
54
+ end
55
+ end
56
+
57
+ def shutdown!
58
+ @pool.shutdown { |conn| safe_disconnect(conn) }
59
+ end
60
+
61
+ def primary_keys_for(table_name)
62
+ upper = table_name.to_s.upcase
63
+ return Array(@primary_keys_override[upper]) if @primary_keys_override.key?(upper)
64
+
65
+ (snowflake_primary_keys || {})[upper] || []
66
+ end
67
+
68
+ def primary_keys_override_for(table_name)
69
+ upper = table_name.to_s.upcase
70
+ return nil unless @primary_keys_override.key?(upper)
71
+
72
+ Array(@primary_keys_override[upper])
73
+ end
74
+
75
+ def snowflake_columns_for(table_name)
76
+ (snowflake_columns || {})[table_name.to_s.upcase] || []
77
+ end
78
+
79
+ private
80
+
81
+ def snowflake_columns
82
+ return @snowflake_columns if defined?(@snowflake_columns)
83
+
84
+ @snowflake_columns = fetch_snowflake_columns
85
+ end
86
+
87
+ def fetch_snowflake_columns
88
+ sql, binds = build_snowflake_columns_query
89
+ with_connection do |conn|
90
+ stmt = conn.prepare(sql)
91
+ begin
92
+ stmt.execute(*binds)
93
+ rows = stmt.fetch_all || []
94
+ rows.group_by { |r| r[0].to_s.upcase }
95
+ ensure
96
+ stmt.drop
97
+ end
98
+ end
99
+ rescue ::ODBC::Error => e
100
+ warn "[forest_admin_datasource_snowflake] column introspection failed: #{e.message}"
101
+ nil
102
+ end
103
+
104
+ def build_snowflake_columns_query
105
+ base = 'SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS '
106
+ filter = @schema_override ? 'WHERE TABLE_SCHEMA = ? ' : "WHERE TABLE_SCHEMA <> 'INFORMATION_SCHEMA' "
107
+ binds = @schema_override ? [@schema_override] : []
108
+ ["#{base}#{filter}ORDER BY TABLE_NAME, ORDINAL_POSITION", binds]
109
+ end
110
+
111
+ def snowflake_primary_keys
112
+ return @snowflake_primary_keys if defined?(@snowflake_primary_keys)
113
+
114
+ @snowflake_primary_keys = fetch_snowflake_primary_keys
115
+ end
116
+
117
+ def fetch_snowflake_primary_keys
118
+ with_connection do |conn|
119
+ stmt = conn.prepare('SHOW PRIMARY KEYS IN SCHEMA')
120
+ rows = begin
121
+ stmt.execute
122
+ stmt.fetch_all || []
123
+ ensure
124
+ stmt.drop
125
+ end
126
+
127
+ rows.group_by { |r| r[3].to_s.upcase }
128
+ .transform_values { |table_rows| table_rows.sort_by { |r| r[5].to_i }.map { |r| r[4].to_s } }
129
+ end
130
+ rescue ::ODBC::Error => e
131
+ warn "[forest_admin_datasource_snowflake] primary-key introspection failed: #{e.message}"
132
+ nil
133
+ end
134
+
135
+ def open_connection(conn_str)
136
+ driver = ::ODBC::Driver.new
137
+ driver.name = 'odbc'
138
+ driver.attrs = parse_conn_str(conn_str)
139
+ conn = ::ODBC::Database.new.drvconnect(driver)
140
+ apply_session_settings(conn)
141
+ conn
142
+ end
143
+
144
+ def parse_conn_str(conn_str)
145
+ conn_str.split(';').reject(&:empty?).to_h do |option|
146
+ pair = option.split('=', 2)
147
+ if pair.size != 2
148
+ raise ForestAdminDatasourceSnowflake::Error,
149
+ "Malformed connection string option '#{option}': expected 'key=value'."
150
+ end
151
+ pair
152
+ end
153
+ end
154
+
155
+ def extract_schema_from_conn_str(conn_str)
156
+ attrs = parse_conn_str(conn_str)
157
+ pair = attrs.find { |k, _| k.to_s.casecmp('schema').zero? }
158
+ value = pair && pair[1]
159
+ value if value && !value.empty?
160
+ end
161
+
162
+ def resolve_default_schema!
163
+ resolved = with_connection do |conn|
164
+ stmt = conn.prepare('SELECT CURRENT_SCHEMA()')
165
+ begin
166
+ stmt.execute
167
+ (stmt.fetch_all || []).first&.first
168
+ ensure
169
+ stmt.drop
170
+ end
171
+ end
172
+
173
+ return resolved if resolved && !resolved.to_s.empty?
174
+
175
+ raise ForestAdminDatasourceSnowflake::Error,
176
+ 'Snowflake session has no default schema; set Schema=<name> in the connection string ' \
177
+ 'so the datasource can scope introspection to a single schema.'
178
+ rescue ::ODBC::Error => e
179
+ raise ForestAdminDatasourceSnowflake::Error,
180
+ "Could not resolve default Snowflake schema (#{e.message}); set Schema=<name> in the connection string."
181
+ end
182
+
183
+ def apply_session_settings(conn)
184
+ run_session_statement(conn, "ALTER SESSION SET TIMEZONE = 'UTC'")
185
+ run_session_statement(conn, "USE SCHEMA #{Utils::Identifier.quote(@schema_override)}") if @schema_override
186
+
187
+ return unless @statement_timeout
188
+
189
+ seconds = Integer(@statement_timeout)
190
+ run_session_statement(conn, "ALTER SESSION SET STATEMENT_TIMEOUT_IN_SECONDS = #{seconds}")
191
+ end
192
+
193
+ def run_session_statement(conn, sql)
194
+ stmt = conn.prepare(sql)
195
+ begin
196
+ stmt.execute
197
+ ensure
198
+ stmt.drop
199
+ end
200
+ end
201
+
202
+ def connection_lost?(error)
203
+ message = error.message.to_s
204
+ CONNECTION_LOST_PATTERNS.any? { |pattern| pattern.match?(message) }
205
+ end
206
+
207
+ def reset_pool!
208
+ @pool.reload { |conn| safe_disconnect(conn) }
209
+ end
210
+
211
+ def safe_disconnect(connection)
212
+ connection.disconnect if connection.respond_to?(:disconnect)
213
+ rescue StandardError
214
+ nil
215
+ end
216
+
217
+ def generate_collections
218
+ visible_tables.each do |table_name|
219
+ add_collection(Collection.new(self, table_name))
220
+ end
221
+ end
222
+
223
+ def discover_relations
224
+ Parser::Relation.discover(self).each do |fk|
225
+ source = collections[fk[:source_table]]
226
+ next if source.nil? || collections[fk[:target_table]].nil?
227
+
228
+ relation = ForestAdminDatasourceToolkit::Schema::Relations::ManyToOneSchema.new(
229
+ foreign_collection: fk[:target_table],
230
+ foreign_key: fk[:source_column],
231
+ foreign_key_target: fk[:target_column]
232
+ )
233
+ relation_name = "#{fk[:source_column]}_#{fk[:target_table]}".downcase
234
+ source.add_field(relation_name, relation)
235
+ end
236
+ rescue ::ODBC::Error => e
237
+ warn "[forest_admin_datasource_snowflake] FK introspection skipped: #{e.message}"
238
+ end
239
+
240
+ def visible_tables
241
+ with_connection do |conn|
242
+ stmt = conn.tables
243
+ rows = begin
244
+ stmt.fetch_all || []
245
+ ensure
246
+ stmt.drop
247
+ end
248
+
249
+ rows
250
+ .map { |row| { catalog: row[0], schema: row[1], name: row[2], type: row[3] } }
251
+ .reject { |t| SYSTEM_SCHEMAS.include?(t[:schema].to_s.upcase) }
252
+ .reject { |t| t[:type].to_s.upcase == 'SYSTEM TABLE' }
253
+ .select { |t| @schema_override.nil? || t[:schema].to_s.casecmp(@schema_override).zero? }
254
+ .map { |t| t[:name] }
255
+ end
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,85 @@
1
+ module ForestAdminDatasourceSnowflake
2
+ module Parser
3
+ module Column
4
+ include ForestAdminDatasourceToolkit::Components::Query::ConditionTree
5
+
6
+ SNOWFLAKE_NATIVE_TYPE_TO_FOREST = {
7
+ 'NUMBER' => 'Number',
8
+ 'DECIMAL' => 'Number',
9
+ 'NUMERIC' => 'Number',
10
+ 'INT' => 'Number',
11
+ 'INTEGER' => 'Number',
12
+ 'BIGINT' => 'Number',
13
+ 'SMALLINT' => 'Number',
14
+ 'TINYINT' => 'Number',
15
+ 'BYTEINT' => 'Number',
16
+ 'FLOAT' => 'Number',
17
+ 'FLOAT4' => 'Number',
18
+ 'FLOAT8' => 'Number',
19
+ 'DOUBLE' => 'Number',
20
+ 'DOUBLE PRECISION' => 'Number',
21
+ 'REAL' => 'Number',
22
+ 'BOOLEAN' => 'Boolean',
23
+ 'TEXT' => 'String',
24
+ 'VARCHAR' => 'String',
25
+ 'CHAR' => 'String',
26
+ 'CHARACTER' => 'String',
27
+ 'STRING' => 'String',
28
+ 'DATE' => 'Dateonly',
29
+ 'TIME' => 'Time',
30
+ 'DATETIME' => 'Date',
31
+ 'TIMESTAMP' => 'Date',
32
+ 'TIMESTAMP_NTZ' => 'Date',
33
+ 'TIMESTAMP_LTZ' => 'Date',
34
+ 'TIMESTAMP_TZ' => 'Date',
35
+ 'VARIANT' => 'Json',
36
+ 'OBJECT' => 'Json',
37
+ 'ARRAY' => 'Json',
38
+ 'BINARY' => 'Binary',
39
+ 'VARBINARY' => 'Binary',
40
+ 'GEOGRAPHY' => 'String',
41
+ 'GEOMETRY' => 'String',
42
+ 'VECTOR' => 'String'
43
+ }.freeze
44
+
45
+ module_function
46
+
47
+ def forest_type_for_snowflake_native(snowflake_type)
48
+ SNOWFLAKE_NATIVE_TYPE_TO_FOREST[snowflake_type.to_s.upcase] || 'String'
49
+ end
50
+
51
+ def operators_for_column_type(type)
52
+ result = [Operators::PRESENT, Operators::BLANK, Operators::MISSING]
53
+ equality = [Operators::EQUAL, Operators::NOT_EQUAL, Operators::IN, Operators::NOT_IN]
54
+ orderables = [
55
+ Operators::LESS_THAN,
56
+ Operators::GREATER_THAN,
57
+ Operators::LESS_THAN_OR_EQUAL,
58
+ Operators::GREATER_THAN_OR_EQUAL
59
+ ]
60
+ strings = [
61
+ Operators::CONTAINS,
62
+ Operators::I_CONTAINS,
63
+ Operators::NOT_CONTAINS,
64
+ Operators::NOT_I_CONTAINS,
65
+ Operators::STARTS_WITH,
66
+ Operators::I_STARTS_WITH,
67
+ Operators::ENDS_WITH,
68
+ Operators::I_ENDS_WITH,
69
+ Operators::LIKE,
70
+ Operators::I_LIKE,
71
+ Operators::SHORTER_THAN,
72
+ Operators::LONGER_THAN
73
+ ]
74
+
75
+ return result unless type.is_a?(String)
76
+
77
+ result += equality if %w[Boolean Binary Enum Uuid Json].include?(type)
78
+ result += equality + orderables if %w[Date Dateonly Time Number].include?(type)
79
+ result += equality + orderables + strings if type == 'String'
80
+
81
+ result
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,35 @@
1
+ module ForestAdminDatasourceSnowflake
2
+ module Parser
3
+ module Relation
4
+ PK_TABLE_NAME_IDX = 3
5
+ PK_COLUMN_NAME_IDX = 4
6
+ FK_TABLE_NAME_IDX = 7
7
+ FK_COLUMN_NAME_IDX = 8
8
+
9
+ QUERY = 'SHOW IMPORTED KEYS IN SCHEMA'.freeze
10
+
11
+ module_function
12
+
13
+ def discover(datasource)
14
+ rows = datasource.with_connection do |conn|
15
+ stmt = conn.prepare(QUERY)
16
+ begin
17
+ stmt.execute
18
+ stmt.fetch_all || []
19
+ ensure
20
+ stmt.drop
21
+ end
22
+ end
23
+
24
+ rows.map do |row|
25
+ {
26
+ source_table: row[FK_TABLE_NAME_IDX],
27
+ source_column: row[FK_COLUMN_NAME_IDX],
28
+ target_table: row[PK_TABLE_NAME_IDX],
29
+ target_column: row[PK_COLUMN_NAME_IDX]
30
+ }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,14 @@
1
+ module ForestAdminDatasourceSnowflake
2
+ module Utils
3
+ module Identifier
4
+ module_function
5
+
6
+ def quote(name)
7
+ s = name.to_s
8
+ return s if s == '*'
9
+
10
+ %("#{s.gsub('"', '""')}")
11
+ end
12
+ end
13
+ end
14
+ end