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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/LICENSE +674 -0
- data/Rakefile +5 -0
- data/forest_admin_datasource_snowflake.gemspec +37 -0
- data/lib/forest_admin_datasource_snowflake/collection.rb +141 -0
- data/lib/forest_admin_datasource_snowflake/datasource.rb +258 -0
- data/lib/forest_admin_datasource_snowflake/parser/column.rb +85 -0
- data/lib/forest_admin_datasource_snowflake/parser/relation.rb +35 -0
- data/lib/forest_admin_datasource_snowflake/utils/identifier.rb +14 -0
- data/lib/forest_admin_datasource_snowflake/utils/query.rb +181 -0
- data/lib/forest_admin_datasource_snowflake/version.rb +3 -0
- data/lib/forest_admin_datasource_snowflake.rb +9 -0
- metadata +103 -0
|
@@ -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
|